aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/fparkan-game/src/main.rs4
-rw-r--r--crates/fparkan-runtime/src/lib.rs294
-rw-r--r--crates/fparkan-runtime/tests/original_host_callback_oracle.cs185
-rw-r--r--docs/appendices/knowledge-boundaries.md12
-rw-r--r--docs/appendices/script-vm.md81
-rw-r--r--docs/tomes/06-behavior.md12
6 files changed, 522 insertions, 66 deletions
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs
index d615aaf..379fac9 100644
--- a/apps/fparkan-game/src/main.rs
+++ b/apps/fparkan-game/src/main.rs
@@ -54,7 +54,7 @@ use fparkan_render_vulkan::{
use fparkan_runtime::{
create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets,
loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets,
- MissionObjectDraft, MissionRequest,
+ MissionMode, MissionObjectDraft, MissionRequest,
};
use fparkan_terrain::{
FullSurfaceMask, SurfaceQuery, TerrainLod, TerrainMaterialLayers, TerrainWorld,
@@ -173,6 +173,7 @@ fn load_requested_mission(
) -> Result<fparkan_runtime::LoadedMission, String> {
let request = MissionRequest {
key: args.mission.clone(),
+ mode: MissionMode::SingleMission,
};
if args.preview_roots.get() == 1 {
load_mission_static_preview(engine, request)
@@ -8090,6 +8091,7 @@ mod tests {
&mut engine,
MissionRequest {
key: mission.clone(),
+ mode: MissionMode::SingleMission,
},
NonZeroUsize::MAX,
)
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); }
+}
diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md
index ad7bc13..af89d49 100644
--- a/docs/appendices/knowledge-boundaries.md
+++ b/docs/appendices/knowledge-boundaries.md
@@ -44,6 +44,18 @@ state ещё не восстановлены.
вывод по одному известному `ai.dll`, а не утверждение о совпадении во время
исполнения.
+Для `Handler(30)` отдельно подтверждены обратный вызов `iron3d.dll` RVA
+`0x611d0` (SHA-256 `d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`),
+начальные поля IGame и проверки режима/состояния. Обработчик выбора миссии
+выставляет режим 1 (RVA `0x29142`), а обработчик таймера — режим 3 (RVA `0x70f3`).
+Небольшая тестовая x86-программа сравнивает этот обратный вызов с исходной DLL
+на синтетических структурах. Пока не восстановлен сквозной путь от выбора и
+инициализации миссии к первому событию Handler(30): Rust загружает миссию, но
+ещё не выполняет штатный `stdInitGame`, переход состояния `5 -> 4`, запуск и
+игровой цикл с полной диспетчеризацией событий. Поэтому
+совпадение изолированного обратного вызова не доказывает совместимость живого
+сценария.
+
Закрывающий эксперимент: найти dispatcher loop в `ai.dll`, сопоставить jump
table с instruction sizes, построить disassembler и сравнить выполнение
коротких scripts с оригиналом. Для evaluator-а условий отдельно проследить
diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md
index a2a2a3d..0bffbe5 100644
--- a/docs/appendices/script-vm.md
+++ b/docs/appendices/script-vm.md
@@ -343,21 +343,20 @@ remaining script event semantics.
текстовых значений по умолчанию. `resolve_loaded_handler30` возвращает точную
команду обратного вызова `(mode=0, first, second)`, не приписывая ей
дополнительный смысл. Отдельная функция `apply_loaded_script_host_callback`
-отображает в Rust состояние исхода только `(0, 0, payload=0|1)`; она не
-выбирает событие `.scr` и не исполняет ветвь оригинальной игры.
+моделирует в Rust состояние исхода для `(0, 0, payload=0|1)`; она не выбирает
+событие `.scr` и не вызывает машинный код оригинала.
Снимок работающей GOG AutoDemo определяет обратный вызов: прочитанный без записи
указатель по `ai.dll + 0x555e4` равен `0x100611d0`, то есть `iron3d.dll +
0x611d0` при наблюдавшейся базе загрузки. Восстановленное ABI вызываемой
функции — `__cdecl(mode, command, payload)`; `Handler(30)` передаёт те же поля как
-`(0, first, second)`. В режиме `mode == 0` команда `command == 0` выполняется,
-только если контрольный байт по `IGame + 0xe5` равен нулю. Ветвь отказа при
-значении аргумента `payload`, равном `0`, срабатывает лишь когда слово по
-`IGame + 8` не равно `1`: игра запускает
-`VOICE_MISSION_FAIL`, записывает `1` в это слово и обнуляет байт по
-`*(IGame + 4) + 0x157`. Ветвь завершения при `payload`, равном `1`, срабатывает
-лишь когда слово не равно `0`: игра запускает `VOICE_MISSION_COMPLETE`, записывает
-`0` в слово и устанавливает байт в `1`. Команды 3, 4 и 5 ведут к другим
+`(0, first, second)`. Обратный вызов принимает интересующую нас команду только
+при `mode == 0`, `command == 0` и нулевом байте `IGame + 0xe5`. Ветвь отказа
+(`payload == 0`) запускает `VOICE_MISSION_FAIL`, записывает `1` в слово `IGame + 8`
+и обнуляет байт `*(IGame + 4) + 0x157`, только если прежнее слово не равно `1`.
+Ветвь завершения (`payload == 1`) запускает `VOICE_MISSION_COMPLETE`, записывает
+`0` в слово и устанавливает байт в `1`, только если прежнее слово не равно `0`.
+Оба звуковых вызова предшествуют этим записям. Команды 3, 4 и 5 ведут к другим
ветвям игры; режим `2` выполняет отдельный вызов IGame, а ветви для режима `1`
в этой функции нет.
@@ -376,14 +375,60 @@ remaining script event semantics.
повторных вызовов, но не назначение сообщения интерфейса или смысл этих
идентификаторов; в Rust команда `1` поэтому остаётся `Unhandled`.
-Сейчас `apply_loaded_script_host_callback` отображает `(0, 0, 0)` в локальное
-состояние `Failed`, `(0, 0, 1)` — в `Completed`, а повтор уже записанного
-состояния возвращает `NoChange`. Эта дедупликация относится только к модели
-Rust и не заменяет проверки Iron3D: в Rust нет ни байта блокировки
-`IGame + 0xe5`, ни слова состояния `IGame + 8`. Значение `Active` назначается
-при загрузке миссии, но его соответствие исходному состоянию игры пока не
-подтверждено. Команды 1, 3, 4, 5 и режим 2 остаются `Unhandled` в этом
-обработчике исхода.
+### Состояние обратного вызова исхода миссии
+
+Слово `IGame + 8` создаётся не загрузчиком миссии. В исследованной GOG-сборке
+`iron3d.dll` (SHA-256
+`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`, image base
+`0x10000000`) конструктор `FUN_1005c820` записывает туда `5` (RVA `0x5c84f`), а
+байт `IGame + 0xe5` обнуляет (RVA `0x5c9f6`). При настройке игры функция
+`FUN_1005cbb0` задаёт `+0xe5` выражением `(*AppState == 3)` (RVA `0x5cc4a`);
+обработчик таймера главного меню выставляет режим `3` для AutoDemo (RVA `0x70f3`).
+Отдельный обработчик выбора миссии записывает режим `1` (RVA `0x29142`) перед
+переходом к пути `missions/single.*`. После успешной стандартной инициализации
+игровой цикл записывает в `IGame + 8` значение `4` (`FUN_1005eb70`, RVA `0x5eb70`).
+Этот переход запуска не выполняется при статическом просмотре миссии и здесь не
+моделируется.
+
+`MissionRequest` в Rust несёт явный `MissionMode::{SingleMission, AutoDemo}`;
+режим задаёт вызывающая сторона и его нельзя выводить из имени пути. Во время
+загрузки Rust инициализирует своё слово состояния значением `5`, как у нового
+IGame, и сохраняет запрошенный режим; сама загрузка не вызывает нативный
+конструктор.
+Публичный `apply_loaded_script_host_callback` применяет те же интересующие нас
+проверки: режим `AutoDemo` закрывает ветвь, а в `SingleMission` обратный вызов
+меняет только слово состояния и исход Rust, если выполняется исходное условие
+по этому слову. Поэтому при состоянии `4` ветвь завершения разрешена, как и
+ветвь отказа, пока слово не равно `1`. Повторный отказ при состоянии `1` либо
+завершение при состоянии `0` ничего не меняют; если тест намеренно
+рассинхронизирует исход Rust и слово состояния, повтор оставляет это
+расхождение. Так работает исходная проверка слова, а не выравнивание всех полей.
+
+Изолированное сравнение с оригинальной DLL выполнено небольшой тестовой
+x86-программой `crates/fparkan-runtime/tests/original_host_callback_oracle.cs`:
+программа проверяет SHA-256, загружает `iron3d.dll` и вызывает RVA `0x611d0` с
+синтетическими IGame и AppState. Для изоляции проверки она временно подставляет
+синтетический IGame через глобальную переменную `DAT_1010a5f8` (RVA `0x10a5f8`)
+и заменяет слот таблицы импортов звукового интерфейса (RVA `0xe42cc`)
+регистратором; машинный код обратного вызова остаётся исходным. Проверка сверяет
+записи полей, повторные/обратные переходы, блокирующий `+0xe5` и порядок вызова
+звука. Это
+подтверждает локальную семантику обратного вызова, но не запуск исходной миссии,
+диспетчеризацию Handler(30) или весь игровой цикл. Rust пока не запускает
+диспетчеризацию сценария автоматически. Команды 1, 3, 4, 5 и режим 2 остаются
+`Unhandled` в этом обработчике исхода.
+
+Повторить из корня репозитория в PowerShell на Windows с установленными GOG
+ресурсами можно так:
+
+```powershell
+$env:FPARKAN_ORIGINAL_IRON3D_DLL = 'C:\GOG Games\Parkan - Iron Strategy\iron3d.dll'
+New-Item -ItemType Directory -Force -Path target\original-host-callback-probe | Out-Null
+& 'C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe' /nologo /platform:x86 /out:target\original-host-callback-probe\HostCallbackOracle.exe crates\fparkan-runtime\tests\original_host_callback_oracle.cs
+if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+& .\target\original-host-callback-probe\HostCallbackOracle.exe
+exit $LASTEXITCODE
+```
### Handler(8): problem-record state write
diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md
index cd61533..3742772 100644
--- a/docs/tomes/06-behavior.md
+++ b/docs/tomes/06-behavior.md
@@ -229,6 +229,18 @@ Tag выбирает конкретное правило. Для tag 1 запиÑ
[`приложении о сценарной VM`](../appendices/script-vm.md#evaluator-записей-условий-ai-dll-rva-0x5180)
и [`границах знаний`](../appendices/knowledge-boundaries.md#script-vm).
+### Исход миссии и состояние игры
+
+Сообщение сценария об успехе или провале проходит ещё один рубеж — обратный
+вызов в игровое приложение. У этой границы есть собственные правила: в режиме
+отдельной миссии вызов может записать новый исход, а в автоматической демо-игре
+та же ветвь подавляется. Повторный сигнал не обязан исправлять соседний флаг
+или сохранённый Rust-исход: оригинал решает по своему слову состояния, а не по
+сравнению всех полей между собой. Поэтому загрузка карты не означает, что игра
+уже перешла в активный игровой цикл; это отдельный шаг запуска. Технические
+смещения, начальное состояние и граница проверки описаны в
+[`приложении о сценарной VM`](../appendices/script-vm.md#состояние-обратного-вызова-исхода-миссии).
+
### TRF и preload-данные
TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial