diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 445 | ||||
| -rw-r--r-- | crates/fparkan-runtime/tests/handler19_anchor_native_oracle.cs | 158 | ||||
| -rw-r--r-- | crates/fparkan-runtime/tests/handler19_init_native_oracle.cs | 284 | ||||
| -rw-r--r-- | crates/fparkan-script/src/lib.rs | 410 |
4 files changed, 1241 insertions, 56 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index c7899c0..00e9cc5 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -16,8 +16,8 @@ use fparkan_prototype::{ }; use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository}; use fparkan_script::{ - Handler19DwordWrite, Handler19InitInput, ScriptDispatchSelector, ScriptInstruction, - ScriptPackage, VarSet, VarSetDefault, VmHostCallbackCommand, + Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError, + ScriptInstruction, ScriptPackage, VarSet, VarSetDefault, VmHostCallbackCommand, }; use fparkan_terrain::SurfaceQuery; use fparkan_vfs::{Vfs, VfsError}; @@ -166,6 +166,8 @@ pub struct MissionScriptBundle { /// Losslessly decoded compiled package; only proven `Handler(19)` Init /// writes are resolved and retained by runtime. pub package: ScriptPackage, + /// Outcome of the restricted first-`Init` resolution for this bundle. + pub init_execution: MissionScriptInitExecution, } /// Numeric script defaults selected for the mission's first script bundle. @@ -192,12 +194,36 @@ pub struct MissionScriptVarSet { pub struct MissionScriptInitState { /// TMA clan index that owns the `SuperAI` instance and varset. pub clan_index: usize, + /// Zero-based first case-sensitive C-string `Init` event selected by native lookup. + pub event_index: usize, /// Raw event word preserved from the compiled `Init` event. pub event_word: u32, /// Exact three DWORD writes made by each proven `Handler(19)` instruction. pub writes: Vec<Handler19DwordWrite>, } +/// Execution status of the first event whose case-sensitive C-string name is `Init`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MissionScriptInitExecution { + /// Native name lookup found no event named `Init` and made no handler call. + Missing, + /// The selected event completed within the currently recovered action subset. + Executed { + /// Zero-based event table index selected by native lookup. + event_index: usize, + }, + /// A preview retained the decoded package but stopped before the first + /// instruction outside the recovered `Handler(19)` action subset. + Unsupported { + /// Zero-based event table index selected by native lookup. + event_index: usize, + /// Zero-based instruction index where restricted resolution stopped. + instruction_index: usize, + /// Typed selector, control, or result-save shape that is not modeled. + error: Handler19ResolveError, + }, +} + /// Per-clan mutable-value model of one instantiated mission varset. /// /// Declarations remain shared source metadata; these values are the runtime @@ -418,6 +444,19 @@ pub enum EngineError { /// Proven-contract diagnostic. message: String, }, + /// A full mission load reached an unsupported instruction in its first Init event. + ScriptInitUnsupported { + /// Owning TMA clan index. + clan_index: usize, + /// Resolved `.scr` package path. + path: String, + /// Zero-based first exact Init event index. + event_index: usize, + /// Zero-based instruction index within that event. + instruction_index: usize, + /// Typed unsupported instruction shape. + source: Handler19ResolveError, + }, /// A recovered script handler could not resolve against runtime state. ScriptRuntime { /// Owning TMA clan index. @@ -558,6 +597,16 @@ impl std::fmt::Display for EngineError { clan_index, message, } => write!(f, "clan {clan_index}: script Init failed: {message}"), + Self::ScriptInitUnsupported { + clan_index, + path, + event_index, + instruction_index, + source, + } => write!( + f, + "{path}: clan {clan_index} Init event {event_index} instruction {instruction_index} is unsupported: {source}" + ), Self::ScriptRuntime { clan_index, message, @@ -594,6 +643,7 @@ impl std::error::Error for EngineError { Self::Nres { source, .. } => Some(source), Self::Mission { source, .. } => Some(source), Self::TerrainFormat { source, .. } => Some(source), + Self::ScriptInitUnsupported { source, .. } => Some(source), Self::Terrain(source) => Some(source), Self::World(source) => Some(source), Self::AssetPreparation { source, .. } => Some(source), @@ -736,7 +786,7 @@ fn load_mission_with_options( source, } })?; - let loaded_scripts = load_mission_scripts(&vfs, &mission)?; + let loaded_scripts = load_mission_scripts(&vfs, &mission, options.asset_scope)?; let repository = CachedResourceRepository::with_payload_cache_budget( vfs.clone(), MISSION_DECODED_PAYLOAD_CACHE_ENTRIES, @@ -1195,6 +1245,7 @@ fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPa fn load_mission_scripts( vfs: &Arc<dyn Vfs>, mission: &MissionDocument, + asset_scope: MissionAssetScope, ) -> Result<LoadedMissionScripts, EngineError> { let script_bases = mission_script_bundle_bases(mission); let mut bundles = Vec::with_capacity(script_bases.len()); @@ -1220,6 +1271,7 @@ fn load_mission_scripts( base_path, script_path: normalized.as_str().to_string(), package, + init_execution: MissionScriptInitExecution::Missing, }); } let varset = first_base_path @@ -1233,8 +1285,9 @@ fn load_mission_scripts( .iter() .map(|clan| clan.anchor) .collect::<Vec<_>>(), - &bundles, + &mut bundles, varset, + asset_scope, )?, None => Vec::new(), }; @@ -1292,11 +1345,21 @@ fn materialize_script_varset_states( fn resolve_handler19_init_states( clan_anchors: &[[f32; 2]], - bundles: &[MissionScriptBundle], + bundles: &mut [MissionScriptBundle], varset: &MissionScriptVarSet, + asset_scope: MissionAssetScope, ) -> Result<Vec<MissionScriptInitState>, EngineError> { let mut states = Vec::new(); for bundle in bundles { + if !bundle + .package + .events + .iter() + .any(|event| event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])) + { + bundle.init_execution = MissionScriptInitExecution::Missing; + continue; + } let Some(anchor) = clan_anchors.get(bundle.clan_index) else { return Err(EngineError::ScriptInit { clan_index: bundle.clan_index, @@ -1304,41 +1367,60 @@ fn resolve_handler19_init_states( }); }; let input = Handler19InitInput { - first_x87_word: handler19_x87_truncated_u32(anchor[0], bundle.clan_index, "x")?, - second_x87_word: handler19_x87_truncated_u32(anchor[1], bundle.clan_index, "y")?, + first_x87_word: handler19_anchor_word(anchor[0], bundle.clan_index, "x")?, + second_x87_word: handler19_anchor_word(anchor[1], bundle.clan_index, "y")?, third_word: u32::try_from(bundle.clan_index).map_err(|_| EngineError::ScriptInit { clan_index: bundle.clan_index, message: "clan index does not fit the recovered DWORD ClanID field".to_string(), })?, }; - for event in &bundle.package.events { - if event.name_raw.as_slice() != b"Init\0" { - continue; + match varset + .declarations + .resolve_handler19_init(&bundle.package, input) + .map_err(|source| EngineError::ScriptInit { + clan_index: bundle.clan_index, + message: source.to_string(), + })? { + Handler19InitResolution::Missing => { + bundle.init_execution = MissionScriptInitExecution::Missing; } - let writes: Vec<Handler19DwordWrite> = event - .instructions - .iter() - .filter(|instruction| { - instruction.dispatch_selector() == ScriptDispatchSelector::Handler(19) - }) - .map(|instruction| { - varset - .declarations - .resolve_handler19(instruction, input) - .map_err(|source| EngineError::ScriptInit { - clan_index: bundle.clan_index, - message: source.to_string(), - }) - }) - .collect::<Result<Vec<_>, _>>()? - .into_iter() - .flatten() - .collect(); - if !writes.is_empty() { - states.push(MissionScriptInitState { + Handler19InitResolution::Executed { + event_index, + event_word, + writes, + } => { + bundle.init_execution = MissionScriptInitExecution::Executed { event_index }; + if !writes.is_empty() { + states.push(MissionScriptInitState { + clan_index: bundle.clan_index, + event_index, + event_word, + writes, + }); + } + } + Handler19InitResolution::Unsupported { + event_index, + instruction_index, + error, + } if asset_scope != MissionAssetScope::Full => { + bundle.init_execution = MissionScriptInitExecution::Unsupported { + event_index, + instruction_index, + error, + }; + } + Handler19InitResolution::Unsupported { + event_index, + instruction_index, + error, + } => { + return Err(EngineError::ScriptInitUnsupported { clan_index: bundle.clan_index, - event_word: event.event_word, - writes, + path: bundle.script_path.clone(), + event_index, + instruction_index, + source: error, }); } } @@ -1346,24 +1428,29 @@ fn resolve_handler19_init_states( Ok(states) } -fn handler19_x87_truncated_u32( +fn handler19_anchor_word( value: f32, clan_index: usize, axis: &'static str, ) -> Result<u32, EngineError> { - if !value.is_finite() || !(0.0..=10_000.0).contains(&value) { + const SIGNED_QWORD_LIMIT: f32 = 9_223_372_036_854_775_808.0; + + let rounded = value.round_ties_even(); + if !value.is_finite() || rounded < -SIGNED_QWORD_LIMIT || rounded >= SIGNED_QWORD_LIMIT { return Err(EngineError::ScriptInit { clan_index, message: format!( - "ClanBase{axis}={value:?} is outside the recovered CreateSuperAI base range" + "ClanBase{axis}={value:?} is outside the modeled x87 FISTP qword range" ), }); } - // ai.dll's __ftol saves the x87 control word, sets rounding-control bits - // to truncate, performs `fistp qword`, and restores the control word. - let word = value.trunc() as u32; - Ok(word) + // Iron3D FISTP qword keeps its low DWORD; AI FUN_10005c70 then round-trips + // that unsigned word through f32 before Handler(19)'s truncating __ftol. + let creation_word = (rounded as i64) as u32; + let ai_field = creation_word as f32; + let handler_qword = ai_field.trunc() as i64; + Ok(handler_qword as u32) } fn load_script_varset( @@ -1412,6 +1499,7 @@ fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, Engi #[cfg(test)] mod tests { use super::*; + use fparkan_script::ScriptDispatchSelector; use fparkan_vfs::{DirectoryVfs, MemoryVfs, VfsEntry, VfsMetadata}; use std::path::{Path, PathBuf}; @@ -1544,7 +1632,7 @@ mod tests { used_fallback: true, declarations, }; - let bundles = [MissionScriptBundle { + let mut bundles = [MissionScriptBundle { clan_index: 1, base_path: "MISSIONS/SCRIPTS/default".to_string(), script_path: "MISSIONS/SCRIPTS/default.scr".to_string(), @@ -1555,22 +1643,28 @@ mod tests { name_raw: b"Init\0".to_vec(), event_word: 0x1234_5678, instructions: vec![fparkan_script::ScriptInstruction { - header_words: [19, 0, 0, 0, 0, 3, 0], + header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0], references: vec![0, 1, 2], }], }], trailing_bytes: Vec::new(), raw: Arc::from(&b""[..]), }, + init_execution: MissionScriptInitExecution::Missing, }]; - let states = - resolve_handler19_init_states(&[[500.0, 752.0], [728.0, 449.0]], &bundles, &varset) - .expect("exact Init execution"); + let states = resolve_handler19_init_states( + &[[500.0, 752.0], [728.0, 449.0]], + &mut bundles, + &varset, + MissionAssetScope::Full, + ) + .expect("exact Init execution"); assert_eq!( states, vec![MissionScriptInitState { clan_index: 1, + event_index: 0, event_word: 0x1234_5678, writes: vec![ Handler19DwordWrite { @@ -1618,16 +1712,162 @@ mod tests { } #[test] - fn handler_nineteen_init_rejects_anchor_outside_recovered_base_range() { - let error = handler19_x87_truncated_u32(-0.5, 0, "x").expect_err("negative anchor"); + fn handler_nineteen_preview_keeps_unsupported_package_without_partial_writes() { + let varset = MissionScriptVarSet { + path: FALLBACK_SCRIPT_VARSET_PATH.to_string(), + used_fallback: true, + declarations: fparkan_script::parse_varset( + b"VAR( DWORD, x, 950)\nVAR( DWORD, y, 1000)\nVAR( DWORD, id, 0)\n", + ) + .expect("varset"), + }; + let package = ScriptPackage { + opcode_handler_count: 73, + events: vec![fparkan_script::ScriptEvent { + name_len: 4, + name_raw: b"Init\0".to_vec(), + event_word: 0x1234, + instructions: vec![ + fparkan_script::ScriptInstruction { + header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0], + references: vec![0, 1, 2], + }, + fparkan_script::ScriptInstruction { + header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0], + references: Vec::new(), + }, + ], + }], + trailing_bytes: Vec::new(), + raw: Arc::from(&b""[..]), + }; + let bundle = || MissionScriptBundle { + clan_index: 0, + base_path: "MISSIONS/SCRIPTS/default".to_string(), + script_path: "MISSIONS/SCRIPTS/default.scr".to_string(), + package: package.clone(), + init_execution: MissionScriptInitExecution::Missing, + }; + let anchors = [[500.0, 752.0]]; + let mut preview_bundles = vec![bundle()]; + let preview_states = resolve_handler19_init_states( + &anchors, + &mut preview_bundles, + &varset, + MissionAssetScope::FirstMeshPreview, + ) + .expect("static preview retains unsupported script"); + assert!(preview_states.is_empty(), "no partial writes are retained"); + assert_eq!( + preview_bundles[0].init_execution, + MissionScriptInitExecution::Unsupported { + event_index: 0, + instruction_index: 1, + error: Handler19ResolveError::UnsupportedSelector { + found: ScriptDispatchSelector::Handler(18), + }, + } + ); + assert_eq!( + materialize_script_varset_states(&preview_bundles, &varset, &preview_states) + .expect("preview keeps default varset values"), + vec![MissionScriptVarSetState { + clan_index: 0, + values: vec![ + VarSetDefault::Dword(950), + VarSetDefault::Dword(1000), + VarSetDefault::Dword(0), + ], + }] + ); + assert_eq!(preview_bundles[0].package, package); + + let mut full_bundles = vec![bundle()]; + assert!(matches!( + resolve_handler19_init_states( + &anchors, + &mut full_bundles, + &varset, + MissionAssetScope::Full, + ), + Err(EngineError::ScriptInitUnsupported { + clan_index: 0, + event_index: 0, + instruction_index: 1, + source: Handler19ResolveError::UnsupportedSelector { + found: ScriptDispatchSelector::Handler(18), + }, + .. + }) + )); + + let mut malformed_package = package.clone(); + malformed_package.events[0].instructions[0] + .references + .clear(); + let mut malformed_bundle = vec![MissionScriptBundle { + package: malformed_package, + ..bundle() + }]; assert!(matches!( - error, - EngineError::ScriptInit { clan_index: 0, message } - if message.contains("outside the recovered CreateSuperAI base range") + resolve_handler19_init_states( + &anchors, + &mut malformed_bundle, + &varset, + MissionAssetScope::FirstMeshPreview, + ), + Err(EngineError::ScriptInit { message, .. }) + if message.contains("Handler(19) is missing reference 0") )); } #[test] + fn handler_nineteen_init_matches_original_x87_rounding_and_dword_path() { + for (value, expected) in [ + (500.2857, 500), + (751.8217, 752), + (500.5, 500), + (501.5, 502), + (-0.5, 0), + (-0.4, 0), + (-0.6, 0), + (10_000.4, 10_000), + (10_000.5, 10_000), + (10_000.6, 10_001), + (10_001.0, 10_001), + (4_294_967_296.0, 0), + (u32::MAX as f32, 0), + (4_294_967_808.0, 512), + ] { + assert_eq!( + handler19_anchor_word(value, 0, "x").expect("supported anchor"), + expected, + "anchor {value:?}" + ); + } + assert_eq!(u32::MAX as f32, 4_294_967_296.0); + } + + #[test] + fn handler_nineteen_init_rejects_unmodeled_x87_qword_conversions() { + let qword_limit = 9_223_372_036_854_775_808.0_f32; + assert_eq!( + handler19_anchor_word(-qword_limit, 0, "x").expect("valid signed qword minimum"), + 0, + "the signed qword minimum is representable before low-DWORD conversion" + ); + for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, qword_limit] { + let error = + handler19_anchor_word(value, 0, "x").expect_err("unmodeled FISTP qword input"); + assert!(matches!( + error, + EngineError::ScriptInit { clan_index: 0, message } + if message.contains("outside the modeled x87 FISTP qword range") + )); + } + } + + #[test] fn handler_thirty_host_callback_obeys_native_mode_and_status_guards() { fn command(second: u32) -> VmHostCallbackCommand { VmHostCallbackCommand { @@ -1752,6 +1992,111 @@ mod tests { #[test] #[ignore = "requires licensed corpus"] + fn licensed_autodemo_load_resolves_native_handler19_init_for_both_clans() { + let root = licensed_root("IS"); + let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); + let mut engine = create(EngineServices::new(vfs)).expect("engine"); + let loaded = load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".to_string(), + mode: MissionMode::AutoDemo, + }, + ) + .expect("load licensed AutoDemo"); + + assert_eq!( + loaded_mission_document(&engine) + .expect("mission document") + .clans + .iter() + .map(|clan| clan.anchor) + .collect::<Vec<_>>(), + vec![[500.2857, 751.8217], [728.0, 449.0]] + ); + assert_eq!(loaded.script_bundle_count, 2); + assert_eq!(loaded.script_init_state_count, 2); + assert_eq!(loaded.script_varset_state_count, 2); + assert!(loaded_mission_script_bundles(&engine) + .expect("script bundles") + .iter() + .all(|bundle| bundle.init_execution + == MissionScriptInitExecution::Executed { event_index: 0 })); + + let varsets = loaded_mission_script_varset_states(&engine).expect("per-clan varsets"); + let clan_zero = varsets + .iter() + .find(|state| state.clan_index == 0) + .expect("first clan varset"); + let clan_one = varsets + .iter() + .find(|state| state.clan_index == 1) + .expect("second clan varset"); + assert_eq!( + &clan_zero.values[224..227], + &[ + VarSetDefault::Dword(500), + VarSetDefault::Dword(752), + VarSetDefault::Dword(0), + ] + ); + assert_eq!( + &clan_one.values[224..227], + &[ + VarSetDefault::Dword(728), + VarSetDefault::Dword(449), + VarSetDefault::Dword(1), + ] + ); + } + + #[test] + #[ignore = "requires licensed corpus"] + fn licensed_campaign_preview_keeps_unsupported_init_without_partial_writes() { + let root = licensed_root("IS"); + let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); + let mut engine = create(EngineServices::new(vfs)).expect("engine"); + let loaded = load_mission_static_preview_roots( + &mut engine, + MissionRequest { + key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + NonZeroUsize::new(1).expect("non-zero root count"), + ) + .expect("load licensed campaign preview"); + + let bundles = loaded_mission_script_bundles(&engine).expect("script bundles"); + let unsupported = bundles + .iter() + .find(|bundle| { + matches!( + &bundle.init_execution, + MissionScriptInitExecution::Unsupported { .. } + ) + }) + .expect("campaign package with an unsupported Init instruction"); + let clan_index = unsupported.clan_index; + let states = loaded_mission_script_init_states(&engine).expect("Init writes"); + assert!(states.iter().all(|state| state.clan_index != clan_index)); + let varset = loaded_mission_script_varset_states(&engine) + .expect("per-clan varsets") + .iter() + .find(|state| state.clan_index == clan_index) + .expect("unsupported clan varset"); + assert_eq!( + &varset.values[224..227], + &[ + VarSetDefault::Dword(950), + VarSetDefault::Dword(1000), + VarSetDefault::Dword(0), + ] + ); + assert!(loaded.script_bundle_count >= 1); + } + + #[test] + #[ignore = "requires licensed corpus"] fn loaded_host_callback_uses_requested_mode_and_constructor_status() { let root = licensed_root("IS"); let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); diff --git a/crates/fparkan-runtime/tests/handler19_anchor_native_oracle.cs b/crates/fparkan-runtime/tests/handler19_anchor_native_oracle.cs new file mode 100644 index 0000000..294cfc1 --- /dev/null +++ b/crates/fparkan-runtime/tests/handler19_anchor_native_oracle.cs @@ -0,0 +1,158 @@ +using System; +using System.IO; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Cryptography; + +internal static class Iron3dAnchorOracle { + private const uint LoadWithAlteredSearchPath = 0x00000008; + private const uint PageExecuteReadWrite = 0x40; + private const uint MemCommitReserve = 0x3000; + private const uint MemRelease = 0x8000; + private const int CallerRva = 0x392e0; + private const int GameGlobalRva = 0x10a5f8; + private const int CreateSuperAiIatRva = 0xe4010; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr LoadLibraryEx(string path, IntPtr file, uint flags); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool FreeLibrary(IntPtr module); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr VirtualAlloc(IntPtr address, UIntPtr size, uint allocationType, uint protect); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool VirtualFree(IntPtr address, UIntPtr size, uint freeType); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool VirtualProtect(IntPtr address, UIntPtr size, uint protect, out uint oldProtect); + + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate void CreateAiDelegate(IntPtr record, int clanIndex, uint networkPlayer, int networkFlag); + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate IntPtr CreateSuperAiDelegate(uint p0, uint p1, uint p2, uint clanIndex, uint x, uint y, uint p6, uint p7, uint p8, uint callback); + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate void FpuWordOperation(ref ushort value); + + private static FpuWordOperation ReadControlWord; + private static uint CapturedX; + private static uint CapturedY; + private static ushort CapturedControlWord; + private static CreateSuperAiDelegate CaptureDelegate; + + private static IntPtr CaptureCreateSuperAi(uint p0, uint p1, uint p2, uint clanIndex, uint x, uint y, uint p6, uint p7, uint p8, uint callback) { + CapturedX = x; + CapturedY = y; + ReadControlWord(ref CapturedControlWord); + return IntPtr.Zero; + } + + private static void Require(bool value, string message) { + if (!value) throw new InvalidOperationException(message); + } + + private static void Expect(CreateAiDelegate caller, IntPtr record, FpuWordOperation setControlWord, ushort mode, float x, float y, uint expectedX, uint expectedY, string label) { + ushort requested = mode; + setControlWord(ref requested); + Marshal.WriteInt32(record, 4, BitConverter.ToInt32(BitConverter.GetBytes(x), 0)); + Marshal.WriteInt32(record, 8, BitConverter.ToInt32(BitConverter.GetBytes(y), 0)); + CapturedX = 0xdeadbeef; + CapturedY = 0xdeadbeef; + CapturedControlWord = 0; + caller(record, 0, 0, 0); + string actual = String.Format(CultureInfo.InvariantCulture, "{0}: cw=0x{1:x4} rc={2}, ({3:R}, {4:R}) -> (0x{5:x8}, 0x{6:x8})", label, CapturedControlWord, (CapturedControlWord >> 10) & 3, x, y, CapturedX, CapturedY); + Console.WriteLine(actual); + Require(CapturedX == expectedX && CapturedY == expectedY, label + " expected (0x" + expectedX.ToString("x8") + ",0x" + expectedY.ToString("x8") + ")"); + Require(((CapturedControlWord >> 10) & 3) == ((requested >> 10) & 3), label + " x87 RC changed before the native callback"); + } + + private static IntPtr BuildInstruction(byte[] bytes) { + IntPtr memory = VirtualAlloc(IntPtr.Zero, new UIntPtr(4096), MemCommitReserve, PageExecuteReadWrite); + if (memory == IntPtr.Zero) throw new InvalidOperationException("VirtualAlloc failed: " + Marshal.GetLastWin32Error()); + Marshal.Copy(bytes, 0, memory, bytes.Length); + return memory; + } + + private static void ProtectWrite(IntPtr address, int value, out uint oldProtect) { + if (!VirtualProtect(address, new UIntPtr(4), PageExecuteReadWrite, out oldProtect)) throw new InvalidOperationException("VirtualProtect failed: " + Marshal.GetLastWin32Error()); + Marshal.WriteInt32(address, value); + } + + private static void RestoreProtection(IntPtr address, uint oldProtect) { + uint ignored; + if (!VirtualProtect(address, new UIntPtr(4), oldProtect, out ignored)) throw new InvalidOperationException("VirtualProtect restore failed: " + Marshal.GetLastWin32Error()); + } + + private static void Main() { + Require(IntPtr.Size == 4, "This oracle must run as an x86 process"); + string dll = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_IRON3D_DLL"); + Require(!String.IsNullOrEmpty(dll) && File.Exists(dll), "Set FPARKAN_ORIGINAL_IRON3D_DLL to the installed iron3d.dll"); + string hash; + using (SHA256 sha = SHA256.Create()) using (FileStream stream = File.OpenRead(dll)) hash = BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "").ToLowerInvariant(); + Require(hash == "d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa", "unexpected iron3d.dll hash: " + hash); + + IntPtr module = LoadLibraryEx(dll, IntPtr.Zero, LoadWithAlteredSearchPath); + if (module == IntPtr.Zero) throw new InvalidOperationException("LoadLibraryEx failed: " + Marshal.GetLastWin32Error()); + IntPtr readCode = IntPtr.Zero; + IntPtr writeCode = IntPtr.Zero; + IntPtr game = Marshal.AllocHGlobal(0x40); + IntPtr manager = Marshal.AllocHGlobal(0x40); + IntPtr record = Marshal.AllocHGlobal(0x68); + uint oldIatProtection = 0; + int oldGlobal = Marshal.ReadInt32(IntPtr.Add(module, GameGlobalRva)); + int oldIat = Marshal.ReadInt32(IntPtr.Add(module, CreateSuperAiIatRva)); + bool iatChanged = false; + bool controlWordCaptured = false; + ushort originalControlWord = 0; + FpuWordOperation setControlWord = null; + try { + byte[] readBytes = { 0x8b, 0x44, 0x24, 0x04, 0xd9, 0x38, 0xc2, 0x04, 0x00 }; // mov eax,[esp+4]; fnstcw [eax]; ret 4 + byte[] writeBytes = { 0x8b, 0x44, 0x24, 0x04, 0xd9, 0x28, 0xc2, 0x04, 0x00 }; // mov eax,[esp+4]; fldcw [eax]; ret 4 + readCode = BuildInstruction(readBytes); + writeCode = BuildInstruction(writeBytes); + ReadControlWord = (FpuWordOperation)Marshal.GetDelegateForFunctionPointer(readCode, typeof(FpuWordOperation)); + setControlWord = (FpuWordOperation)Marshal.GetDelegateForFunctionPointer(writeCode, typeof(FpuWordOperation)); + ReadControlWord(ref originalControlWord); + controlWordCaptured = true; + Console.WriteLine("iron3d.dll sha256=" + hash + " loadedBase=0x" + module.ToInt32().ToString("x8") + " defaultCW=0x" + originalControlWord.ToString("x4") + " defaultRC=" + ((originalControlWord >> 10) & 3)); + + for (int offset = 0; offset < 0x40; offset += 4) { Marshal.WriteInt32(game, offset, 0); Marshal.WriteInt32(manager, offset, 0); } + for (int offset = 0; offset < 0x68; offset += 4) Marshal.WriteInt32(record, offset, 0); + Marshal.WriteInt32(game, 0x1c, manager.ToInt32()); + Marshal.WriteInt32(IntPtr.Add(module, GameGlobalRva), game.ToInt32()); + + CaptureDelegate = CaptureCreateSuperAi; + IntPtr callbackPointer = Marshal.GetFunctionPointerForDelegate(CaptureDelegate); + IntPtr iat = IntPtr.Add(module, CreateSuperAiIatRva); + ProtectWrite(iat, callbackPointer.ToInt32(), out oldIatProtection); + iatChanged = true; + + CreateAiDelegate caller = (CreateAiDelegate)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, CallerRva), typeof(CreateAiDelegate)); + ushort nearest = (ushort)(originalControlWord & ~0x0c00); + ushort down = (ushort)((originalControlWord & ~0x0c00) | 0x0400); + ushort up = (ushort)((originalControlWord & ~0x0c00) | 0x0800); + ushort truncate = (ushort)((originalControlWord & ~0x0c00) | 0x0c00); + + Expect(caller, record, setControlWord, nearest, 500.2857f, 751.8217f, 500, 752, "Autodemo anchors / nearest-even"); + Expect(caller, record, setControlWord, nearest, 500.5f, 501.5f, 500, 502, "nearest tie pair"); + Expect(caller, record, setControlWord, down, 500.5f, 501.5f, 500, 501, "down tie pair"); + Expect(caller, record, setControlWord, up, 500.5f, 501.5f, 501, 502, "up tie pair"); + Expect(caller, record, setControlWord, truncate, 500.5f, 501.5f, 500, 501, "truncate tie pair"); + Expect(caller, record, setControlWord, nearest, -0.4f, 10000.4f, 0, 10000, "round-in lower/upper boundary"); + Expect(caller, record, setControlWord, nearest, -0.6f, 10000.6f, 0xffffffff, 10001, "round-out lower/upper boundary"); + Expect(caller, record, setControlWord, nearest, 4294967296.0f, 4294967808.0f, 0, 512, "low-DWORD wrap boundary"); + } + finally { + if (iatChanged) { + IntPtr iat = IntPtr.Add(module, CreateSuperAiIatRva); + Marshal.WriteInt32(iat, oldIat); + RestoreProtection(iat, oldIatProtection); + } + Marshal.WriteInt32(IntPtr.Add(module, GameGlobalRva), oldGlobal); + if (controlWordCaptured) setControlWord(ref originalControlWord); + Marshal.FreeHGlobal(record); + Marshal.FreeHGlobal(manager); + Marshal.FreeHGlobal(game); + if (readCode != IntPtr.Zero) VirtualFree(readCode, UIntPtr.Zero, MemRelease); + if (writeCode != IntPtr.Zero) VirtualFree(writeCode, UIntPtr.Zero, MemRelease); + FreeLibrary(module); + } + } +} diff --git a/crates/fparkan-runtime/tests/handler19_init_native_oracle.cs b/crates/fparkan-runtime/tests/handler19_init_native_oracle.cs new file mode 100644 index 0000000..5dc319a --- /dev/null +++ b/crates/fparkan-runtime/tests/handler19_init_native_oracle.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; + +internal static class Handler19DispatchOracle { + private const uint LoadWithAlteredSearchPath = 0x00000008; + private const string ExpectedAiSha256 = "c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc"; + private const string ExpectedScriptSha256 = "db1c6fde57aff43181619f19e19e1cacf7301ebb859e8cb883db6b4b7181eced"; + private const int EventLookupRva = 0x11f40; + private const int EventDispatchRva = 0x11f10; + private const int Handler19Rva = 0x0aa38; + private const int EventStride = 0x14; + private const int InstructionStride = 0x20; + private const int VarSetStride = 0x30; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr LoadLibraryEx(string fileName, IntPtr file, uint flags); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool FreeLibrary(IntPtr module); + + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate int FindEvent(IntPtr vm, IntPtr name); + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate uint DispatchEvent(IntPtr vm, int eventIndex); + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate uint ReadVariable(IntPtr record); + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void SetDwordPosition(IntPtr record, uint x, uint y); + + private sealed class Instruction { + public readonly uint[] Words; + public readonly uint[] References; + public Instruction(uint[] words, uint[] references) { Words = words; References = references; } + } + private sealed class Event { + public readonly byte[] Name; + public readonly uint EventWord; + public readonly Instruction[] Instructions; + public Event(byte[] name, uint eventWord, Instruction[] instructions) { Name = name; EventWord = eventWord; Instructions = instructions; } + } + private sealed class Package { + public readonly uint HandlerCount; + public readonly Event[] Events; + public Package(uint handlerCount, Event[] events) { HandlerCount = handlerCount; Events = events; } + } + private sealed class Instance : IDisposable { + private readonly List<IntPtr> allocations = new List<IntPtr>(); + public IntPtr Ai; + public IntPtr EventTable; + public IntPtr HandlerTable; + public IntPtr VarRecords; + public IntPtr Cells; + public void Dispose() { for (int i = allocations.Count - 1; i >= 0; --i) Marshal.FreeHGlobal(allocations[i]); } + public IntPtr Allocate(int length) { + IntPtr p = Marshal.AllocHGlobal(length); + if (p == IntPtr.Zero) throw new OutOfMemoryException(); + Zero(p, length); allocations.Add(p); return p; + } + } + + private static int Main() { + if (IntPtr.Size != 4) { Console.Error.WriteLine("Build with /platform:x86."); return 2; } + string aiPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_AI_DLL"); + string scriptPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_DEFAULT_SCR"); + if (String.IsNullOrEmpty(aiPath) || !File.Exists(aiPath) || String.IsNullOrEmpty(scriptPath) || !File.Exists(scriptPath)) { + Console.Error.WriteLine("Set FPARKAN_ORIGINAL_AI_DLL and FPARKAN_ORIGINAL_DEFAULT_SCR to the installed GOG files."); return 3; + } + if (Hash(aiPath) != ExpectedAiSha256) { Console.Error.WriteLine("Unexpected ai.dll SHA-256: " + Hash(aiPath)); return 4; } + if (Hash(scriptPath) != ExpectedScriptSha256) { Console.Error.WriteLine("Unexpected default.scr SHA-256: " + Hash(scriptPath)); return 5; } + Package package = ReadPackage(scriptPath); + if (package.HandlerCount != 73 || package.Events.Length != 9) throw new InvalidDataException("Unexpected default.scr header."); + if (package.Events[0].Name.Length != 5 || Encoding.ASCII.GetString(package.Events[0].Name) != "Init\0" || package.Events[0].Instructions.Length != 1) + throw new InvalidDataException("Expected a single-instruction Init event at index 0."); + Instruction init = package.Events[0].Instructions[0]; + if (init.Words[0] != 19 || init.Words[1] != 0xffffffff || init.Words[2] != 0xffffffff || init.References.Length != 3 || init.References[0] != 224 || init.References[1] != 225 || init.References[2] != 226) + throw new InvalidDataException("Init instruction no longer matches the recovered Handler(19) shape."); + + IntPtr module = LoadLibraryEx(aiPath, IntPtr.Zero, LoadWithAlteredSearchPath); + if (module == IntPtr.Zero) { Console.Error.WriteLine("LoadLibraryEx(ai.dll) failed: " + Marshal.GetLastWin32Error()); return 6; } + try { + FindEvent find = (FindEvent)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, EventLookupRva), typeof(FindEvent)); + DispatchEvent dispatch = (DispatchEvent)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, EventDispatchRva), typeof(DispatchEvent)); + ReadVariable readVariable = (ReadVariable)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, 0x13570), typeof(ReadVariable)); + SetDwordPosition setDwordPosition = (SetDwordPosition)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, 0x5c70), typeof(SetDwordPosition)); + VerifyEventLookupVectors(package, find, dispatch, module); + RunInstance(package, find, dispatch, readVariable, module, 500.2857f, 752.0f, 0, 500, 752); + RunInstance(package, find, dispatch, readVariable, module, 728.0f, 449.0f, 1, 728, 449); + RunDwordToHandlerRoundTrip(package, find, dispatch, readVariable, setDwordPosition, module); + Console.WriteLine("original ai.dll dispatcher passed for default.scr Init instances and DWORD-to-handler round-trip; code bytes were not modified"); + return 0; + } + finally { FreeLibrary(module); } + } + + private static void VerifyEventLookupVectors(Package package, FindEvent find, DispatchEvent dispatch, IntPtr module) { + var duplicateEvents = new Event[package.Events.Length + 1]; + Array.Copy(package.Events, duplicateEvents, package.Events.Length); + duplicateEvents[duplicateEvents.Length - 1] = new Event( + Encoding.ASCII.GetBytes("Init\0"), 0xabcdef01, + new Instruction[] { new Instruction(new uint[] { 19, 0xffffffff, 0xffffffff, 0, 0, 3, 0 }, new uint[] { 224, 225, 226 }) }); + var duplicatePackage = new Package(package.HandlerCount, duplicateEvents); + using (Instance instance = BuildInstance(duplicatePackage, 500.2857f, 752.0f, 0, IntPtr.Add(module, Handler19Rva))) { + Expect(LookupEvent(find, IntPtr.Add(instance.Ai, 8), "Init") == 0, "native lookup chooses first exact duplicate event"); + } + + var embeddedNulEvents = (Event[])package.Events.Clone(); + for (int i = 0; i < embeddedNulEvents.Length; i++) { + if (Encoding.ASCII.GetString(embeddedNulEvents[i].Name) == "Init\0") { + embeddedNulEvents[i] = new Event(Encoding.ASCII.GetBytes("Init\0suffix\0"), embeddedNulEvents[i].EventWord, embeddedNulEvents[i].Instructions); + break; + } + } + var embeddedNulPackage = new Package(package.HandlerCount, embeddedNulEvents); + using (Instance instance = BuildInstance(embeddedNulPackage, 500.2857f, 752.0f, 0, IntPtr.Add(module, Handler19Rva))) { + Expect(LookupEvent(find, IntPtr.Add(instance.Ai, 8), "Init") == 0, "native lookup stops at an embedded NUL and ignores trailing name bytes"); + } + + var missingEvents = (Event[])package.Events.Clone(); + for (int i = 0; i < missingEvents.Length; i++) { + if (Encoding.ASCII.GetString(missingEvents[i].Name) == "Init\0") { + missingEvents[i] = new Event(Encoding.ASCII.GetBytes("NoInit\0"), missingEvents[i].EventWord, missingEvents[i].Instructions); + } + } + var missingPackage = new Package(package.HandlerCount, missingEvents); + using (Instance instance = BuildInstance(missingPackage, 500.2857f, 752.0f, 0, IntPtr.Add(module, Handler19Rva))) { + IntPtr vm = IntPtr.Add(instance.Ai, 8); + int eventIndex = LookupEvent(find, vm, "Init"); + Expect(eventIndex == -1, "native lookup reports missing exact event"); + Expect((dispatch(vm, eventIndex) & 0xff) == 0, "dispatching missing event performs no callback"); + Expect(Marshal.ReadInt32(instance.Cells, 0) == 0 && Marshal.ReadInt32(instance.Cells, 4) == 0 && Marshal.ReadInt32(instance.Cells, 8) == 0, + "missing event makes no varset writes"); + } + Console.WriteLine("original event lookup: first duplicate wins; Init\\0suffix also matches; missing Init returns -1 without writes"); + } + + private static int LookupEvent(FindEvent find, IntPtr vm, string nameText) { + byte[] nameBytes = Encoding.ASCII.GetBytes(nameText + "\0"); + IntPtr name = Marshal.AllocHGlobal(nameBytes.Length); + try { + Marshal.Copy(nameBytes, 0, name, nameBytes.Length); + return find(vm, name); + } + finally { Marshal.FreeHGlobal(name); } + } + + private static void RunDwordToHandlerRoundTrip(Package package, FindEvent find, DispatchEvent dispatch, ReadVariable readVariable, SetDwordPosition setDwordPosition, IntPtr module) { + IntPtr position = Marshal.AllocHGlobal(12); + try { + Zero(position, 12); + setDwordPosition(position, UInt32.MaxValue, 10001); + float x = BitConverter.ToSingle(BitConverter.GetBytes(ReadWord(position, 1)), 0); + float y = BitConverter.ToSingle(BitConverter.GetBytes(ReadWord(position, 2)), 0); + Expect(x == 4294967296.0f && y == 10001.0f, "FUN_10005c70 converts DWORD coordinates to the expected single-precision fields"); + Console.WriteLine("FUN_10005c70 input=({0:X8}, {1}) fields=({2:R}, {3:R})", UInt32.MaxValue, 10001, x, y); + RunInstance(package, find, dispatch, readVariable, module, x, y, 2, 0, 10001); + } + finally { Marshal.FreeHGlobal(position); } + } + + private static void RunInstance(Package package, FindEvent find, DispatchEvent dispatch, ReadVariable readVariable, IntPtr module, float x, float y, uint clanId, uint expectedX, uint expectedY) { + using (Instance instance = BuildInstance(package, x, y, clanId, IntPtr.Add(module, Handler19Rva))) { + IntPtr vm = IntPtr.Add(instance.Ai, 8); + IntPtr name = Marshal.AllocHGlobal(5); + try { + Marshal.Copy(Encoding.ASCII.GetBytes("Init\0"), 0, name, 5); + int eventIndex = find(vm, name); + Expect(eventIndex == 0, "original name lookup returns Init index 0"); + uint result = dispatch(vm, eventIndex); + uint first = ReadWord(instance.Cells, 0); + uint second = ReadWord(instance.Cells, 1); + uint third = ReadWord(instance.Cells, 2); + uint getterX = readVariable(IntPtr.Add(instance.VarRecords, 224 * VarSetStride)); + uint getterY = readVariable(IntPtr.Add(instance.VarRecords, 225 * VarSetStride)); + uint getterClan = readVariable(IntPtr.Add(instance.VarRecords, 226 * VarSetStride)); + Console.WriteLine("probe clan={0}: cell_words=({1:X8}, {2:X8}, {3:X8}), native_getter=({4:X8}, {5:X8}, {6:X8})", clanId, first, second, third, getterX, getterY, getterClan); + Expect((result & 0xff) == 1, "dispatcher reports completion"); + Expect(Marshal.ReadInt32(vm, 0x40) == 0, "current event field +0x48 is Init index"); + Expect(Marshal.ReadInt32(vm, 0x44) == 1, "current instruction field +0x4c advanced once"); + Expect(Marshal.ReadInt32(vm, 0x6c) == 0, "condition stack depth reset to zero"); + Expect(Marshal.ReadInt32(instance.Ai, 0x50) == 0, "handler result slot +0x50 cleared"); + Expect(first == expectedX && second == expectedY && third == clanId, "Handler(19) writes the three referenced DWORD cells in order"); + Expect(getterX == first && getterY == second && getterClan == third, "native DWORD getter reads the values written by Handler(19)"); + Console.WriteLine("clan={0}: Init={1}, instruction={2}, result={3}, stack={4}, result_slot={5}, cell_words=({6:X8}, {7:X8}, {8:X8}), typed_getters=({9:X8}, {10:X8}, {11:X8})", + clanId, eventIndex, Marshal.ReadInt32(vm, 0x44), result, Marshal.ReadInt32(vm, 0x6c), Marshal.ReadInt32(instance.Ai, 0x50), first, second, third, getterX, getterY, getterClan); + } + finally { Marshal.FreeHGlobal(name); } + } + } + + private static Instance BuildInstance(Package package, float x, float y, uint clanId, IntPtr handler19) { + var instance = new Instance(); + try { + instance.Ai = instance.Allocate(0x8b0); + instance.EventTable = instance.Allocate(package.Events.Length * EventStride); + instance.HandlerTable = instance.Allocate((int)package.HandlerCount * 4); + instance.VarRecords = instance.Allocate(227 * VarSetStride); + instance.Cells = instance.Allocate(3 * 4); + IntPtr vm = IntPtr.Add(instance.Ai, 8); + Marshal.WriteInt32(vm, 0, package.Events.Length); + Marshal.WriteInt32(vm, 4, instance.EventTable.ToInt32()); + Marshal.WriteInt32(vm, 8, (int)package.HandlerCount); + Marshal.WriteInt32(vm, 0x0c, instance.HandlerTable.ToInt32()); + Marshal.WriteInt32(vm, 0x3c, instance.Ai.ToInt32()); + Marshal.WriteInt32(instance.HandlerTable, 19 * 4, handler19.ToInt32()); + for (int eventIndex = 0; eventIndex < package.Events.Length; eventIndex++) { + Event sourceEvent = package.Events[eventIndex]; + IntPtr name = instance.Allocate(sourceEvent.Name.Length); + Marshal.Copy(sourceEvent.Name, 0, name, sourceEvent.Name.Length); + IntPtr records = sourceEvent.Instructions.Length == 0 ? IntPtr.Zero : instance.Allocate(sourceEvent.Instructions.Length * InstructionStride); + int eventOffset = eventIndex * EventStride; + Marshal.WriteInt32(instance.EventTable, eventOffset, name.ToInt32()); + Marshal.WriteInt32(instance.EventTable, eventOffset + 4, sourceEvent.Name.Length - 1); + Marshal.WriteInt32(instance.EventTable, eventOffset + 8, unchecked((int)sourceEvent.EventWord)); + Marshal.WriteInt32(instance.EventTable, eventOffset + 0x0c, sourceEvent.Instructions.Length); + Marshal.WriteInt32(instance.EventTable, eventOffset + 0x10, records.ToInt32()); + for (int instructionIndex = 0; instructionIndex < sourceEvent.Instructions.Length; instructionIndex++) { + Instruction sourceInstruction = sourceEvent.Instructions[instructionIndex]; + IntPtr refs = sourceInstruction.References.Length == 0 ? IntPtr.Zero : instance.Allocate(sourceInstruction.References.Length * 4); + for (int referenceIndex = 0; referenceIndex < sourceInstruction.References.Length; referenceIndex++) + Marshal.WriteInt32(refs, referenceIndex * 4, unchecked((int)sourceInstruction.References[referenceIndex])); + IntPtr instruction = IntPtr.Add(records, instructionIndex * InstructionStride); + for (int word = 0; word < 5; word++) Marshal.WriteInt32(instruction, word * 4, unchecked((int)sourceInstruction.Words[word])); + Marshal.WriteInt32(instruction, 0x14, refs.ToInt32()); + Marshal.WriteInt32(instruction, 0x18, sourceInstruction.References.Length); + Marshal.WriteInt32(instruction, 0x1c, unchecked((int)sourceInstruction.Words[6])); + } + } + Marshal.WriteInt32(instance.Ai, 0x7c, unchecked((int)clanId)); + Marshal.WriteInt32(instance.Ai, 0x80, BitConverter.ToInt32(BitConverter.GetBytes(x), 0)); + Marshal.WriteInt32(instance.Ai, 0x84, BitConverter.ToInt32(BitConverter.GetBytes(y), 0)); + // AI+0x18 is the original varset vector object: +4=count and +0xc=record array. + Marshal.WriteInt32(instance.Ai, 0x1c, 227); + Marshal.WriteInt32(instance.Ai, 0x24, instance.VarRecords.ToInt32()); + uint[] targets = { 224, 225, 226 }; + for (int i = 0; i < targets.Length; i++) { + IntPtr record = IntPtr.Add(instance.VarRecords, checked((int)targets[i] * VarSetStride)); + IntPtr cell = IntPtr.Add(instance.Cells, i * 4); + Marshal.WriteInt32(record, 8, 5); // original parser's DWORD type tag + Marshal.WriteInt32(record, 0x20, cell.ToInt32()); + } + return instance; + } + catch { instance.Dispose(); throw; } + } + + private static Package ReadPackage(string path) { + using (BinaryReader reader = new BinaryReader(File.OpenRead(path), Encoding.ASCII)) { + uint handlerCount = reader.ReadUInt32(); + uint eventCount = reader.ReadUInt32(); + if (eventCount > 4096 || handlerCount > 4096) throw new InvalidDataException("Unreasonable package header."); + var events = new Event[eventCount]; + for (int e = 0; e < events.Length; e++) { + uint nameLength = reader.ReadUInt32(); + if (nameLength > 1024) throw new InvalidDataException("Unreasonable event name length."); + byte[] name = reader.ReadBytes(checked((int)nameLength + 1)); + if (name.Length != nameLength + 1 || name[name.Length - 1] != 0) throw new InvalidDataException("Bad event name."); + uint eventWord = reader.ReadUInt32(); + uint instructionCount = reader.ReadUInt32(); + if (instructionCount > 65536) throw new InvalidDataException("Unreasonable event instruction count."); + var instructions = new Instruction[instructionCount]; + for (int i = 0; i < instructions.Length; i++) { + uint[] words = new uint[6]; + for (int w = 0; w < words.Length; w++) words[w] = reader.ReadUInt32(); + if (words[5] > 4096) throw new InvalidDataException("Unreasonable reference count."); + uint[] refs = new uint[words[5]]; + for (int r = 0; r < refs.Length; r++) refs[r] = reader.ReadUInt32(); + uint trailing = reader.ReadUInt32(); + instructions[i] = new Instruction(new uint[] { words[0], words[1], words[2], words[3], words[4], words[5], trailing }, refs); + } + events[e] = new Event(name, eventWord, instructions); + } + if (reader.BaseStream.Position != reader.BaseStream.Length) throw new InvalidDataException("Trailing bytes in default.scr."); + return new Package(handlerCount, events); + } + } + + private static uint ReadWord(IntPtr cells, int index) { return unchecked((uint)Marshal.ReadInt32(cells, index * 4)); } + private static string Hash(string path) { + using (SHA256 sha = SHA256.Create()) using (FileStream input = File.OpenRead(path)) { + byte[] digest = sha.ComputeHash(input); var hex = new StringBuilder(); + foreach (byte value in digest) hex.Append(value.ToString("x2")); return hex.ToString(); + } + } + private static void Expect(bool value, string message) { if (!value) throw new InvalidOperationException("original dispatcher oracle failed: " + message); } + private static void Zero(IntPtr buffer, int length) { for (int offset = 0; offset < length; offset++) Marshal.WriteByte(buffer, offset, 0); } +} diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs index 010c62a..088b51f 100644 --- a/crates/fparkan-script/src/lib.rs +++ b/crates/fparkan-script/src/lib.rs @@ -2,8 +2,8 @@ //! Lossless, bounded reader for compiled AI `.scr` packages. //! //! This module preserves the layout proven by the GOG `ai.dll` reader. It -//! deliberately does not assign semantics to instruction words or execute -//! bytecode: that requires handler-specific evidence. +//! deliberately does not implement a general bytecode VM. It contains a +//! narrow, handler-specific resolver for the confirmed `Init` path. use fparkan_binary::{checked_allocation_len, Cursor, DecodeError, Limits}; use std::sync::Arc; @@ -151,6 +151,35 @@ pub struct Handler19DwordWrite { pub value: u32, } +/// Result of resolving the first event whose case-sensitive C-string name is +/// `Init` (bytes through the first NUL) with the supported +/// `Handler(19)` action subset. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Handler19InitResolution { + /// No event has the case-sensitive C-string name `Init`; the native lookup + /// makes no call. + Missing, + /// The first `Init\0` event was walked in source order. + Executed { + /// Zero-based event table index selected by the native name lookup. + event_index: usize, + /// Opaque event word retained from the selected record. + event_word: u32, + /// Ordered writes emitted by every supported instruction in the event. + writes: Vec<Handler19DwordWrite>, + }, + /// The first unsupported instruction stopped this restricted resolver. + /// Any earlier local writes are discarded because the event did not finish. + Unsupported { + /// Zero-based event table index selected by the native name lookup. + event_index: usize, + /// Zero-based instruction index within that event. + instruction_index: usize, + /// Unsupported action shape. + error: Handler19ResolveError, + }, +} + /// Inputs already expressed at the original `Handler(19)` setter ABI boundary. /// /// The first two words are results of original x87 `__ftol` calls. Keeping @@ -169,6 +198,21 @@ pub struct Handler19InitInput { /// Error resolving the three-target `Handler(19)` Init contract. #[derive(Clone, Debug, Eq, PartialEq)] pub enum Handler19ResolveError { + /// The selected action is not the recovered `Handler(19)` slot. + UnsupportedSelector { + /// Recovered selector shape. + found: ScriptDispatchSelector, + }, + /// Only action instructions with control word `0xffff_ffff` are supported. + UnsupportedControlWord { + /// Opaque control word from instruction header word one. + found: u32, + }, + /// Only instructions with no result-save reference are supported. + UnsupportedResultSave { + /// Opaque variable reference from instruction header word two. + found: u32, + }, /// One of the three required references was absent. MissingReference { /// Zero-based required target position. @@ -193,6 +237,17 @@ pub enum Handler19ResolveError { impl std::fmt::Display for Handler19ResolveError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + Self::UnsupportedSelector { found } => { + write!(formatter, "unsupported script selector {found:?} in Init") + } + Self::UnsupportedControlWord { found } => write!( + formatter, + "unsupported Init control word 0x{found:08x}; expected 0xffffffff" + ), + Self::UnsupportedResultSave { found } => write!( + formatter, + "unsupported Init result-save reference 0x{found:08x}; expected 0xffffffff" + ), Self::MissingReference { position } => { write!(formatter, "Handler(19) is missing reference {position}") } @@ -528,6 +583,73 @@ impl VarSet { ]) } + /// Resolves only the first event whose case-sensitive C-string name is + /// `Init` (bytes through the first NUL), and its supported + /// `Handler(19)` action instructions in source order. + /// + /// A missing event is a normal no-write result, matching the native event + /// lookup. Unsupported selector/control/result-save forms stop at their + /// first instruction and return no partial writes. Malformed handler + /// references and declaration types remain errors. + pub fn resolve_handler19_init( + &self, + package: &ScriptPackage, + input: Handler19InitInput, + ) -> Result<Handler19InitResolution, Handler19ResolveError> { + let Some((event_index, event)) = package.events.iter().enumerate().find(|(_, event)| { + event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..]) + }) else { + return Ok(Handler19InitResolution::Missing); + }; + + let mut writes = Vec::new(); + for (instruction_index, instruction) in event.instructions.iter().enumerate() { + let instruction_writes = + match self.resolve_handler19_init_instruction(instruction, input) { + Ok(writes) => writes, + Err(error @ Handler19ResolveError::UnsupportedSelector { .. }) + | Err(error @ Handler19ResolveError::UnsupportedControlWord { .. }) + | Err(error @ Handler19ResolveError::UnsupportedResultSave { .. }) => { + return Ok(Handler19InitResolution::Unsupported { + event_index, + instruction_index, + error, + }); + } + Err(error) => return Err(error), + }; + writes.extend(instruction_writes); + } + + Ok(Handler19InitResolution::Executed { + event_index, + event_word: event.event_word, + writes, + }) + } + + fn resolve_handler19_init_instruction( + &self, + instruction: &ScriptInstruction, + input: Handler19InitInput, + ) -> Result<[Handler19DwordWrite; 3], Handler19ResolveError> { + let selector = instruction.dispatch_selector(); + if selector != ScriptDispatchSelector::Handler(19) { + return Err(Handler19ResolveError::UnsupportedSelector { found: selector }); + } + if instruction.header_words[1] != u32::MAX { + return Err(Handler19ResolveError::UnsupportedControlWord { + found: instruction.header_words[1], + }); + } + if instruction.header_words[2] != u32::MAX { + return Err(Handler19ResolveError::UnsupportedResultSave { + found: instruction.header_words[2], + }); + } + self.resolve_handler19(instruction, input) + } + fn resolve_handler19_target( &self, instruction: &ScriptInstruction, @@ -1196,10 +1318,11 @@ fn read_instruction( mod tests { use super::{ decode, decode_with_limits, parse_varset, Handler15ResolveError, Handler15TargetPayload, - Handler19DwordWrite, Handler19InitInput, Handler19ResolveError, Handler2RecordInput, - Handler2RecordScheduler, Handler30ResolveError, Handler8Reset, Handler8ResolveError, - ScriptDispatchSelector, ScriptInstruction, VarSetDefault, VarSetError, VarSetType, - VmHostCallbackCommand, GOG_HANDLER_COUNT, INSTRUCTION_WORDS, + Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError, + Handler2RecordInput, Handler2RecordScheduler, Handler30ResolveError, Handler8Reset, + Handler8ResolveError, ScriptDispatchSelector, ScriptEvent, ScriptInstruction, + ScriptPackage, VarSetDefault, VarSetError, VarSetType, VmHostCallbackCommand, + GOG_HANDLER_COUNT, INSTRUCTION_WORDS, }; use fparkan_binary::{DecodeError, Limits}; @@ -1561,6 +1684,281 @@ STRING( 8, ignored, ignored, ignored)\r\n"; } #[test] + fn handler_nineteen_init_uses_first_exact_event_and_keeps_instruction_order() { + let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n") + .expect("DWORD targets"); + let action = |references: Vec<u32>| ScriptInstruction { + header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0], + references, + }; + let package = ScriptPackage { + opcode_handler_count: 73, + events: vec![ + ScriptEvent { + name_len: 11, + name_raw: b"Init\0suffix\0".to_vec(), + event_word: 0x1234, + instructions: vec![action(vec![0, 1, 2]), action(vec![1, 0, 2])], + }, + ScriptEvent { + name_len: 4, + name_raw: b"Init\0".to_vec(), + event_word: 0x5678, + instructions: vec![ScriptInstruction { + header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0], + references: Vec::new(), + }], + }, + ], + trailing_bytes: Vec::new(), + raw: std::sync::Arc::from(&b""[..]), + }; + + let result = varset + .resolve_handler19_init( + &package, + Handler19InitInput { + first_x87_word: 7, + second_x87_word: 8, + third_word: 9, + }, + ) + .expect("valid first Init event"); + assert_eq!( + result, + Handler19InitResolution::Executed { + event_index: 0, + event_word: 0x1234, + writes: vec![ + Handler19DwordWrite { index: 0, value: 7 }, + Handler19DwordWrite { index: 1, value: 8 }, + Handler19DwordWrite { index: 2, value: 9 }, + Handler19DwordWrite { index: 1, value: 7 }, + Handler19DwordWrite { index: 0, value: 8 }, + Handler19DwordWrite { index: 2, value: 9 }, + ], + } + ); + } + + #[test] + fn handler_nineteen_init_missing_exact_name_has_no_writes() { + let varset = parse_varset(b"").expect("empty varset"); + let package = ScriptPackage { + opcode_handler_count: 73, + events: vec![ + ScriptEvent { + name_len: 4, + name_raw: b"init\0".to_vec(), + event_word: 1, + instructions: Vec::new(), + }, + ScriptEvent { + name_len: 4, + name_raw: b"INIT\0".to_vec(), + event_word: 2, + instructions: Vec::new(), + }, + ], + trailing_bytes: Vec::new(), + raw: std::sync::Arc::from(&b""[..]), + }; + + assert_eq!( + varset.resolve_handler19_init( + &package, + Handler19InitInput { + first_x87_word: 7, + second_x87_word: 8, + third_word: 9, + }, + ), + Ok(Handler19InitResolution::Missing) + ); + } + + #[test] + fn handler_nineteen_init_accepts_empty_first_event_as_executed_with_no_writes() { + let varset = parse_varset(b"").expect("empty varset"); + let package = ScriptPackage { + opcode_handler_count: 73, + events: vec![ + ScriptEvent { + name_len: 4, + name_raw: b"Init\0".to_vec(), + event_word: 0x1234, + instructions: Vec::new(), + }, + ScriptEvent { + name_len: 4, + name_raw: b"Init\0".to_vec(), + event_word: 0x5678, + instructions: vec![ScriptInstruction { + header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0], + references: Vec::new(), + }], + }, + ], + trailing_bytes: Vec::new(), + raw: std::sync::Arc::from(&b""[..]), + }; + + assert_eq!( + varset.resolve_handler19_init( + &package, + Handler19InitInput { + first_x87_word: 7, + second_x87_word: 8, + third_word: 9, + }, + ), + Ok(Handler19InitResolution::Executed { + event_index: 0, + event_word: 0x1234, + writes: Vec::new(), + }) + ); + } + + #[test] + fn handler_nineteen_init_reports_unsupported_selector_and_discards_prior_writes() { + let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n") + .expect("DWORD targets"); + let package = ScriptPackage { + opcode_handler_count: 73, + events: vec![ScriptEvent { + name_len: 4, + name_raw: b"Init\0".to_vec(), + event_word: 0, + instructions: vec![ + ScriptInstruction { + header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0], + references: vec![0, 1, 2], + }, + ScriptInstruction { + header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0], + references: Vec::new(), + }, + ScriptInstruction { + header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0], + references: vec![0, 1, 2], + }, + ], + }], + trailing_bytes: Vec::new(), + raw: std::sync::Arc::from(&b""[..]), + }; + + assert_eq!( + varset.resolve_handler19_init( + &package, + Handler19InitInput { + first_x87_word: 7, + second_x87_word: 8, + third_word: 9, + }, + ), + Ok(Handler19InitResolution::Unsupported { + event_index: 0, + instruction_index: 1, + error: Handler19ResolveError::UnsupportedSelector { + found: super::ScriptDispatchSelector::Handler(18), + }, + }) + ); + } + + #[test] + fn handler_nineteen_init_stops_at_unsupported_selector_before_any_write() { + let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n") + .expect("DWORD targets"); + let package = ScriptPackage { + opcode_handler_count: 73, + events: vec![ScriptEvent { + name_len: 4, + name_raw: b"Init\0".to_vec(), + event_word: 0, + instructions: vec![ + ScriptInstruction { + header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0], + references: Vec::new(), + }, + ScriptInstruction { + header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0], + references: vec![0, 1, 2], + }, + ], + }], + trailing_bytes: Vec::new(), + raw: std::sync::Arc::from(&b""[..]), + }; + + assert_eq!( + varset.resolve_handler19_init( + &package, + Handler19InitInput { + first_x87_word: 7, + second_x87_word: 8, + third_word: 9, + }, + ), + Ok(Handler19InitResolution::Unsupported { + event_index: 0, + instruction_index: 0, + error: Handler19ResolveError::UnsupportedSelector { + found: super::ScriptDispatchSelector::Handler(18), + }, + }) + ); + } + + #[test] + fn handler_nineteen_init_types_unsupported_control_and_result_save() { + let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n") + .expect("DWORD targets"); + let input = Handler19InitInput { + first_x87_word: 7, + second_x87_word: 8, + third_word: 9, + }; + let instruction = |control_word, result_word| ScriptInstruction { + header_words: [19, control_word, result_word, 0, 0, 3, 0], + references: vec![0, 1, 2], + }; + let resolve = |instruction| { + let package = ScriptPackage { + opcode_handler_count: 73, + events: vec![ScriptEvent { + name_len: 4, + name_raw: b"Init\0".to_vec(), + event_word: 0, + instructions: vec![instruction], + }], + trailing_bytes: Vec::new(), + raw: std::sync::Arc::from(&b""[..]), + }; + varset.resolve_handler19_init(&package, input) + }; + + assert_eq!( + resolve(instruction(0, u32::MAX)), + Ok(Handler19InitResolution::Unsupported { + event_index: 0, + instruction_index: 0, + error: Handler19ResolveError::UnsupportedControlWord { found: 0 }, + }) + ); + assert_eq!( + resolve(instruction(u32::MAX, 2)), + Ok(Handler19InitResolution::Unsupported { + event_index: 0, + instruction_index: 0, + error: Handler19ResolveError::UnsupportedResultSave { found: 2 }, + }) + ); + } + + #[test] fn handler_fifteen_resolves_exact_typed_target_by_place_operands() { let varset = parse_varset( b"VAR( DWORD, d0, 1)\nVAR( DWORD, d1, 2)\nVAR( DWORD, d2, 3)\nVAR( DWORD, d3, 4)\nVAR( float, f4, 1.5)\nVAR( float, f5, 2.5)\nVAR( float, f6, 3.5)\nVAR( float, f7, 4.5)\nVAR( DWORD, mode, 0x0202)\nVAR( DWORD, place0, 12)\nVAR( DWORD, place1, 13)\n", |
