feat(runtime): resolve clan init writes

This commit is contained in:
2026-07-18 22:15:55 +04:00
parent f324017151
commit d91e63fa9a
6 changed files with 305 additions and 8 deletions
+2 -1
View File
@@ -56,7 +56,7 @@ fn run() -> Result<(), String> {
let loaded = load_mission(&mut engine, MissionRequest { key: mission }) let loaded = load_mission(&mut engine, MissionRequest { key: mission })
.map_err(|err| format!("{err}"))?; .map_err(|err| format!("{err}"))?;
println!( println!(
"mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} scripts={} script_events={} script_varset_declarations={} graph_failures={}", "mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} scripts={} script_events={} script_varset_declarations={} script_init_states={} graph_failures={}",
loaded.object_count, loaded.object_count,
loaded.areal_count, loaded.areal_count,
loaded.surface_count, loaded.surface_count,
@@ -69,6 +69,7 @@ fn run() -> Result<(), String> {
loaded.script_bundle_count, loaded.script_bundle_count,
loaded.script_event_count, loaded.script_event_count,
loaded.script_varset_declaration_count, loaded.script_varset_declaration_count,
loaded.script_init_state_count,
loaded.graph_failure_count loaded.graph_failure_count
); );
} }
+208 -4
View File
@@ -34,7 +34,9 @@ use fparkan_prototype::{
UnitComponentRecord, UnitComponentRecord,
}; };
use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository}; use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository};
use fparkan_script::{ScriptPackage, VarSet}; use fparkan_script::{
Handler19DwordWrite, Handler19InitInput, ScriptDispatchSelector, ScriptPackage, VarSet,
};
use fparkan_terrain::SurfaceQuery; use fparkan_terrain::SurfaceQuery;
use fparkan_vfs::{Vfs, VfsError}; use fparkan_vfs::{Vfs, VfsError};
use fparkan_world::{ use fparkan_world::{
@@ -248,7 +250,8 @@ pub struct MissionScriptBundle {
pub base_path: String, pub base_path: String,
/// Resolved compiled `.scr` path. /// Resolved compiled `.scr` path.
pub script_path: String, pub script_path: String,
/// Losslessly decoded compiled package; not yet executed by runtime. /// Losslessly decoded compiled package; only proven `Handler(19)` Init
/// writes are resolved and retained by runtime.
pub package: ScriptPackage, pub package: ScriptPackage,
} }
@@ -256,7 +259,7 @@ pub struct MissionScriptBundle {
/// ///
/// GOG `ai.dll` first tries `<bundle-base>.var`, then falls back to the shared /// GOG `ai.dll` first tries `<bundle-base>.var`, then falls back to the shared
/// `MISSIONS/SCRIPTS/varset.var`. This stores the successfully selected source /// `MISSIONS/SCRIPTS/varset.var`. This stores the successfully selected source
/// without claiming that the not-yet-recovered VM executes its declarations. /// and supplies the typed targets for the proven `Handler(19)` Init writes.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionScriptVarSet { pub struct MissionScriptVarSet {
/// Resolved `.var` path selected by the original fallback order. /// Resolved `.var` path selected by the original fallback order.
@@ -267,6 +270,21 @@ pub struct MissionScriptVarSet {
pub declarations: VarSet, pub declarations: VarSet,
} }
/// One resolved, corpus-proven `Init` result for a mission clan.
///
/// This is intentionally limited to `Handler(19)`: an `Init` event's other
/// selectors are retained in [`MissionScriptBundle::package`] but are not
/// guessed as executable behavior.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionScriptInitState {
/// TMA clan index that owns the `SuperAI` instance and varset.
pub clan_index: usize,
/// Raw event word preserved from the compiled `Init` event.
pub event_word: u32,
/// Exact three DWORD writes made by each proven `Handler(19)` instruction.
pub writes: Vec<Handler19DwordWrite>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct MissionLoadOptions { struct MissionLoadOptions {
fail_after_registered_objects: Option<usize>, fail_after_registered_objects: Option<usize>,
@@ -277,6 +295,7 @@ struct MissionLoadOptions {
struct LoadedMissionScripts { struct LoadedMissionScripts {
bundles: Vec<MissionScriptBundle>, bundles: Vec<MissionScriptBundle>,
varset: Option<MissionScriptVarSet>, varset: Option<MissionScriptVarSet>,
init_states: Vec<MissionScriptInitState>,
} }
/// Selects how much of the resolved mission asset graph to prepare. /// Selects how much of the resolved mission asset graph to prepare.
@@ -366,6 +385,8 @@ pub struct LoadedMission {
pub script_event_count: usize, pub script_event_count: usize,
/// Numeric script defaults loaded through the original bundle/fallback rule. /// Numeric script defaults loaded through the original bundle/fallback rule.
pub script_varset_declaration_count: usize, pub script_varset_declaration_count: usize,
/// `Handler(19)` Init results resolved for mission clans.
pub script_init_state_count: usize,
} }
/// Frame result. /// Frame result.
@@ -404,6 +425,7 @@ struct LoadedMissionState {
object_drafts: Vec<MissionObjectDraft>, object_drafts: Vec<MissionObjectDraft>,
script_bundles: Vec<MissionScriptBundle>, script_bundles: Vec<MissionScriptBundle>,
script_varset: Option<MissionScriptVarSet>, script_varset: Option<MissionScriptVarSet>,
script_init_states: Vec<MissionScriptInitState>,
} }
/// Engine error. /// Engine error.
@@ -441,6 +463,13 @@ pub enum EngineError {
/// Parse diagnostic. /// Parse diagnostic.
message: String, message: String,
}, },
/// A recovered `Handler(19)` Init input or target was not representable.
ScriptInit {
/// Owning TMA clan index.
clan_index: usize,
/// Proven-contract diagnostic.
message: String,
},
/// `NRes` decode error. /// `NRes` decode error.
Nres { Nres {
/// Resource path. /// Resource path.
@@ -577,6 +606,10 @@ impl std::fmt::Display for EngineError {
Self::Vfs { path, source } => write!(f, "{path}: {source}"), Self::Vfs { path, source } => write!(f, "{path}: {source}"),
Self::Script { path, message } => write!(f, "{path}: script decode failed: {message}"), Self::Script { path, message } => write!(f, "{path}: script decode failed: {message}"),
Self::VarSet { path, message } => write!(f, "{path}: varset decode failed: {message}"), Self::VarSet { path, message } => write!(f, "{path}: varset decode failed: {message}"),
Self::ScriptInit {
clan_index,
message,
} => write!(f, "clan {clan_index}: script Init failed: {message}"),
Self::Nres { path, source } => write!(f, "{path}: {source}"), Self::Nres { path, source } => write!(f, "{path}: {source}"),
Self::Mission { path, source } => write!(f, "{path}: {source}"), Self::Mission { path, source } => write!(f, "{path}: {source}"),
Self::TerrainFormat { path, source } => write!(f, "{path}: {source}"), Self::TerrainFormat { path, source } => write!(f, "{path}: {source}"),
@@ -619,6 +652,7 @@ impl std::error::Error for EngineError {
Self::MissingVfs Self::MissingVfs
| Self::Script { .. } | Self::Script { .. }
| Self::VarSet { .. } | Self::VarSet { .. }
| Self::ScriptInit { .. }
| Self::Movement(_) | Self::Movement(_)
| Self::PrototypeGraph { .. } | Self::PrototypeGraph { .. }
| Self::SchedulerPhaseOrder { .. } | Self::SchedulerPhaseOrder { .. }
@@ -1064,6 +1098,7 @@ fn load_mission_with_options_and_progress(
.varset .varset
.as_ref() .as_ref()
.map_or(0, |varset| varset.declarations.declarations.len()), .map_or(0, |varset| varset.declarations.declarations.len()),
script_init_state_count: loaded_scripts.init_states.len(),
}; };
engine.world = new_runtime_world; engine.world = new_runtime_world;
@@ -1079,6 +1114,7 @@ fn load_mission_with_options_and_progress(
object_drafts, object_drafts,
script_bundles: loaded_scripts.bundles, script_bundles: loaded_scripts.bundles,
script_varset: loaded_scripts.varset, script_varset: loaded_scripts.varset,
script_init_states: loaded_scripts.init_states,
}); });
Ok((summary, trace)) Ok((summary, trace))
} }
@@ -1177,6 +1213,18 @@ pub fn loaded_mission_script_varset(engine: &Engine) -> Option<&MissionScriptVar
.and_then(|state| state.script_varset.as_ref()) .and_then(|state| state.script_varset.as_ref())
} }
/// Returns the proven script `Init` writes resolved for the loaded mission.
///
/// The list has one entry for each clan whose `Init` event contains recovered
/// `Handler(19)` instructions. It is not a general script event executor.
#[must_use]
pub fn loaded_mission_script_init_states(engine: &Engine) -> Option<&[MissionScriptInitState]> {
engine
.loaded
.as_ref()
.map(|state| state.script_init_states.as_slice())
}
/// Returns terrain runtime data for the loaded mission. /// Returns terrain runtime data for the loaded mission.
#[must_use] #[must_use]
pub fn loaded_terrain(engine: &Engine) -> Option<&TerrainWorld> { pub fn loaded_terrain(engine: &Engine) -> Option<&TerrainWorld> {
@@ -1341,7 +1389,99 @@ fn load_mission_scripts(
.as_deref() .as_deref()
.map(|base_path| load_script_varset(vfs, base_path)) .map(|base_path| load_script_varset(vfs, base_path))
.transpose()?; .transpose()?;
Ok(LoadedMissionScripts { bundles, varset }) let init_states = match &varset {
Some(varset) => resolve_handler19_init_states(
&mission
.clans
.iter()
.map(|clan| clan.anchor)
.collect::<Vec<_>>(),
&bundles,
varset,
)?,
None => Vec::new(),
};
Ok(LoadedMissionScripts {
bundles,
varset,
init_states,
})
}
fn resolve_handler19_init_states(
clan_anchors: &[[f32; 2]],
bundles: &[MissionScriptBundle],
varset: &MissionScriptVarSet,
) -> Result<Vec<MissionScriptInitState>, EngineError> {
let mut states = Vec::new();
for bundle in bundles {
let Some(anchor) = clan_anchors.get(bundle.clan_index) else {
return Err(EngineError::ScriptInit {
clan_index: bundle.clan_index,
message: "script bundle clan index is outside the decoded mission".to_string(),
});
};
let input = Handler19InitInput {
first_x87_word: handler19_x87_truncated_u32(anchor[0], bundle.clan_index, "x")?,
second_x87_word: handler19_x87_truncated_u32(anchor[1], bundle.clan_index, "y")?,
third_word: u32::try_from(bundle.clan_index).map_err(|_| EngineError::ScriptInit {
clan_index: bundle.clan_index,
message: "clan index does not fit the recovered DWORD ClanID field".to_string(),
})?,
};
for event in &bundle.package.events {
if event.name_raw.as_slice() != b"Init\0" {
continue;
}
let writes: Vec<Handler19DwordWrite> = event
.instructions
.iter()
.filter(|instruction| {
instruction.dispatch_selector() == ScriptDispatchSelector::Handler(19)
})
.map(|instruction| {
varset
.declarations
.resolve_handler19(instruction, input)
.map_err(|source| EngineError::ScriptInit {
clan_index: bundle.clan_index,
message: source.to_string(),
})
})
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.collect();
if !writes.is_empty() {
states.push(MissionScriptInitState {
clan_index: bundle.clan_index,
event_word: event.event_word,
writes,
});
}
}
}
Ok(states)
}
fn handler19_x87_truncated_u32(
value: f32,
clan_index: usize,
axis: &'static str,
) -> Result<u32, EngineError> {
if !value.is_finite() || !(0.0..=10_000.0).contains(&value) {
return Err(EngineError::ScriptInit {
clan_index,
message: format!(
"ClanBase{axis}={value:?} is outside the recovered CreateSuperAI base range"
),
});
}
// ai.dll's __ftol saves the x87 control word, sets rounding-control bits
// to truncate, performs `fistp qword`, and restores the control word.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let word = value.trunc() as u32;
Ok(word)
} }
fn load_script_varset( fn load_script_varset(
@@ -1488,6 +1628,70 @@ mod tests {
)); ));
} }
#[test]
fn handler_nineteen_init_executes_from_clan_anchor_for_each_bundle() {
let declarations = fparkan_script::parse_varset(
b"VAR( DWORD, ClanBaseX, 950)\nVAR( DWORD, ClanBaseY, 1000)\nVAR( DWORD, ClanID, 0)\n",
)
.expect("varset");
let varset = MissionScriptVarSet {
path: FALLBACK_SCRIPT_VARSET_PATH.to_string(),
used_fallback: true,
declarations,
};
let bundles = [MissionScriptBundle {
clan_index: 1,
base_path: "MISSIONS/SCRIPTS/default".to_string(),
script_path: "MISSIONS/SCRIPTS/default.scr".to_string(),
package: ScriptPackage {
opcode_handler_count: 73,
events: vec![fparkan_script::ScriptEvent {
name_len: 4,
name_raw: b"Init\0".to_vec(),
event_word: 0x1234_5678,
instructions: vec![fparkan_script::ScriptInstruction {
header_words: [19, 0, 0, 0, 0, 3, 0],
references: vec![0, 1, 2],
}],
}],
trailing_bytes: Vec::new(),
raw: Arc::from(&b""[..]),
},
}];
let states =
resolve_handler19_init_states(&[[500.0, 752.0], [728.0, 449.0]], &bundles, &varset)
.expect("exact Init execution");
assert_eq!(
states,
vec![MissionScriptInitState {
clan_index: 1,
event_word: 0x1234_5678,
writes: vec![
Handler19DwordWrite {
index: 0,
value: 728
},
Handler19DwordWrite {
index: 1,
value: 449
},
Handler19DwordWrite { index: 2, value: 1 },
],
}]
);
}
#[test]
fn handler_nineteen_init_rejects_anchor_outside_recovered_base_range() {
let error = handler19_x87_truncated_u32(-0.5, 0, "x").expect_err("negative anchor");
assert!(matches!(
error,
EngineError::ScriptInit { clan_index: 0, message }
if message.contains("outside the recovered CreateSuperAI base range")
));
}
#[test] #[test]
fn headless_scheduler_trace_skips_presentation_phases() { fn headless_scheduler_trace_skips_presentation_phases() {
let mut engine = create( let mut engine = create(
+26 -3
View File
@@ -190,6 +190,26 @@ a license to replace the first two conversions with Rust float casts: their
rounding behavior remains an x87 compatibility boundary until it has captured rounding behavior remains an x87 compatibility boundary until it has captured
test vectors. test vectors.
The missing source-field provenance is now constrained by the public creation
boundary. `CreateSuperAI` at `ai.dll` VA `0x1000f710` allocates `0x8b0` bytes
and calls constructor `0x10001000` with its first eight arguments. That
constructor calls `0x10006340(this + 0x7c, clan_id, base_x, base_y)`: these are
the fields later read by `Handler(19)`. `base_x` and `base_y` are unsigned and
the constructor rejects values greater than `10000`. The actual `__ftol` helper
at `0x1001df70` saves the x87 control word, sets its rounding-control bits to
truncate, executes `fistp qword`, then restores the control word.
Consequently the runtime resolves and retains this one proved vertical slice
during mission loading: each selected clan's TMA anchor is accepted only in the recovered
`0..=10000` base range, truncated through the recovered x87 rule, and paired
with its zero-based clan index. For every `Init` instruction whose selector is
`Handler(19)`, `VarSet::resolve_handler19` produces the three per-clan DWORD
immutable `MissionScriptInitState` writes. Other Init selectors and all other events remain decoded but
unexecuted. AutoDemo validates the path end-to-end: its non-integral first
anchor (`500.2857`) yields captured `ClanBaseX=500`, and the live GOG process
contains two initialized SuperAI entries `(500, 752, 0)` and `(728, 449, 1)`;
the Rust loader reports `script_init_states=2`.
`GetSuperAI` returns element `n` of the 64-pointer global table at preferred `GetSuperAI` returns element `n` of the 64-pointer global table at preferred
`ai.dll + 0x55398` for `n <= 63`. The read-only `ai.dll + 0x55398` for `n <= 63`. The read-only
`tools/capture-ai-init.ps1` probe observed the running GOG AutoDemo values `tools/capture-ai-init.ps1` probe observed the running GOG AutoDemo values
@@ -199,11 +219,14 @@ profile.
The Rust reader exposes `VarSet::resolve_handler19`. It accepts the already The Rust reader exposes `VarSet::resolve_handler19`. It accepts the already
converted first two words and the third raw word, produces three typed writes, converted first two words and the third raw word, produces three typed writes,
and rejects missing, out-of-range, or non-`DWORD` targets. It deliberately does and rejects missing, out-of-range, or non-`DWORD` targets. The runtime only
not guess how the mission creates the three source fields or execute a script binds it to the proven creation/anchor path above; it does not guess the
event yet. The associated Ghidra scripts are remaining script event semantics. The associated Ghidra scripts are
`ExportAiVmHandler19.java`, `ExportAiVmHandler19Setter.java`, `ExportAiVmHandler19.java`, `ExportAiVmHandler19Setter.java`,
`ExportAiVmHandler19SetterCallee.java`, and `ExportAiGetSuperAi.java`. `ExportAiVmHandler19SetterCallee.java`, and `ExportAiGetSuperAi.java`.
The creation and conversion boundaries are reproducible with
`ExportAiCreateSuperAi.java`, `ExportAiSuperAiConstructor.java`, and
`ExportAiFtol.java`.
### Handler(8): problem-record state write ### Handler(8): problem-record state write
+23
View File
@@ -0,0 +1,23 @@
// Emits the GOG CreateSuperAI export to recover the mission-to-SuperAI
// initialization boundary. Run headless; the original PE remains read only.
import ghidra.app.decompiler.DecompInterface;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
public class ExportAiCreateSuperAi extends GhidraScript {
private static final long ADDRESS = 0x1000f710L;
@Override
public void run() throws Exception {
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
.getAddress(ADDRESS);
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
println("===== AI CreateSuperAI =====");
if (function == null) { println("missing"); return; }
DecompInterface decompiler = new DecompInterface();
decompiler.openProgram(currentProgram);
println(decompiler.decompileFunction(function, 120, monitor).getDecompiledFunction().getC());
decompiler.dispose();
}
}
+23
View File
@@ -0,0 +1,23 @@
// Emits the x87-to-integer helper called by Handler(19). Run headless; the
// original PE remains read only.
import ghidra.app.decompiler.DecompInterface;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
public class ExportAiFtol extends GhidraScript {
private static final long ADDRESS = 0x1001df70L;
@Override
public void run() throws Exception {
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
.getAddress(ADDRESS);
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
println("===== AI x87 __ftol helper =====");
if (function == null) { println("missing"); return; }
DecompInterface decompiler = new DecompInterface();
decompiler.openProgram(currentProgram);
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
decompiler.dispose();
}
}
@@ -0,0 +1,23 @@
// Emits the SuperAI constructor called by CreateSuperAI. Run headless; the
// original PE remains read only.
import ghidra.app.decompiler.DecompInterface;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
public class ExportAiSuperAiConstructor extends GhidraScript {
private static final long ADDRESS = 0x10001000L;
@Override
public void run() throws Exception {
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
.getAddress(ADDRESS);
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
println("===== AI SuperAI constructor =====");
if (function == null) { println("missing"); return; }
DecompInterface decompiler = new DecompInterface();
decompiler.openProgram(currentProgram);
println(decompiler.decompileFunction(function, 120, monitor).getDecompiledFunction().getC());
decompiler.dispose();
}
}