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
+524 -68

No files matched your search

+3 -1
View File
@@ -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,
)
+249 -49
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.
#[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,
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 {
mode: 0,
first: 0,
second: 0,
},
),
)
.expect("apply failure callback"),
MissionScriptHostEffect::MissionFailed
);
assert_eq!(outcome, MissionScriptOutcome::Failed);
assert_eq!(engine.loaded.as_ref().unwrap().script_host_status, 1);
assert_eq!(
apply_recovered_script_host_callback(
&mut outcome,
VmHostCallbackCommand {
mode: 0,
first: 0,
second: 1,
},
),
MissionScriptHostEffect::MissionCompleted
loaded_mission_script_outcome(&engine),
Some(MissionScriptOutcome::Failed)
);
assert_eq!(outcome, MissionScriptOutcome::Completed);
engine.loaded.as_mut().unwrap().script_outcome = MissionScriptOutcome::Completed;
assert_eq!(
apply_recovered_script_host_callback(
&mut outcome,
apply_loaded_script_host_callback(
&mut engine,
VmHostCallbackCommand {
mode: 0,
first: 0,
second: 1,
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!(
@@ -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`, а не утверждение о совпадении во время
исполнения.
Для `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-а условий отдельно проследить
+63 -18
View File
@@ -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
+12
View File
@@ -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