fix(runtime): preserve native mission outcome guards
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s

This commit is contained in:
Valentin Popov committed 2026-10-20 03:20:20 +04:00
1 parent 65f1298d93
commit dc8be18458
6 files changed
+522 -66

No files matched your search

+3 -1
View File
@@ -54,7 +54,7 @@ use fparkan_render_vulkan::{
use fparkan_runtime::{ use fparkan_runtime::{
create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets, create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets,
loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets, loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets,
MissionObjectDraft, MissionRequest, MissionMode, MissionObjectDraft, MissionRequest,
}; };
use fparkan_terrain::{ use fparkan_terrain::{
FullSurfaceMask, SurfaceQuery, TerrainLod, TerrainMaterialLayers, TerrainWorld, FullSurfaceMask, SurfaceQuery, TerrainLod, TerrainMaterialLayers, TerrainWorld,
@@ -173,6 +173,7 @@ fn load_requested_mission(
) -> Result<fparkan_runtime::LoadedMission, String> { ) -> Result<fparkan_runtime::LoadedMission, String> {
let request = MissionRequest { let request = MissionRequest {
key: args.mission.clone(), key: args.mission.clone(),
mode: MissionMode::SingleMission,
}; };
if args.preview_roots.get() == 1 { if args.preview_roots.get() == 1 {
load_mission_static_preview(engine, request) load_mission_static_preview(engine, request)
@@ -8090,6 +8091,7 @@ mod tests {
&mut engine, &mut engine,
MissionRequest { MissionRequest {
key: mission.clone(), key: mission.clone(),
mode: MissionMode::SingleMission,
}, },
NonZeroUsize::MAX, NonZeroUsize::MAX,
) )
+247 -47
View File
@@ -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. /// Mission request.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionRequest { pub struct MissionRequest {
/// Mission key/path. /// Mission key/path.
pub key: String, 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. /// Ordered mission property preserved for later runtime stages.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionObjectProperty { pub struct MissionObjectProperty {
@@ -197,10 +210,11 @@ pub struct MissionScriptVarSetState {
pub values: Vec<VarSetDefault>, 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)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum MissionScriptOutcome { 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] #[default]
Active, Active,
/// Rust outcome corresponding to completion payload `1`. /// Rust outcome corresponding to completion payload `1`.
@@ -218,7 +232,7 @@ pub enum MissionScriptHostEffect {
MissionCompleted, MissionCompleted,
/// The Rust outcome changed to failed. /// The Rust outcome changed to failed.
MissionFailed, MissionFailed,
/// The Rust outcome already held this target state. /// The native status guard rejected a repeated callback without changing state.
NoChange, NoChange,
} }
@@ -357,6 +371,8 @@ struct LoadedMissionState {
script_varset: Option<MissionScriptVarSet>, script_varset: Option<MissionScriptVarSet>,
script_init_states: Vec<MissionScriptInitState>, script_init_states: Vec<MissionScriptInitState>,
script_varset_states: Vec<MissionScriptVarSetState>, script_varset_states: Vec<MissionScriptVarSetState>,
script_host_mode: MissionMode,
script_host_status: u32,
script_outcome: MissionScriptOutcome, script_outcome: MissionScriptOutcome,
} }
@@ -899,6 +915,10 @@ fn load_mission_with_options(
script_varset: loaded_scripts.varset, script_varset: loaded_scripts.varset,
script_init_states: loaded_scripts.init_states, script_init_states: loaded_scripts.init_states,
script_varset_states: loaded_scripts.varset_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, script_outcome: MissionScriptOutcome::Active,
}); });
Ok(summary) 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 /// Unlike the loader-only resolver, operands are read from the selected
/// clan's instantiated varset cells. Dispatching the returned host callback /// clan's instantiated varset cells. Dispatching the returned host callback
/// remains separate from this function; the Rust outcome mapper does not model /// remains separate from this function. The runtime currently models the
/// the native `IGame` guards or initial status word. /// callback's recovered mode and status guards, but not the full game startup
/// transition or frame/event execution that reaches it.
/// ///
/// # Errors /// # Errors
/// ///
@@ -1040,13 +1061,16 @@ pub fn loaded_mission_script_outcome(engine: &Engine) -> Option<MissionScriptOut
engine.loaded.as_ref().map(|state| state.script_outcome) 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 /// Native mission mode `3` closes the callback gate. Otherwise the callback
/// completion. Iron3D also checks the gate byte at `IGame + 0xe5` and the /// must have `(mode=0, command=0)` and payload `0` or `1`. Payload `0` writes
/// status word at `IGame + 8`; those native guards and the initial status are /// status `1` and records failure only when the current status is not `1`;
/// not represented here. Other callback commands return /// payload `1` writes status `0` and records completion only when the current
/// [`MissionScriptHostEffect::Unhandled`]. /// 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 /// # Errors
/// ///
@@ -1063,31 +1087,36 @@ pub fn apply_loaded_script_host_callback(
message: "mission script host state is unavailable".to_string(), message: "mission script host state is unavailable".to_string(),
})?; })?;
Ok(apply_recovered_script_host_callback( Ok(apply_recovered_script_host_callback(
state.script_host_mode,
&mut state.script_host_status,
&mut state.script_outcome, &mut state.script_outcome,
command, command,
)) ))
} }
fn apply_recovered_script_host_callback( fn apply_recovered_script_host_callback(
native_mode: MissionMode,
status: &mut u32,
outcome: &mut MissionScriptOutcome, outcome: &mut MissionScriptOutcome,
command: VmHostCallbackCommand, command: VmHostCallbackCommand,
) -> MissionScriptHostEffect { ) -> MissionScriptHostEffect {
if command.mode != 0 || command.first != 0 { if native_mode == MissionMode::AutoDemo || command.mode != 0 || command.first != 0 {
return MissionScriptHostEffect::Unhandled; return MissionScriptHostEffect::Unhandled;
} }
let next = match command.second { match command.second {
0 => MissionScriptOutcome::Failed, 0 if *status != 1 => {
1 => MissionScriptOutcome::Completed, *status = 1;
_ => return MissionScriptHostEffect::Unhandled, *outcome = MissionScriptOutcome::Failed;
}; MissionScriptHostEffect::MissionFailed
if *outcome == next {
return MissionScriptHostEffect::NoChange;
} }
*outcome = next; 0 => MissionScriptHostEffect::NoChange,
match next { 1 if *status != 0 => {
MissionScriptOutcome::Completed => MissionScriptHostEffect::MissionCompleted, *status = 0;
MissionScriptOutcome::Failed => MissionScriptHostEffect::MissionFailed, *outcome = MissionScriptOutcome::Completed;
MissionScriptOutcome::Active => MissionScriptHostEffect::Unhandled, MissionScriptHostEffect::MissionCompleted
}
1 => MissionScriptHostEffect::NoChange,
_ => MissionScriptHostEffect::Unhandled,
} }
} }
@@ -1446,6 +1475,7 @@ mod tests {
&mut engine, &mut engine,
MissionRequest { MissionRequest {
key: "MISSIONS/Autodemo.00/data.tma".to_string(), key: "MISSIONS/Autodemo.00/data.tma".to_string(),
mode: MissionMode::SingleMission,
}, },
) )
.expect_err("missing VFS"); .expect_err("missing VFS");
@@ -1598,54 +1628,212 @@ mod tests {
} }
#[test] #[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; let mut outcome = MissionScriptOutcome::Active;
assert_eq!( assert_eq!(
apply_recovered_script_host_callback( apply_recovered_script_host_callback(
MissionMode::SingleMission,
&mut status,
&mut outcome, &mut outcome,
command(0),
),
MissionScriptHostEffect::MissionFailed
);
assert_eq!((status, outcome), (1, MissionScriptOutcome::Failed));
outcome = MissionScriptOutcome::Completed;
assert_eq!(
apply_recovered_script_host_callback(
MissionMode::SingleMission,
&mut status,
&mut outcome,
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!((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 { VmHostCallbackCommand {
mode: 0, mode: 0,
first: 0, first: 0,
second: 0, second: 0,
}, },
), )
.expect("apply failure callback"),
MissionScriptHostEffect::MissionFailed MissionScriptHostEffect::MissionFailed
); );
assert_eq!(outcome, MissionScriptOutcome::Failed); assert_eq!(engine.loaded.as_ref().unwrap().script_host_status, 1);
assert_eq!( assert_eq!(
apply_recovered_script_host_callback( loaded_mission_script_outcome(&engine),
&mut outcome, Some(MissionScriptOutcome::Failed)
VmHostCallbackCommand {
mode: 0,
first: 0,
second: 1,
},
),
MissionScriptHostEffect::MissionCompleted
); );
assert_eq!(outcome, MissionScriptOutcome::Completed);
engine.loaded.as_mut().unwrap().script_outcome = MissionScriptOutcome::Completed;
assert_eq!( assert_eq!(
apply_recovered_script_host_callback( apply_loaded_script_host_callback(
&mut outcome, &mut engine,
VmHostCallbackCommand { VmHostCallbackCommand {
mode: 0, mode: 0,
first: 0, first: 0,
second: 1, second: 0,
}, },
), )
.expect("apply repeated failure callback"),
MissionScriptHostEffect::NoChange MissionScriptHostEffect::NoChange
); );
assert_eq!(engine.loaded.as_ref().unwrap().script_host_status, 1);
assert_eq!( assert_eq!(
apply_recovered_script_host_callback( loaded_mission_script_outcome(&engine),
&mut outcome, 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 { VmHostCallbackCommand {
mode: 0, mode: 0,
first: 3, first: 0,
second: 42, second: 1,
}, },
), )
.expect("apply AutoDemo callback"),
MissionScriptHostEffect::Unhandled MissionScriptHostEffect::Unhandled
); );
assert_eq!(engine.loaded.as_ref().unwrap().script_host_status, 5);
assert_eq!(
loaded_mission_script_outcome(&engine),
Some(MissionScriptOutcome::Active)
);
} }
#[test] #[test]
@@ -1676,6 +1864,7 @@ mod tests {
&mut engine, &mut engine,
MissionRequest { MissionRequest {
key: "MISSIONS/Autodemo.00/data.tma".to_string(), key: "MISSIONS/Autodemo.00/data.tma".to_string(),
mode: MissionMode::SingleMission,
}, },
) )
.expect("load mission"); .expect("load mission");
@@ -1732,12 +1921,14 @@ mod tests {
DenyRule::Suffix("Land.map"), DenyRule::Suffix("Land.map"),
MissionRequest { MissionRequest {
key: "MISSIONS/Autodemo.00/data.tma".to_string(), key: "MISSIONS/Autodemo.00/data.tma".to_string(),
mode: MissionMode::SingleMission,
}, },
), ),
( (
DenyRule::Suffix("objects.rlb"), DenyRule::Suffix("objects.rlb"),
MissionRequest { MissionRequest {
key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(), key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
mode: MissionMode::SingleMission,
}, },
), ),
] { ] {
@@ -1773,6 +1964,7 @@ mod tests {
&mut engine, &mut engine,
MissionRequest { MissionRequest {
key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(), key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
mode: MissionMode::SingleMission,
}, },
MissionLoadOptions { MissionLoadOptions {
fail_after_registered_objects: Some(1), fail_after_registered_objects: Some(1),
@@ -1842,6 +2034,7 @@ mod tests {
&mut engine, &mut engine,
MissionRequest { MissionRequest {
key: case.1.to_string(), key: case.1.to_string(),
mode: MissionMode::SingleMission,
}, },
) )
.expect("load mission"); .expect("load mission");
@@ -1954,6 +2147,7 @@ mod tests {
&mut engine, &mut engine,
MissionRequest { MissionRequest {
key: case.mission.to_string(), key: case.mission.to_string(),
mode: MissionMode::SingleMission,
}, },
) )
.expect("load selected mission"); .expect("load selected mission");
@@ -1978,7 +2172,13 @@ mod tests {
let mut totals = LoadTotals::default(); let mut totals = LoadTotals::default();
for mission in missions { for mission in missions {
let mut engine = create(EngineServices::new(vfs.clone())).expect("engine"); let mut engine = create(EngineServices::new(vfs.clone())).expect("engine");
let loaded = load_mission(&mut engine, MissionRequest { key: mission }) let loaded = load_mission(
&mut engine,
MissionRequest {
key: mission,
mode: MissionMode::SingleMission,
},
)
.expect("load mission foundation"); .expect("load mission foundation");
assert_eq!(loaded.object_count, loaded.registered_objects); assert_eq!(loaded.object_count, loaded.registered_objects);
assert_eq!(loaded.object_count, loaded.graph_root_count); assert_eq!(loaded.object_count, loaded.graph_root_count);
@@ -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); }
}
+12
View File
@@ -44,6 +44,18 @@ state ещё не восстановлены.
вывод по одному известному `ai.dll`, а не утверждение о совпадении во время вывод по одному известному `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 Закрывающий эксперимент: найти dispatcher loop в `ai.dll`, сопоставить jump
table с instruction sizes, построить disassembler и сравнить выполнение table с instruction sizes, построить disassembler и сравнить выполнение
коротких scripts с оригиналом. Для evaluator-а условий отдельно проследить коротких scripts с оригиналом. Для evaluator-а условий отдельно проследить
+63 -18
View File
@@ -343,21 +343,20 @@ remaining script event semantics.
текстовых значений по умолчанию. `resolve_loaded_handler30` возвращает точную текстовых значений по умолчанию. `resolve_loaded_handler30` возвращает точную
команду обратного вызова `(mode=0, first, second)`, не приписывая ей команду обратного вызова `(mode=0, first, second)`, не приписывая ей
дополнительный смысл. Отдельная функция `apply_loaded_script_host_callback` дополнительный смысл. Отдельная функция `apply_loaded_script_host_callback`
отображает в Rust состояние исхода только `(0, 0, payload=0|1)`; она не моделирует в Rust состояние исхода для `(0, 0, payload=0|1)`; она не выбирает
выбирает событие `.scr` и не исполняет ветвь оригинальной игры. событие `.scr` и не вызывает машинный код оригинала.
Снимок работающей GOG AutoDemo определяет обратный вызов: прочитанный без записи Снимок работающей GOG AutoDemo определяет обратный вызов: прочитанный без записи
указатель по `ai.dll + 0x555e4` равен `0x100611d0`, то есть `iron3d.dll + указатель по `ai.dll + 0x555e4` равен `0x100611d0`, то есть `iron3d.dll +
0x611d0` при наблюдавшейся базе загрузки. Восстановленное ABI вызываемой 0x611d0` при наблюдавшейся базе загрузки. Восстановленное ABI вызываемой
функции — `__cdecl(mode, command, payload)`; `Handler(30)` передаёт те же поля как функции — `__cdecl(mode, command, payload)`; `Handler(30)` передаёт те же поля как
`(0, first, second)`. В режиме `mode == 0` команда `command == 0` выполняется, `(0, first, second)`. Обратный вызов принимает интересующую нас команду только
только если контрольный байт по `IGame + 0xe5` равен нулю. Ветвь отказа при при `mode == 0`, `command == 0` и нулевом байте `IGame + 0xe5`. Ветвь отказа
значении аргумента `payload`, равном `0`, срабатывает лишь когда слово по (`payload == 0`) запускает `VOICE_MISSION_FAIL`, записывает `1` в слово `IGame + 8`
`IGame + 8` не равно `1`: игра запускает и обнуляет байт `*(IGame + 4) + 0x157`, только если прежнее слово не равно `1`.
`VOICE_MISSION_FAIL`, записывает `1` в это слово и обнуляет байт по Ветвь завершения (`payload == 1`) запускает `VOICE_MISSION_COMPLETE`, записывает
`*(IGame + 4) + 0x157`. Ветвь завершения при `payload`, равном `1`, срабатывает `0` в слово и устанавливает байт в `1`, только если прежнее слово не равно `0`.
лишь когда слово не равно `0`: игра запускает `VOICE_MISSION_COMPLETE`, записывает Оба звуковых вызова предшествуют этим записям. Команды 3, 4 и 5 ведут к другим
`0` в слово и устанавливает байт в `1`. Команды 3, 4 и 5 ведут к другим
ветвям игры; режим `2` выполняет отдельный вызов IGame, а ветви для режима `1` ветвям игры; режим `2` выполняет отдельный вызов IGame, а ветви для режима `1`
в этой функции нет. в этой функции нет.
@@ -376,14 +375,60 @@ remaining script event semantics.
повторных вызовов, но не назначение сообщения интерфейса или смысл этих повторных вызовов, но не назначение сообщения интерфейса или смысл этих
идентификаторов; в Rust команда `1` поэтому остаётся `Unhandled`. идентификаторов; в Rust команда `1` поэтому остаётся `Unhandled`.
Сейчас `apply_loaded_script_host_callback` отображает `(0, 0, 0)` в локальное ### Состояние обратного вызова исхода миссии
состояние `Failed`, `(0, 0, 1)` — в `Completed`, а повтор уже записанного
состояния возвращает `NoChange`. Эта дедупликация относится только к модели Слово `IGame + 8` создаётся не загрузчиком миссии. В исследованной GOG-сборке
Rust и не заменяет проверки Iron3D: в Rust нет ни байта блокировки `iron3d.dll` (SHA-256
`IGame + 0xe5`, ни слова состояния `IGame + 8`. Значение `Active` назначается `d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`, image base
при загрузке миссии, но его соответствие исходному состоянию игры пока не `0x10000000`) конструктор `FUN_1005c820` записывает туда `5` (RVA `0x5c84f`), а
подтверждено. Команды 1, 3, 4, 5 и режим 2 остаются `Unhandled` в этом байт `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 ### Handler(8): problem-record state write
+12
View File
@@ -229,6 +229,18 @@ Tag выбирает конкретное правило. Для tag 1 запи
[`приложении о сценарной VM`](../appendices/script-vm.md#evaluator-записей-условий-ai-dll-rva-0x5180) [`приложении о сценарной VM`](../appendices/script-vm.md#evaluator-записей-условий-ai-dll-rva-0x5180)
и [`границах знаний`](../appendices/knowledge-boundaries.md#script-vm). и [`границах знаний`](../appendices/knowledge-boundaries.md#script-vm).
### Исход миссии и состояние игры
Сообщение сценария об успехе или провале проходит ещё один рубеж — обратный
вызов в игровое приложение. У этой границы есть собственные правила: в режиме
отдельной миссии вызов может записать новый исход, а в автоматической демо-игре
та же ветвь подавляется. Повторный сигнал не обязан исправлять соседний флаг
или сохранённый Rust-исход: оригинал решает по своему слову состояния, а не по
сравнению всех полей между собой. Поэтому загрузка карты не означает, что игра
уже перешла в активный игровой цикл; это отдельный шаг запуска. Технические
смещения, начальное состояние и граница проверки описаны в
[`приложении о сценарной VM`](../appendices/script-vm.md#состояние-обратного-вызова-исхода-миссии).
### TRF и preload-данные ### TRF и preload-данные
TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial