diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 294 | ||||
| -rw-r--r-- | crates/fparkan-runtime/tests/original_host_callback_oracle.cs | 185 |
2 files changed, 432 insertions, 47 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 91909d5..c7899c0 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -49,13 +49,26 @@ impl EngineServices { } } +/// Native mission mode used by the recovered host callback. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MissionMode { + /// Native application mode 1, used by the selected `missions/single.*` path. + SingleMission, + /// Native application mode 3, selected by the main-menu idle timer. + AutoDemo, +} + /// Mission request. #[derive(Clone, Debug, Eq, PartialEq)] pub struct MissionRequest { /// Mission key/path. pub key: String, + /// Explicit host mode; this is not inferred from the mission path. + pub mode: MissionMode, } +const INITIAL_SCRIPT_HOST_STATUS: u32 = 5; + /// Ordered mission property preserved for later runtime stages. #[derive(Clone, Debug, Eq, PartialEq)] pub struct MissionObjectProperty { @@ -197,10 +210,11 @@ pub struct MissionScriptVarSetState { pub values: Vec<VarSetDefault>, } -/// Rust-side outcome selected by the recovered `Iron3D` host-callback mapper. +/// Rust-side outcome selected by the recovered `Iron3D` host callback. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum MissionScriptOutcome { - /// No outcome has been recorded in the Rust model. + /// No host callback outcome has been recorded; this does not mean that the + /// native `IGame` has entered its running state. #[default] Active, /// Rust outcome corresponding to completion payload `1`. @@ -218,7 +232,7 @@ pub enum MissionScriptHostEffect { MissionCompleted, /// The Rust outcome changed to failed. MissionFailed, - /// The Rust outcome already held this target state. + /// The native status guard rejected a repeated callback without changing state. NoChange, } @@ -357,6 +371,8 @@ struct LoadedMissionState { script_varset: Option<MissionScriptVarSet>, script_init_states: Vec<MissionScriptInitState>, script_varset_states: Vec<MissionScriptVarSetState>, + script_host_mode: MissionMode, + script_host_status: u32, script_outcome: MissionScriptOutcome, } @@ -899,6 +915,10 @@ fn load_mission_with_options( script_varset: loaded_scripts.varset, script_init_states: loaded_scripts.init_states, script_varset_states: loaded_scripts.varset_states, + script_host_mode: request.mode, + // Fresh IGame construction seeds this field with 5; this is not the + // later status-4 transition performed after native game initialization. + script_host_status: INITIAL_SCRIPT_HOST_STATUS, script_outcome: MissionScriptOutcome::Active, }); Ok(summary) @@ -997,8 +1017,9 @@ pub fn loaded_mission_script_varset_states(engine: &Engine) -> Option<&[MissionS /// /// Unlike the loader-only resolver, operands are read from the selected /// clan's instantiated varset cells. Dispatching the returned host callback -/// remains separate from this function; the Rust outcome mapper does not model -/// the native `IGame` guards or initial status word. +/// remains separate from this function. The runtime currently models the +/// callback's recovered mode and status guards, but not the full game startup +/// transition or frame/event execution that reaches it. /// /// # Errors /// @@ -1040,13 +1061,16 @@ pub fn loaded_mission_script_outcome(engine: &Engine) -> Option<MissionScriptOut engine.loaded.as_ref().map(|state| state.script_outcome) } -/// Records the recovered `Iron3D` host-callback target in the Rust outcome. +/// Applies the recovered `Iron3D` host-callback guards and records its outcome. /// -/// `(mode=0, command=0, payload=0|1)` maps payload zero to failure and one to -/// completion. Iron3D also checks the gate byte at `IGame + 0xe5` and the -/// status word at `IGame + 8`; those native guards and the initial status are -/// not represented here. Other callback commands return -/// [`MissionScriptHostEffect::Unhandled`]. +/// Native mission mode `3` closes the callback gate. Otherwise the callback +/// must have `(mode=0, command=0)` and payload `0` or `1`. Payload `0` writes +/// status `1` and records failure only when the current status is not `1`; +/// payload `1` writes status `0` and records completion only when the current +/// status is not `0`. A duplicate returns [`MissionScriptHostEffect::NoChange`] +/// without repairing any other state. Loaded mission state seeds the raw +/// status from the native constructor value `5`; it does not model the later +/// game-initialization transition to status `4`. /// /// # Errors /// @@ -1063,31 +1087,36 @@ pub fn apply_loaded_script_host_callback( message: "mission script host state is unavailable".to_string(), })?; Ok(apply_recovered_script_host_callback( + state.script_host_mode, + &mut state.script_host_status, &mut state.script_outcome, command, )) } fn apply_recovered_script_host_callback( + native_mode: MissionMode, + status: &mut u32, outcome: &mut MissionScriptOutcome, command: VmHostCallbackCommand, ) -> MissionScriptHostEffect { - if command.mode != 0 || command.first != 0 { + if native_mode == MissionMode::AutoDemo || command.mode != 0 || command.first != 0 { return MissionScriptHostEffect::Unhandled; } - let next = match command.second { - 0 => MissionScriptOutcome::Failed, - 1 => MissionScriptOutcome::Completed, - _ => return MissionScriptHostEffect::Unhandled, - }; - if *outcome == next { - return MissionScriptHostEffect::NoChange; - } - *outcome = next; - match next { - MissionScriptOutcome::Completed => MissionScriptHostEffect::MissionCompleted, - MissionScriptOutcome::Failed => MissionScriptHostEffect::MissionFailed, - MissionScriptOutcome::Active => MissionScriptHostEffect::Unhandled, + match command.second { + 0 if *status != 1 => { + *status = 1; + *outcome = MissionScriptOutcome::Failed; + MissionScriptHostEffect::MissionFailed + } + 0 => MissionScriptHostEffect::NoChange, + 1 if *status != 0 => { + *status = 0; + *outcome = MissionScriptOutcome::Completed; + MissionScriptHostEffect::MissionCompleted + } + 1 => MissionScriptHostEffect::NoChange, + _ => MissionScriptHostEffect::Unhandled, } } @@ -1446,6 +1475,7 @@ mod tests { &mut engine, MissionRequest { key: "MISSIONS/Autodemo.00/data.tma".to_string(), + mode: MissionMode::SingleMission, }, ) .expect_err("missing VFS"); @@ -1598,54 +1628,212 @@ mod tests { } #[test] - fn recovered_handler_thirty_host_callback_transitions_mission_outcome() { + fn handler_thirty_host_callback_obeys_native_mode_and_status_guards() { + fn command(second: u32) -> VmHostCallbackCommand { + VmHostCallbackCommand { + mode: 0, + first: 0, + second, + } + } + + let mut status = 5; let mut outcome = MissionScriptOutcome::Active; assert_eq!( apply_recovered_script_host_callback( + MissionMode::SingleMission, + &mut status, &mut outcome, - VmHostCallbackCommand { - mode: 0, - first: 0, - second: 0, - }, + command(0), ), MissionScriptHostEffect::MissionFailed ); - assert_eq!(outcome, MissionScriptOutcome::Failed); + assert_eq!((status, outcome), (1, MissionScriptOutcome::Failed)); + outcome = MissionScriptOutcome::Completed; assert_eq!( apply_recovered_script_host_callback( + MissionMode::SingleMission, + &mut status, &mut outcome, - VmHostCallbackCommand { - mode: 0, - first: 0, - second: 1, - }, + command(0), + ), + MissionScriptHostEffect::NoChange + ); + assert_eq!((status, outcome), (1, MissionScriptOutcome::Completed)); + outcome = MissionScriptOutcome::Failed; + + assert_eq!( + apply_recovered_script_host_callback( + MissionMode::SingleMission, + &mut status, + &mut outcome, + command(1), + ), + MissionScriptHostEffect::MissionCompleted + ); + assert_eq!((status, outcome), (0, MissionScriptOutcome::Completed)); + assert_eq!( + apply_recovered_script_host_callback( + MissionMode::SingleMission, + &mut status, + &mut outcome, + command(1), + ), + MissionScriptHostEffect::NoChange + ); + assert_eq!((status, outcome), (0, MissionScriptOutcome::Completed)); + + assert_eq!( + apply_recovered_script_host_callback( + MissionMode::SingleMission, + &mut status, + &mut outcome, + command(0), + ), + MissionScriptHostEffect::MissionFailed + ); + assert_eq!((status, outcome), (1, MissionScriptOutcome::Failed)); + + status = 4; + outcome = MissionScriptOutcome::Active; + assert_eq!( + apply_recovered_script_host_callback( + MissionMode::SingleMission, + &mut status, + &mut outcome, + command(1), ), MissionScriptHostEffect::MissionCompleted ); - assert_eq!(outcome, MissionScriptOutcome::Completed); + assert_eq!((status, outcome), (0, MissionScriptOutcome::Completed)); + + status = 5; + outcome = MissionScriptOutcome::Active; assert_eq!( apply_recovered_script_host_callback( + MissionMode::AutoDemo, + &mut status, &mut outcome, + command(0), + ), + MissionScriptHostEffect::Unhandled + ); + assert_eq!((status, outcome), (5, MissionScriptOutcome::Active)); + + for command in [ + VmHostCallbackCommand { + mode: 1, + first: 0, + second: 0, + }, + VmHostCallbackCommand { + mode: 0, + first: 3, + second: 0, + }, + VmHostCallbackCommand { + mode: 0, + first: 0, + second: 42, + }, + ] { + assert_eq!( + apply_recovered_script_host_callback( + MissionMode::SingleMission, + &mut status, + &mut outcome, + command, + ), + MissionScriptHostEffect::Unhandled + ); + assert_eq!((status, outcome), (5, MissionScriptOutcome::Active)); + } + } + + #[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)); + let key = "MISSIONS/Autodemo.00/data.tma".to_string(); + let mut engine = create(EngineServices::new(vfs)).expect("engine"); + + load_mission_static_preview_roots( + &mut engine, + MissionRequest { + key: key.clone(), + mode: MissionMode::SingleMission, + }, + NonZeroUsize::new(1).expect("non-zero root count"), + ) + .expect("load single-mission preview"); + let state = engine.loaded.as_mut().expect("loaded state"); + assert_eq!(state.script_host_status, INITIAL_SCRIPT_HOST_STATUS); + assert_eq!(state.script_outcome, MissionScriptOutcome::Active); + assert_eq!( + apply_loaded_script_host_callback( + &mut engine, VmHostCallbackCommand { mode: 0, first: 0, - second: 1, + second: 0, }, - ), + ) + .expect("apply failure callback"), + MissionScriptHostEffect::MissionFailed + ); + assert_eq!(engine.loaded.as_ref().unwrap().script_host_status, 1); + assert_eq!( + loaded_mission_script_outcome(&engine), + Some(MissionScriptOutcome::Failed) + ); + + engine.loaded.as_mut().unwrap().script_outcome = MissionScriptOutcome::Completed; + assert_eq!( + apply_loaded_script_host_callback( + &mut engine, + VmHostCallbackCommand { + mode: 0, + first: 0, + second: 0, + }, + ) + .expect("apply repeated failure callback"), MissionScriptHostEffect::NoChange ); + assert_eq!(engine.loaded.as_ref().unwrap().script_host_status, 1); assert_eq!( - apply_recovered_script_host_callback( - &mut outcome, + loaded_mission_script_outcome(&engine), + Some(MissionScriptOutcome::Completed) + ); + + load_mission_static_preview_roots( + &mut engine, + MissionRequest { + key, + mode: MissionMode::AutoDemo, + }, + NonZeroUsize::new(1).expect("non-zero root count"), + ) + .expect("reload AutoDemo preview"); + assert_eq!(engine.loaded.as_ref().unwrap().script_host_status, 5); + assert_eq!( + apply_loaded_script_host_callback( + &mut engine, VmHostCallbackCommand { mode: 0, - first: 3, - second: 42, + first: 0, + second: 1, }, - ), + ) + .expect("apply AutoDemo callback"), MissionScriptHostEffect::Unhandled ); + assert_eq!(engine.loaded.as_ref().unwrap().script_host_status, 5); + assert_eq!( + loaded_mission_script_outcome(&engine), + Some(MissionScriptOutcome::Active) + ); } #[test] @@ -1676,6 +1864,7 @@ mod tests { &mut engine, MissionRequest { key: "MISSIONS/Autodemo.00/data.tma".to_string(), + mode: MissionMode::SingleMission, }, ) .expect("load mission"); @@ -1732,12 +1921,14 @@ mod tests { DenyRule::Suffix("Land.map"), MissionRequest { key: "MISSIONS/Autodemo.00/data.tma".to_string(), + mode: MissionMode::SingleMission, }, ), ( DenyRule::Suffix("objects.rlb"), MissionRequest { key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(), + mode: MissionMode::SingleMission, }, ), ] { @@ -1773,6 +1964,7 @@ mod tests { &mut engine, MissionRequest { key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(), + mode: MissionMode::SingleMission, }, MissionLoadOptions { fail_after_registered_objects: Some(1), @@ -1842,6 +2034,7 @@ mod tests { &mut engine, MissionRequest { key: case.1.to_string(), + mode: MissionMode::SingleMission, }, ) .expect("load mission"); @@ -1954,6 +2147,7 @@ mod tests { &mut engine, MissionRequest { key: case.mission.to_string(), + mode: MissionMode::SingleMission, }, ) .expect("load selected mission"); @@ -1978,8 +2172,14 @@ mod tests { let mut totals = LoadTotals::default(); for mission in missions { let mut engine = create(EngineServices::new(vfs.clone())).expect("engine"); - let loaded = load_mission(&mut engine, MissionRequest { key: mission }) - .expect("load mission foundation"); + let loaded = load_mission( + &mut engine, + MissionRequest { + key: mission, + mode: MissionMode::SingleMission, + }, + ) + .expect("load mission foundation"); assert_eq!(loaded.object_count, loaded.registered_objects); assert_eq!(loaded.object_count, loaded.graph_root_count); assert_eq!( diff --git a/crates/fparkan-runtime/tests/original_host_callback_oracle.cs b/crates/fparkan-runtime/tests/original_host_callback_oracle.cs new file mode 100644 index 0000000..0fd953e --- /dev/null +++ b/crates/fparkan-runtime/tests/original_host_callback_oracle.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; + +internal static class HostCallbackOracle { + private const uint LoadWithAlteredSearchPath = 0x00000008; + private const uint MemCommitReserve = 0x00003000; + private const uint PageExecuteReadWrite = 0x40; + private const int CallbackRva = 0x611d0; + private const int GameGlobalRva = 0x10a5f8; + private const int SoundServerIatRva = 0xe42cc; + private const string ExpectedSha256 = "d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa"; + + [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); + [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 newProtect, out uint oldProtect); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int HostCallback(int gameMode, int command, int payload); + + private static int Main() { + if (IntPtr.Size != 4) { Console.Error.WriteLine("Build with /platform:x86."); return 2; } + string dllPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_IRON3D_DLL"); + if (String.IsNullOrEmpty(dllPath) || !File.Exists(dllPath)) { + Console.Error.WriteLine("Set FPARKAN_ORIGINAL_IRON3D_DLL to the original GOG iron3d.dll."); return 3; + } + string hash; + using (SHA256 sha = SHA256.Create()) using (FileStream input = File.OpenRead(dllPath)) { + byte[] digest = sha.ComputeHash(input); StringBuilder hex = new StringBuilder(); + foreach (byte value in digest) hex.Append(value.ToString("x2")); hash = hex.ToString(); + } + if (hash != ExpectedSha256) { Console.Error.WriteLine("Unexpected iron3d.dll SHA-256: " + hash); return 4; } + IntPtr module = LoadLibraryEx(dllPath, IntPtr.Zero, LoadWithAlteredSearchPath); + if (module == IntPtr.Zero) { Console.Error.WriteLine("LoadLibraryEx failed: " + Marshal.GetLastWin32Error()); return 5; } + + IntPtr game = IntPtr.Zero, app = IntPtr.Zero, manager = IntPtr.Zero, sound = IntPtr.Zero, vtable = IntPtr.Zero; + IntPtr callStub = IntPtr.Zero, returnStub = IntPtr.Zero; + IntPtr gameGlobal = IntPtr.Zero; int originalGameGlobal = 0; bool gameGlobalPatched = false; + uint oldProtect = 0; bool iatPatched = false; int originalSoundPointer = 0; + try { + game = Marshal.AllocHGlobal(0x200); app = Marshal.AllocHGlobal(0x200); manager = Marshal.AllocHGlobal(0x40); + sound = Marshal.AllocHGlobal(4); vtable = Marshal.AllocHGlobal(0x14); + IntPtr observations = Marshal.AllocHGlobal(56); + try { + Zero(game, 0x200); Zero(app, 0x200); Zero(manager, 0x40); Zero(sound, 4); Zero(vtable, 0x14); Zero(observations, 56); + Marshal.WriteInt32(game, 4, app.ToInt32()); Marshal.WriteInt32(game, 0x2c, manager.ToInt32()); + Marshal.WriteInt32(manager, 0x10, IntPtr.Zero.ToInt32()); + Marshal.WriteInt32(sound, vtable.ToInt32()); + IntPtr codePage = VirtualAlloc(IntPtr.Zero, new UIntPtr(0x1000), MemCommitReserve, PageExecuteReadWrite); + if (codePage == IntPtr.Zero) throw new InvalidOperationException("VirtualAlloc failed: " + Marshal.GetLastWin32Error()); + callStub = codePage; + byte[] soundMethod = BuildSoundMethod(observations, game, app); + Marshal.Copy(soundMethod, 0, callStub, soundMethod.Length); + Marshal.WriteInt32(vtable, 0x10, callStub.ToInt32()); + returnStub = IntPtr.Add(codePage, 0x100); + byte[] returnCode = { 0xb8, 0, 0, 0, 0, 0xc3 }; + WriteImm32(returnCode, 1, sound.ToInt32()); Marshal.Copy(returnCode, 0, returnStub, returnCode.Length); + + IntPtr iat = IntPtr.Add(module, SoundServerIatRva); + originalSoundPointer = Marshal.ReadInt32(iat); + if (!VirtualProtect(iat, new UIntPtr(4), PageExecuteReadWrite, out oldProtect)) + throw new InvalidOperationException("VirtualProtect(IAT) failed: " + Marshal.GetLastWin32Error()); + Marshal.WriteInt32(iat, returnStub.ToInt32()); + iatPatched = true; + uint ignored; + if (!VirtualProtect(iat, new UIntPtr(4), oldProtect, out ignored)) + throw new InvalidOperationException("VirtualProtect(IAT restore protection) failed: " + Marshal.GetLastWin32Error()); + + gameGlobal = IntPtr.Add(module, GameGlobalRva); + originalGameGlobal = Marshal.ReadInt32(gameGlobal); + Marshal.WriteInt32(gameGlobal, game.ToInt32()); + gameGlobalPatched = true; + var callback = (HostCallback)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, CallbackRva), typeof(HostCallback)); + RunAllowedFailure(callback, game, app, observations); + RunFailureRepeat(callback, game, app, observations); + RunOppositeCompletion(callback, game, app, observations); + RunCompletionRepeat(callback, game, app, observations); + RunFailureAfterCompletion(callback, game, app, observations); + RunAutoDemoGate(callback, game, app, observations); + RunNonZeroGameMode(callback, game, app, observations); + RunUnknownPayload(callback, game, app, observations); + Console.WriteLine("native callback oracle passed; audio endpoint is an isolated no-op recorder"); + return 0; + } + finally { Marshal.FreeHGlobal(observations); } + } + finally { + if (gameGlobalPatched) Marshal.WriteInt32(gameGlobal, originalGameGlobal); + if (iatPatched) { + IntPtr iat = IntPtr.Add(module, SoundServerIatRva); uint restore; + if (!VirtualProtect(iat, new UIntPtr(4), PageExecuteReadWrite, out restore)) + throw new InvalidOperationException("VirtualProtect(IAT cleanup) failed: " + Marshal.GetLastWin32Error()); + Marshal.WriteInt32(iat, originalSoundPointer); + uint ignored; + if (!VirtualProtect(iat, new UIntPtr(4), restore, out ignored)) + throw new InvalidOperationException("VirtualProtect(IAT cleanup restore protection) failed: " + Marshal.GetLastWin32Error()); + } + if (callStub != IntPtr.Zero) VirtualFree(callStub, UIntPtr.Zero, 0x8000); + if (vtable != IntPtr.Zero) Marshal.FreeHGlobal(vtable); + if (sound != IntPtr.Zero) Marshal.FreeHGlobal(sound); + if (manager != IntPtr.Zero) Marshal.FreeHGlobal(manager); + if (app != IntPtr.Zero) Marshal.FreeHGlobal(app); + FreeLibrary(module); + if (game != IntPtr.Zero) Marshal.FreeHGlobal(game); + } + } + + private static byte[] BuildSoundMethod(IntPtr observations, IntPtr game, IntPtr app) { + var code = new List<byte>(); + code.AddRange(new byte[] { 0x50, 0x52, 0x51, 0x56, 0x57 }); + code.Add(0xa1); AddImm32(code, observations.ToInt32()); code.Add(0x40); code.Add(0xa3); AddImm32(code, observations.ToInt32()); + code.AddRange(new byte[] { 0x8b, 0x74, 0x24, 0x18, 0x8b, 0x06, 0x8b, 0x56, 0x04, 0x2b, 0xd0, 0x89, 0x15 }); AddImm32(code, observations.ToInt32() + 4); + code.AddRange(new byte[] { 0x83, 0xfa, 0x17, 0x76, 0x09, 0xc6, 0x05 }); AddImm32(code, observations.ToInt32() + 48); + code.AddRange(new byte[] { 0x01, 0xeb, 0x0f, 0xbf }); AddImm32(code, observations.ToInt32() + 12); + code.AddRange(new byte[] { 0x8b, 0xca, 0x8b, 0xf0, 0xfc, 0xf3, 0xa4, 0xc6, 0x07, 0x00 }); + code.AddRange(new byte[] { 0x8b, 0x44, 0x24, 0x1c, 0xa3 }); AddImm32(code, observations.ToInt32() + 8); + code.Add(0xb8); AddImm32(code, game.ToInt32()); + code.AddRange(new byte[] { 0x8b, 0x40, 0x08, 0xa3 }); AddImm32(code, observations.ToInt32() + 40); + code.Add(0xb8); AddImm32(code, app.ToInt32()); + code.AddRange(new byte[] { 0x0f, 0xb6, 0x80, 0x57, 0x01, 0x00, 0x00, 0xa3 }); AddImm32(code, observations.ToInt32() + 44); + code.AddRange(new byte[] { 0x5f, 0x5e, 0x59, 0x5a, 0x58, 0xc2, 0x08, 0x00 }); + return code.ToArray(); + } + + private static void RunAllowedFailure(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) { + Setup(game, app, obs, 0, 5, 0x7f); int result = callback(0, 0, 0); + Expect(result == 1 && Marshal.ReadInt32(game, 8) == 1 && Marshal.ReadByte(app, 0x157) == 0, "failure state writes"); + Expect(Marshal.ReadInt32(obs, 0) == 1 && Marshal.ReadInt32(obs, 40) == 5 && Marshal.ReadInt32(obs, 44) == 0x7f, "failure sound before writes"); + Console.WriteLine("fail: status=1 mission_flag=0 sound={0}", SoundId(obs)); + Expect(SoundId(obs) == "VOICE_MISSION_FAIL" && Marshal.ReadInt32(obs, 8) == 0, "failure voice id and argument"); + } + private static void RunFailureRepeat(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) { + Marshal.WriteByte(app, 0x157, 0x7f); + int result = callback(0, 0, 0); + Expect(result == 1 && Marshal.ReadInt32(game, 8) == 1 && Marshal.ReadByte(app, 0x157) == 0x7f && Marshal.ReadInt32(obs, 0) == 1, "repeat failure preserves mismatched mission flag"); + } + private static void RunOppositeCompletion(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) { + int result = callback(0, 0, 1); Expect(result == 1 && Marshal.ReadInt32(game, 8) == 0 && Marshal.ReadByte(app, 0x157) == 1 && Marshal.ReadInt32(obs, 0) == 2, "completion after failure"); + Expect(Marshal.ReadInt32(obs, 40) == 1 && Marshal.ReadInt32(obs, 44) == 0x7f && SoundId(obs) == "VOICE_MISSION_COMPLETE" && Marshal.ReadInt32(obs, 8) == 0, "completion sound before writes"); + } + private static void RunCompletionRepeat(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) { + Marshal.WriteByte(app, 0x157, 0x7f); + int result = callback(0, 0, 1); + Expect(result == 1 && Marshal.ReadInt32(game, 8) == 0 && Marshal.ReadByte(app, 0x157) == 0x7f && Marshal.ReadInt32(obs, 0) == 2, "repeat completion preserves mismatched mission flag"); + } + private static void RunFailureAfterCompletion(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) { + int result = callback(0, 0, 0); + Expect(result == 1 && Marshal.ReadInt32(game, 8) == 1 && Marshal.ReadByte(app, 0x157) == 0 && Marshal.ReadInt32(obs, 0) == 3, "failure after completion"); + Expect(Marshal.ReadInt32(obs, 40) == 0 && Marshal.ReadInt32(obs, 44) == 0x7f && SoundId(obs) == "VOICE_MISSION_FAIL" && Marshal.ReadInt32(obs, 8) == 0, "reverse transition sound before writes"); + Console.WriteLine("fail-after-complete: status=1 mission_flag=0 sound={0}", SoundId(obs)); + } + private static void RunAutoDemoGate(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) { + Setup(game, app, obs, 1, 5, 0x7f); callback(0, 0, 0); + Expect(Marshal.ReadInt32(game, 8) == 5 && Marshal.ReadByte(app, 0x157) == 0x7f && Marshal.ReadInt32(obs, 0) == 0, "AutoDemo gate suppresses callback"); + } + private static void RunNonZeroGameMode(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) { + Setup(game, app, obs, 0, 5, 0x7f); callback(1, 0, 0); + Expect(Marshal.ReadInt32(game, 8) == 5 && Marshal.ReadByte(app, 0x157) == 0x7f && Marshal.ReadInt32(obs, 0) == 0, "nonzero callback mode suppressed"); + } + private static void RunUnknownPayload(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) { + Setup(game, app, obs, 0, 5, 0x7f); callback(0, 0, 2); + Expect(Marshal.ReadInt32(game, 8) == 5 && Marshal.ReadByte(app, 0x157) == 0x7f && Marshal.ReadInt32(obs, 0) == 0, "unknown payload suppressed"); + } + private static void Setup(IntPtr game, IntPtr app, IntPtr obs, byte gate, int status, byte missionFlag) { + Marshal.WriteByte(game, 0xe5, gate); Marshal.WriteInt32(game, 8, status); Marshal.WriteByte(app, 0x157, missionFlag); + Zero(obs, 56); + } + private static void Expect(bool value, string description) { if (!value) throw new InvalidOperationException("native oracle assertion failed: " + description); } + private static string SoundId(IntPtr observations) { + int length = Marshal.ReadInt32(observations, 4); + if (Marshal.ReadByte(observations, 48) != 0) throw new InvalidOperationException("native sound identifier exceeded recorder capacity"); + if (length < 0 || length > 23) throw new InvalidOperationException("unexpected sound identifier length: " + length); + byte[] bytes = new byte[length]; Marshal.Copy(IntPtr.Add(observations, 12), bytes, 0, length); + return Encoding.ASCII.GetString(bytes); + } + private static void Zero(IntPtr buffer, int length) { for (int offset = 0; offset < length; offset++) Marshal.WriteByte(buffer, offset, 0); } + private static void AddImm32(List<byte> code, int value) { byte[] bytes = BitConverter.GetBytes(value); code.AddRange(bytes); } + private static void WriteImm32(byte[] bytes, int offset, int value) { byte[] immediate = BitConverter.GetBytes(value); Buffer.BlockCopy(immediate, 0, bytes, offset, 4); } +} |
