aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/fparkan-game/src/main.rs4
-rw-r--r--crates/fparkan-runtime/src/lib.rs445
-rw-r--r--crates/fparkan-runtime/tests/handler19_anchor_native_oracle.cs158
-rw-r--r--crates/fparkan-runtime/tests/handler19_init_native_oracle.cs284
-rw-r--r--crates/fparkan-script/src/lib.rs410
-rw-r--r--docs/appendices/knowledge-boundaries.md52
-rw-r--r--docs/appendices/script-vm.md187
-rw-r--r--docs/tomes/06-behavior.md26
8 files changed, 1454 insertions, 112 deletions
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs
index 379fac9..8ad25e8 100644
--- a/apps/fparkan-game/src/main.rs
+++ b/apps/fparkan-game/src/main.rs
@@ -53,8 +53,8 @@ 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,
- MissionMode, MissionObjectDraft, MissionRequest,
+ loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets, MissionMode,
+ MissionObjectDraft, MissionRequest,
};
use fparkan_terrain::{
FullSurfaceMask, SurfaceQuery, TerrainLod, TerrainMaterialLayers, TerrainWorld,
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
index c7899c0..00e9cc5 100644
--- a/crates/fparkan-runtime/src/lib.rs
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -16,8 +16,8 @@ use fparkan_prototype::{
};
use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository};
use fparkan_script::{
- Handler19DwordWrite, Handler19InitInput, ScriptDispatchSelector, ScriptInstruction,
- ScriptPackage, VarSet, VarSetDefault, VmHostCallbackCommand,
+ Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError,
+ ScriptInstruction, ScriptPackage, VarSet, VarSetDefault, VmHostCallbackCommand,
};
use fparkan_terrain::SurfaceQuery;
use fparkan_vfs::{Vfs, VfsError};
@@ -166,6 +166,8 @@ pub struct MissionScriptBundle {
/// Losslessly decoded compiled package; only proven `Handler(19)` Init
/// writes are resolved and retained by runtime.
pub package: ScriptPackage,
+ /// Outcome of the restricted first-`Init` resolution for this bundle.
+ pub init_execution: MissionScriptInitExecution,
}
/// Numeric script defaults selected for the mission's first script bundle.
@@ -192,12 +194,36 @@ pub struct MissionScriptVarSet {
pub struct MissionScriptInitState {
/// TMA clan index that owns the `SuperAI` instance and varset.
pub clan_index: usize,
+ /// Zero-based first case-sensitive C-string `Init` event selected by native lookup.
+ pub event_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>,
}
+/// Execution status of the first event whose case-sensitive C-string name is `Init`.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum MissionScriptInitExecution {
+ /// Native name lookup found no event named `Init` and made no handler call.
+ Missing,
+ /// The selected event completed within the currently recovered action subset.
+ Executed {
+ /// Zero-based event table index selected by native lookup.
+ event_index: usize,
+ },
+ /// A preview retained the decoded package but stopped before the first
+ /// instruction outside the recovered `Handler(19)` action subset.
+ Unsupported {
+ /// Zero-based event table index selected by native lookup.
+ event_index: usize,
+ /// Zero-based instruction index where restricted resolution stopped.
+ instruction_index: usize,
+ /// Typed selector, control, or result-save shape that is not modeled.
+ error: Handler19ResolveError,
+ },
+}
+
/// Per-clan mutable-value model of one instantiated mission varset.
///
/// Declarations remain shared source metadata; these values are the runtime
@@ -418,6 +444,19 @@ pub enum EngineError {
/// Proven-contract diagnostic.
message: String,
},
+ /// A full mission load reached an unsupported instruction in its first Init event.
+ ScriptInitUnsupported {
+ /// Owning TMA clan index.
+ clan_index: usize,
+ /// Resolved `.scr` package path.
+ path: String,
+ /// Zero-based first exact Init event index.
+ event_index: usize,
+ /// Zero-based instruction index within that event.
+ instruction_index: usize,
+ /// Typed unsupported instruction shape.
+ source: Handler19ResolveError,
+ },
/// A recovered script handler could not resolve against runtime state.
ScriptRuntime {
/// Owning TMA clan index.
@@ -558,6 +597,16 @@ impl std::fmt::Display for EngineError {
clan_index,
message,
} => write!(f, "clan {clan_index}: script Init failed: {message}"),
+ Self::ScriptInitUnsupported {
+ clan_index,
+ path,
+ event_index,
+ instruction_index,
+ source,
+ } => write!(
+ f,
+ "{path}: clan {clan_index} Init event {event_index} instruction {instruction_index} is unsupported: {source}"
+ ),
Self::ScriptRuntime {
clan_index,
message,
@@ -594,6 +643,7 @@ impl std::error::Error for EngineError {
Self::Nres { source, .. } => Some(source),
Self::Mission { source, .. } => Some(source),
Self::TerrainFormat { source, .. } => Some(source),
+ Self::ScriptInitUnsupported { source, .. } => Some(source),
Self::Terrain(source) => Some(source),
Self::World(source) => Some(source),
Self::AssetPreparation { source, .. } => Some(source),
@@ -736,7 +786,7 @@ fn load_mission_with_options(
source,
}
})?;
- let loaded_scripts = load_mission_scripts(&vfs, &mission)?;
+ let loaded_scripts = load_mission_scripts(&vfs, &mission, options.asset_scope)?;
let repository = CachedResourceRepository::with_payload_cache_budget(
vfs.clone(),
MISSION_DECODED_PAYLOAD_CACHE_ENTRIES,
@@ -1195,6 +1245,7 @@ fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPa
fn load_mission_scripts(
vfs: &Arc<dyn Vfs>,
mission: &MissionDocument,
+ asset_scope: MissionAssetScope,
) -> Result<LoadedMissionScripts, EngineError> {
let script_bases = mission_script_bundle_bases(mission);
let mut bundles = Vec::with_capacity(script_bases.len());
@@ -1220,6 +1271,7 @@ fn load_mission_scripts(
base_path,
script_path: normalized.as_str().to_string(),
package,
+ init_execution: MissionScriptInitExecution::Missing,
});
}
let varset = first_base_path
@@ -1233,8 +1285,9 @@ fn load_mission_scripts(
.iter()
.map(|clan| clan.anchor)
.collect::<Vec<_>>(),
- &bundles,
+ &mut bundles,
varset,
+ asset_scope,
)?,
None => Vec::new(),
};
@@ -1292,11 +1345,21 @@ fn materialize_script_varset_states(
fn resolve_handler19_init_states(
clan_anchors: &[[f32; 2]],
- bundles: &[MissionScriptBundle],
+ bundles: &mut [MissionScriptBundle],
varset: &MissionScriptVarSet,
+ asset_scope: MissionAssetScope,
) -> Result<Vec<MissionScriptInitState>, EngineError> {
let mut states = Vec::new();
for bundle in bundles {
+ if !bundle
+ .package
+ .events
+ .iter()
+ .any(|event| event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..]))
+ {
+ bundle.init_execution = MissionScriptInitExecution::Missing;
+ continue;
+ }
let Some(anchor) = clan_anchors.get(bundle.clan_index) else {
return Err(EngineError::ScriptInit {
clan_index: bundle.clan_index,
@@ -1304,41 +1367,60 @@ fn resolve_handler19_init_states(
});
};
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")?,
+ first_x87_word: handler19_anchor_word(anchor[0], bundle.clan_index, "x")?,
+ second_x87_word: handler19_anchor_word(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;
+ match varset
+ .declarations
+ .resolve_handler19_init(&bundle.package, input)
+ .map_err(|source| EngineError::ScriptInit {
+ clan_index: bundle.clan_index,
+ message: source.to_string(),
+ })? {
+ Handler19InitResolution::Missing => {
+ bundle.init_execution = MissionScriptInitExecution::Missing;
}
- 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 {
+ Handler19InitResolution::Executed {
+ event_index,
+ event_word,
+ writes,
+ } => {
+ bundle.init_execution = MissionScriptInitExecution::Executed { event_index };
+ if !writes.is_empty() {
+ states.push(MissionScriptInitState {
+ clan_index: bundle.clan_index,
+ event_index,
+ event_word,
+ writes,
+ });
+ }
+ }
+ Handler19InitResolution::Unsupported {
+ event_index,
+ instruction_index,
+ error,
+ } if asset_scope != MissionAssetScope::Full => {
+ bundle.init_execution = MissionScriptInitExecution::Unsupported {
+ event_index,
+ instruction_index,
+ error,
+ };
+ }
+ Handler19InitResolution::Unsupported {
+ event_index,
+ instruction_index,
+ error,
+ } => {
+ return Err(EngineError::ScriptInitUnsupported {
clan_index: bundle.clan_index,
- event_word: event.event_word,
- writes,
+ path: bundle.script_path.clone(),
+ event_index,
+ instruction_index,
+ source: error,
});
}
}
@@ -1346,24 +1428,29 @@ fn resolve_handler19_init_states(
Ok(states)
}
-fn handler19_x87_truncated_u32(
+fn handler19_anchor_word(
value: f32,
clan_index: usize,
axis: &'static str,
) -> Result<u32, EngineError> {
- if !value.is_finite() || !(0.0..=10_000.0).contains(&value) {
+ const SIGNED_QWORD_LIMIT: f32 = 9_223_372_036_854_775_808.0;
+
+ let rounded = value.round_ties_even();
+ if !value.is_finite() || rounded < -SIGNED_QWORD_LIMIT || rounded >= SIGNED_QWORD_LIMIT {
return Err(EngineError::ScriptInit {
clan_index,
message: format!(
- "ClanBase{axis}={value:?} is outside the recovered CreateSuperAI base range"
+ "ClanBase{axis}={value:?} is outside the modeled x87 FISTP qword range"
),
});
}
- // ai.dll's __ftol saves the x87 control word, sets rounding-control bits
- // to truncate, performs `fistp qword`, and restores the control word.
- let word = value.trunc() as u32;
- Ok(word)
+ // Iron3D FISTP qword keeps its low DWORD; AI FUN_10005c70 then round-trips
+ // that unsigned word through f32 before Handler(19)'s truncating __ftol.
+ let creation_word = (rounded as i64) as u32;
+ let ai_field = creation_word as f32;
+ let handler_qword = ai_field.trunc() as i64;
+ Ok(handler_qword as u32)
}
fn load_script_varset(
@@ -1412,6 +1499,7 @@ fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, Engi
#[cfg(test)]
mod tests {
use super::*;
+ use fparkan_script::ScriptDispatchSelector;
use fparkan_vfs::{DirectoryVfs, MemoryVfs, VfsEntry, VfsMetadata};
use std::path::{Path, PathBuf};
@@ -1544,7 +1632,7 @@ mod tests {
used_fallback: true,
declarations,
};
- let bundles = [MissionScriptBundle {
+ let mut bundles = [MissionScriptBundle {
clan_index: 1,
base_path: "MISSIONS/SCRIPTS/default".to_string(),
script_path: "MISSIONS/SCRIPTS/default.scr".to_string(),
@@ -1555,22 +1643,28 @@ mod tests {
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],
+ header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
references: vec![0, 1, 2],
}],
}],
trailing_bytes: Vec::new(),
raw: Arc::from(&b""[..]),
},
+ init_execution: MissionScriptInitExecution::Missing,
}];
- let states =
- resolve_handler19_init_states(&[[500.0, 752.0], [728.0, 449.0]], &bundles, &varset)
- .expect("exact Init execution");
+ let states = resolve_handler19_init_states(
+ &[[500.0, 752.0], [728.0, 449.0]],
+ &mut bundles,
+ &varset,
+ MissionAssetScope::Full,
+ )
+ .expect("exact Init execution");
assert_eq!(
states,
vec![MissionScriptInitState {
clan_index: 1,
+ event_index: 0,
event_word: 0x1234_5678,
writes: vec![
Handler19DwordWrite {
@@ -1618,16 +1712,162 @@ mod tests {
}
#[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");
+ fn handler_nineteen_preview_keeps_unsupported_package_without_partial_writes() {
+ let varset = MissionScriptVarSet {
+ path: FALLBACK_SCRIPT_VARSET_PATH.to_string(),
+ used_fallback: true,
+ declarations: fparkan_script::parse_varset(
+ b"VAR( DWORD, x, 950)\nVAR( DWORD, y, 1000)\nVAR( DWORD, id, 0)\n",
+ )
+ .expect("varset"),
+ };
+ let package = ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![fparkan_script::ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0x1234,
+ instructions: vec![
+ fparkan_script::ScriptInstruction {
+ header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
+ references: vec![0, 1, 2],
+ },
+ fparkan_script::ScriptInstruction {
+ header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0],
+ references: Vec::new(),
+ },
+ ],
+ }],
+ trailing_bytes: Vec::new(),
+ raw: Arc::from(&b""[..]),
+ };
+ let bundle = || MissionScriptBundle {
+ clan_index: 0,
+ base_path: "MISSIONS/SCRIPTS/default".to_string(),
+ script_path: "MISSIONS/SCRIPTS/default.scr".to_string(),
+ package: package.clone(),
+ init_execution: MissionScriptInitExecution::Missing,
+ };
+ let anchors = [[500.0, 752.0]];
+ let mut preview_bundles = vec![bundle()];
+ let preview_states = resolve_handler19_init_states(
+ &anchors,
+ &mut preview_bundles,
+ &varset,
+ MissionAssetScope::FirstMeshPreview,
+ )
+ .expect("static preview retains unsupported script");
+ assert!(preview_states.is_empty(), "no partial writes are retained");
+ assert_eq!(
+ preview_bundles[0].init_execution,
+ MissionScriptInitExecution::Unsupported {
+ event_index: 0,
+ instruction_index: 1,
+ error: Handler19ResolveError::UnsupportedSelector {
+ found: ScriptDispatchSelector::Handler(18),
+ },
+ }
+ );
+ assert_eq!(
+ materialize_script_varset_states(&preview_bundles, &varset, &preview_states)
+ .expect("preview keeps default varset values"),
+ vec![MissionScriptVarSetState {
+ clan_index: 0,
+ values: vec![
+ VarSetDefault::Dword(950),
+ VarSetDefault::Dword(1000),
+ VarSetDefault::Dword(0),
+ ],
+ }]
+ );
+ assert_eq!(preview_bundles[0].package, package);
+
+ let mut full_bundles = vec![bundle()];
+ assert!(matches!(
+ resolve_handler19_init_states(
+ &anchors,
+ &mut full_bundles,
+ &varset,
+ MissionAssetScope::Full,
+ ),
+ Err(EngineError::ScriptInitUnsupported {
+ clan_index: 0,
+ event_index: 0,
+ instruction_index: 1,
+ source: Handler19ResolveError::UnsupportedSelector {
+ found: ScriptDispatchSelector::Handler(18),
+ },
+ ..
+ })
+ ));
+
+ let mut malformed_package = package.clone();
+ malformed_package.events[0].instructions[0]
+ .references
+ .clear();
+ let mut malformed_bundle = vec![MissionScriptBundle {
+ package: malformed_package,
+ ..bundle()
+ }];
assert!(matches!(
- error,
- EngineError::ScriptInit { clan_index: 0, message }
- if message.contains("outside the recovered CreateSuperAI base range")
+ resolve_handler19_init_states(
+ &anchors,
+ &mut malformed_bundle,
+ &varset,
+ MissionAssetScope::FirstMeshPreview,
+ ),
+ Err(EngineError::ScriptInit { message, .. })
+ if message.contains("Handler(19) is missing reference 0")
));
}
#[test]
+ fn handler_nineteen_init_matches_original_x87_rounding_and_dword_path() {
+ for (value, expected) in [
+ (500.2857, 500),
+ (751.8217, 752),
+ (500.5, 500),
+ (501.5, 502),
+ (-0.5, 0),
+ (-0.4, 0),
+ (-0.6, 0),
+ (10_000.4, 10_000),
+ (10_000.5, 10_000),
+ (10_000.6, 10_001),
+ (10_001.0, 10_001),
+ (4_294_967_296.0, 0),
+ (u32::MAX as f32, 0),
+ (4_294_967_808.0, 512),
+ ] {
+ assert_eq!(
+ handler19_anchor_word(value, 0, "x").expect("supported anchor"),
+ expected,
+ "anchor {value:?}"
+ );
+ }
+ assert_eq!(u32::MAX as f32, 4_294_967_296.0);
+ }
+
+ #[test]
+ fn handler_nineteen_init_rejects_unmodeled_x87_qword_conversions() {
+ let qword_limit = 9_223_372_036_854_775_808.0_f32;
+ assert_eq!(
+ handler19_anchor_word(-qword_limit, 0, "x").expect("valid signed qword minimum"),
+ 0,
+ "the signed qword minimum is representable before low-DWORD conversion"
+ );
+ for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, qword_limit] {
+ let error =
+ handler19_anchor_word(value, 0, "x").expect_err("unmodeled FISTP qword input");
+ assert!(matches!(
+ error,
+ EngineError::ScriptInit { clan_index: 0, message }
+ if message.contains("outside the modeled x87 FISTP qword range")
+ ));
+ }
+ }
+
+ #[test]
fn handler_thirty_host_callback_obeys_native_mode_and_status_guards() {
fn command(second: u32) -> VmHostCallbackCommand {
VmHostCallbackCommand {
@@ -1752,6 +1992,111 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
+ fn licensed_autodemo_load_resolves_native_handler19_init_for_both_clans() {
+ let root = licensed_root("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
+ let mut engine = create(EngineServices::new(vfs)).expect("engine");
+ let loaded = load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".to_string(),
+ mode: MissionMode::AutoDemo,
+ },
+ )
+ .expect("load licensed AutoDemo");
+
+ assert_eq!(
+ loaded_mission_document(&engine)
+ .expect("mission document")
+ .clans
+ .iter()
+ .map(|clan| clan.anchor)
+ .collect::<Vec<_>>(),
+ vec![[500.2857, 751.8217], [728.0, 449.0]]
+ );
+ assert_eq!(loaded.script_bundle_count, 2);
+ assert_eq!(loaded.script_init_state_count, 2);
+ assert_eq!(loaded.script_varset_state_count, 2);
+ assert!(loaded_mission_script_bundles(&engine)
+ .expect("script bundles")
+ .iter()
+ .all(|bundle| bundle.init_execution
+ == MissionScriptInitExecution::Executed { event_index: 0 }));
+
+ let varsets = loaded_mission_script_varset_states(&engine).expect("per-clan varsets");
+ let clan_zero = varsets
+ .iter()
+ .find(|state| state.clan_index == 0)
+ .expect("first clan varset");
+ let clan_one = varsets
+ .iter()
+ .find(|state| state.clan_index == 1)
+ .expect("second clan varset");
+ assert_eq!(
+ &clan_zero.values[224..227],
+ &[
+ VarSetDefault::Dword(500),
+ VarSetDefault::Dword(752),
+ VarSetDefault::Dword(0),
+ ]
+ );
+ assert_eq!(
+ &clan_one.values[224..227],
+ &[
+ VarSetDefault::Dword(728),
+ VarSetDefault::Dword(449),
+ VarSetDefault::Dword(1),
+ ]
+ );
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_campaign_preview_keeps_unsupported_init_without_partial_writes() {
+ let root = licensed_root("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
+ let mut engine = create(EngineServices::new(vfs)).expect("engine");
+ let loaded = load_mission_static_preview_roots(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ NonZeroUsize::new(1).expect("non-zero root count"),
+ )
+ .expect("load licensed campaign preview");
+
+ let bundles = loaded_mission_script_bundles(&engine).expect("script bundles");
+ let unsupported = bundles
+ .iter()
+ .find(|bundle| {
+ matches!(
+ &bundle.init_execution,
+ MissionScriptInitExecution::Unsupported { .. }
+ )
+ })
+ .expect("campaign package with an unsupported Init instruction");
+ let clan_index = unsupported.clan_index;
+ let states = loaded_mission_script_init_states(&engine).expect("Init writes");
+ assert!(states.iter().all(|state| state.clan_index != clan_index));
+ let varset = loaded_mission_script_varset_states(&engine)
+ .expect("per-clan varsets")
+ .iter()
+ .find(|state| state.clan_index == clan_index)
+ .expect("unsupported clan varset");
+ assert_eq!(
+ &varset.values[224..227],
+ &[
+ VarSetDefault::Dword(950),
+ VarSetDefault::Dword(1000),
+ VarSetDefault::Dword(0),
+ ]
+ );
+ assert!(loaded.script_bundle_count >= 1);
+ }
+
+ #[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));
diff --git a/crates/fparkan-runtime/tests/handler19_anchor_native_oracle.cs b/crates/fparkan-runtime/tests/handler19_anchor_native_oracle.cs
new file mode 100644
index 0000000..294cfc1
--- /dev/null
+++ b/crates/fparkan-runtime/tests/handler19_anchor_native_oracle.cs
@@ -0,0 +1,158 @@
+using System;
+using System.IO;
+using System.Globalization;
+using System.Runtime.InteropServices;
+using System.Security.Cryptography;
+
+internal static class Iron3dAnchorOracle {
+ private const uint LoadWithAlteredSearchPath = 0x00000008;
+ private const uint PageExecuteReadWrite = 0x40;
+ private const uint MemCommitReserve = 0x3000;
+ private const uint MemRelease = 0x8000;
+ private const int CallerRva = 0x392e0;
+ private const int GameGlobalRva = 0x10a5f8;
+ private const int CreateSuperAiIatRva = 0xe4010;
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern IntPtr LoadLibraryEx(string path, 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 protect, out uint oldProtect);
+
+ [UnmanagedFunctionPointer(CallingConvention.ThisCall)]
+ private delegate void CreateAiDelegate(IntPtr record, int clanIndex, uint networkPlayer, int networkFlag);
+ [UnmanagedFunctionPointer(CallingConvention.StdCall)]
+ private delegate IntPtr CreateSuperAiDelegate(uint p0, uint p1, uint p2, uint clanIndex, uint x, uint y, uint p6, uint p7, uint p8, uint callback);
+ [UnmanagedFunctionPointer(CallingConvention.StdCall)]
+ private delegate void FpuWordOperation(ref ushort value);
+
+ private static FpuWordOperation ReadControlWord;
+ private static uint CapturedX;
+ private static uint CapturedY;
+ private static ushort CapturedControlWord;
+ private static CreateSuperAiDelegate CaptureDelegate;
+
+ private static IntPtr CaptureCreateSuperAi(uint p0, uint p1, uint p2, uint clanIndex, uint x, uint y, uint p6, uint p7, uint p8, uint callback) {
+ CapturedX = x;
+ CapturedY = y;
+ ReadControlWord(ref CapturedControlWord);
+ return IntPtr.Zero;
+ }
+
+ private static void Require(bool value, string message) {
+ if (!value) throw new InvalidOperationException(message);
+ }
+
+ private static void Expect(CreateAiDelegate caller, IntPtr record, FpuWordOperation setControlWord, ushort mode, float x, float y, uint expectedX, uint expectedY, string label) {
+ ushort requested = mode;
+ setControlWord(ref requested);
+ Marshal.WriteInt32(record, 4, BitConverter.ToInt32(BitConverter.GetBytes(x), 0));
+ Marshal.WriteInt32(record, 8, BitConverter.ToInt32(BitConverter.GetBytes(y), 0));
+ CapturedX = 0xdeadbeef;
+ CapturedY = 0xdeadbeef;
+ CapturedControlWord = 0;
+ caller(record, 0, 0, 0);
+ string actual = String.Format(CultureInfo.InvariantCulture, "{0}: cw=0x{1:x4} rc={2}, ({3:R}, {4:R}) -> (0x{5:x8}, 0x{6:x8})", label, CapturedControlWord, (CapturedControlWord >> 10) & 3, x, y, CapturedX, CapturedY);
+ Console.WriteLine(actual);
+ Require(CapturedX == expectedX && CapturedY == expectedY, label + " expected (0x" + expectedX.ToString("x8") + ",0x" + expectedY.ToString("x8") + ")");
+ Require(((CapturedControlWord >> 10) & 3) == ((requested >> 10) & 3), label + " x87 RC changed before the native callback");
+ }
+
+ private static IntPtr BuildInstruction(byte[] bytes) {
+ IntPtr memory = VirtualAlloc(IntPtr.Zero, new UIntPtr(4096), MemCommitReserve, PageExecuteReadWrite);
+ if (memory == IntPtr.Zero) throw new InvalidOperationException("VirtualAlloc failed: " + Marshal.GetLastWin32Error());
+ Marshal.Copy(bytes, 0, memory, bytes.Length);
+ return memory;
+ }
+
+ private static void ProtectWrite(IntPtr address, int value, out uint oldProtect) {
+ if (!VirtualProtect(address, new UIntPtr(4), PageExecuteReadWrite, out oldProtect)) throw new InvalidOperationException("VirtualProtect failed: " + Marshal.GetLastWin32Error());
+ Marshal.WriteInt32(address, value);
+ }
+
+ private static void RestoreProtection(IntPtr address, uint oldProtect) {
+ uint ignored;
+ if (!VirtualProtect(address, new UIntPtr(4), oldProtect, out ignored)) throw new InvalidOperationException("VirtualProtect restore failed: " + Marshal.GetLastWin32Error());
+ }
+
+ private static void Main() {
+ Require(IntPtr.Size == 4, "This oracle must run as an x86 process");
+ string dll = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_IRON3D_DLL");
+ Require(!String.IsNullOrEmpty(dll) && File.Exists(dll), "Set FPARKAN_ORIGINAL_IRON3D_DLL to the installed iron3d.dll");
+ string hash;
+ using (SHA256 sha = SHA256.Create()) using (FileStream stream = File.OpenRead(dll)) hash = BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "").ToLowerInvariant();
+ Require(hash == "d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa", "unexpected iron3d.dll hash: " + hash);
+
+ IntPtr module = LoadLibraryEx(dll, IntPtr.Zero, LoadWithAlteredSearchPath);
+ if (module == IntPtr.Zero) throw new InvalidOperationException("LoadLibraryEx failed: " + Marshal.GetLastWin32Error());
+ IntPtr readCode = IntPtr.Zero;
+ IntPtr writeCode = IntPtr.Zero;
+ IntPtr game = Marshal.AllocHGlobal(0x40);
+ IntPtr manager = Marshal.AllocHGlobal(0x40);
+ IntPtr record = Marshal.AllocHGlobal(0x68);
+ uint oldIatProtection = 0;
+ int oldGlobal = Marshal.ReadInt32(IntPtr.Add(module, GameGlobalRva));
+ int oldIat = Marshal.ReadInt32(IntPtr.Add(module, CreateSuperAiIatRva));
+ bool iatChanged = false;
+ bool controlWordCaptured = false;
+ ushort originalControlWord = 0;
+ FpuWordOperation setControlWord = null;
+ try {
+ byte[] readBytes = { 0x8b, 0x44, 0x24, 0x04, 0xd9, 0x38, 0xc2, 0x04, 0x00 }; // mov eax,[esp+4]; fnstcw [eax]; ret 4
+ byte[] writeBytes = { 0x8b, 0x44, 0x24, 0x04, 0xd9, 0x28, 0xc2, 0x04, 0x00 }; // mov eax,[esp+4]; fldcw [eax]; ret 4
+ readCode = BuildInstruction(readBytes);
+ writeCode = BuildInstruction(writeBytes);
+ ReadControlWord = (FpuWordOperation)Marshal.GetDelegateForFunctionPointer(readCode, typeof(FpuWordOperation));
+ setControlWord = (FpuWordOperation)Marshal.GetDelegateForFunctionPointer(writeCode, typeof(FpuWordOperation));
+ ReadControlWord(ref originalControlWord);
+ controlWordCaptured = true;
+ Console.WriteLine("iron3d.dll sha256=" + hash + " loadedBase=0x" + module.ToInt32().ToString("x8") + " defaultCW=0x" + originalControlWord.ToString("x4") + " defaultRC=" + ((originalControlWord >> 10) & 3));
+
+ for (int offset = 0; offset < 0x40; offset += 4) { Marshal.WriteInt32(game, offset, 0); Marshal.WriteInt32(manager, offset, 0); }
+ for (int offset = 0; offset < 0x68; offset += 4) Marshal.WriteInt32(record, offset, 0);
+ Marshal.WriteInt32(game, 0x1c, manager.ToInt32());
+ Marshal.WriteInt32(IntPtr.Add(module, GameGlobalRva), game.ToInt32());
+
+ CaptureDelegate = CaptureCreateSuperAi;
+ IntPtr callbackPointer = Marshal.GetFunctionPointerForDelegate(CaptureDelegate);
+ IntPtr iat = IntPtr.Add(module, CreateSuperAiIatRva);
+ ProtectWrite(iat, callbackPointer.ToInt32(), out oldIatProtection);
+ iatChanged = true;
+
+ CreateAiDelegate caller = (CreateAiDelegate)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, CallerRva), typeof(CreateAiDelegate));
+ ushort nearest = (ushort)(originalControlWord & ~0x0c00);
+ ushort down = (ushort)((originalControlWord & ~0x0c00) | 0x0400);
+ ushort up = (ushort)((originalControlWord & ~0x0c00) | 0x0800);
+ ushort truncate = (ushort)((originalControlWord & ~0x0c00) | 0x0c00);
+
+ Expect(caller, record, setControlWord, nearest, 500.2857f, 751.8217f, 500, 752, "Autodemo anchors / nearest-even");
+ Expect(caller, record, setControlWord, nearest, 500.5f, 501.5f, 500, 502, "nearest tie pair");
+ Expect(caller, record, setControlWord, down, 500.5f, 501.5f, 500, 501, "down tie pair");
+ Expect(caller, record, setControlWord, up, 500.5f, 501.5f, 501, 502, "up tie pair");
+ Expect(caller, record, setControlWord, truncate, 500.5f, 501.5f, 500, 501, "truncate tie pair");
+ Expect(caller, record, setControlWord, nearest, -0.4f, 10000.4f, 0, 10000, "round-in lower/upper boundary");
+ Expect(caller, record, setControlWord, nearest, -0.6f, 10000.6f, 0xffffffff, 10001, "round-out lower/upper boundary");
+ Expect(caller, record, setControlWord, nearest, 4294967296.0f, 4294967808.0f, 0, 512, "low-DWORD wrap boundary");
+ }
+ finally {
+ if (iatChanged) {
+ IntPtr iat = IntPtr.Add(module, CreateSuperAiIatRva);
+ Marshal.WriteInt32(iat, oldIat);
+ RestoreProtection(iat, oldIatProtection);
+ }
+ Marshal.WriteInt32(IntPtr.Add(module, GameGlobalRva), oldGlobal);
+ if (controlWordCaptured) setControlWord(ref originalControlWord);
+ Marshal.FreeHGlobal(record);
+ Marshal.FreeHGlobal(manager);
+ Marshal.FreeHGlobal(game);
+ if (readCode != IntPtr.Zero) VirtualFree(readCode, UIntPtr.Zero, MemRelease);
+ if (writeCode != IntPtr.Zero) VirtualFree(writeCode, UIntPtr.Zero, MemRelease);
+ FreeLibrary(module);
+ }
+ }
+}
diff --git a/crates/fparkan-runtime/tests/handler19_init_native_oracle.cs b/crates/fparkan-runtime/tests/handler19_init_native_oracle.cs
new file mode 100644
index 0000000..5dc319a
--- /dev/null
+++ b/crates/fparkan-runtime/tests/handler19_init_native_oracle.cs
@@ -0,0 +1,284 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Security.Cryptography;
+using System.Text;
+
+internal static class Handler19DispatchOracle {
+ private const uint LoadWithAlteredSearchPath = 0x00000008;
+ private const string ExpectedAiSha256 = "c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc";
+ private const string ExpectedScriptSha256 = "db1c6fde57aff43181619f19e19e1cacf7301ebb859e8cb883db6b4b7181eced";
+ private const int EventLookupRva = 0x11f40;
+ private const int EventDispatchRva = 0x11f10;
+ private const int Handler19Rva = 0x0aa38;
+ private const int EventStride = 0x14;
+ private const int InstructionStride = 0x20;
+ private const int VarSetStride = 0x30;
+
+ [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);
+
+ [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate int FindEvent(IntPtr vm, IntPtr name);
+ [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate uint DispatchEvent(IntPtr vm, int eventIndex);
+ [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate uint ReadVariable(IntPtr record);
+ [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void SetDwordPosition(IntPtr record, uint x, uint y);
+
+ private sealed class Instruction {
+ public readonly uint[] Words;
+ public readonly uint[] References;
+ public Instruction(uint[] words, uint[] references) { Words = words; References = references; }
+ }
+ private sealed class Event {
+ public readonly byte[] Name;
+ public readonly uint EventWord;
+ public readonly Instruction[] Instructions;
+ public Event(byte[] name, uint eventWord, Instruction[] instructions) { Name = name; EventWord = eventWord; Instructions = instructions; }
+ }
+ private sealed class Package {
+ public readonly uint HandlerCount;
+ public readonly Event[] Events;
+ public Package(uint handlerCount, Event[] events) { HandlerCount = handlerCount; Events = events; }
+ }
+ private sealed class Instance : IDisposable {
+ private readonly List<IntPtr> allocations = new List<IntPtr>();
+ public IntPtr Ai;
+ public IntPtr EventTable;
+ public IntPtr HandlerTable;
+ public IntPtr VarRecords;
+ public IntPtr Cells;
+ public void Dispose() { for (int i = allocations.Count - 1; i >= 0; --i) Marshal.FreeHGlobal(allocations[i]); }
+ public IntPtr Allocate(int length) {
+ IntPtr p = Marshal.AllocHGlobal(length);
+ if (p == IntPtr.Zero) throw new OutOfMemoryException();
+ Zero(p, length); allocations.Add(p); return p;
+ }
+ }
+
+ private static int Main() {
+ if (IntPtr.Size != 4) { Console.Error.WriteLine("Build with /platform:x86."); return 2; }
+ string aiPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_AI_DLL");
+ string scriptPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_DEFAULT_SCR");
+ if (String.IsNullOrEmpty(aiPath) || !File.Exists(aiPath) || String.IsNullOrEmpty(scriptPath) || !File.Exists(scriptPath)) {
+ Console.Error.WriteLine("Set FPARKAN_ORIGINAL_AI_DLL and FPARKAN_ORIGINAL_DEFAULT_SCR to the installed GOG files."); return 3;
+ }
+ if (Hash(aiPath) != ExpectedAiSha256) { Console.Error.WriteLine("Unexpected ai.dll SHA-256: " + Hash(aiPath)); return 4; }
+ if (Hash(scriptPath) != ExpectedScriptSha256) { Console.Error.WriteLine("Unexpected default.scr SHA-256: " + Hash(scriptPath)); return 5; }
+ Package package = ReadPackage(scriptPath);
+ if (package.HandlerCount != 73 || package.Events.Length != 9) throw new InvalidDataException("Unexpected default.scr header.");
+ if (package.Events[0].Name.Length != 5 || Encoding.ASCII.GetString(package.Events[0].Name) != "Init\0" || package.Events[0].Instructions.Length != 1)
+ throw new InvalidDataException("Expected a single-instruction Init event at index 0.");
+ Instruction init = package.Events[0].Instructions[0];
+ if (init.Words[0] != 19 || init.Words[1] != 0xffffffff || init.Words[2] != 0xffffffff || init.References.Length != 3 || init.References[0] != 224 || init.References[1] != 225 || init.References[2] != 226)
+ throw new InvalidDataException("Init instruction no longer matches the recovered Handler(19) shape.");
+
+ IntPtr module = LoadLibraryEx(aiPath, IntPtr.Zero, LoadWithAlteredSearchPath);
+ if (module == IntPtr.Zero) { Console.Error.WriteLine("LoadLibraryEx(ai.dll) failed: " + Marshal.GetLastWin32Error()); return 6; }
+ try {
+ FindEvent find = (FindEvent)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, EventLookupRva), typeof(FindEvent));
+ DispatchEvent dispatch = (DispatchEvent)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, EventDispatchRva), typeof(DispatchEvent));
+ ReadVariable readVariable = (ReadVariable)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, 0x13570), typeof(ReadVariable));
+ SetDwordPosition setDwordPosition = (SetDwordPosition)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, 0x5c70), typeof(SetDwordPosition));
+ VerifyEventLookupVectors(package, find, dispatch, module);
+ RunInstance(package, find, dispatch, readVariable, module, 500.2857f, 752.0f, 0, 500, 752);
+ RunInstance(package, find, dispatch, readVariable, module, 728.0f, 449.0f, 1, 728, 449);
+ RunDwordToHandlerRoundTrip(package, find, dispatch, readVariable, setDwordPosition, module);
+ Console.WriteLine("original ai.dll dispatcher passed for default.scr Init instances and DWORD-to-handler round-trip; code bytes were not modified");
+ return 0;
+ }
+ finally { FreeLibrary(module); }
+ }
+
+ private static void VerifyEventLookupVectors(Package package, FindEvent find, DispatchEvent dispatch, IntPtr module) {
+ var duplicateEvents = new Event[package.Events.Length + 1];
+ Array.Copy(package.Events, duplicateEvents, package.Events.Length);
+ duplicateEvents[duplicateEvents.Length - 1] = new Event(
+ Encoding.ASCII.GetBytes("Init\0"), 0xabcdef01,
+ new Instruction[] { new Instruction(new uint[] { 19, 0xffffffff, 0xffffffff, 0, 0, 3, 0 }, new uint[] { 224, 225, 226 }) });
+ var duplicatePackage = new Package(package.HandlerCount, duplicateEvents);
+ using (Instance instance = BuildInstance(duplicatePackage, 500.2857f, 752.0f, 0, IntPtr.Add(module, Handler19Rva))) {
+ Expect(LookupEvent(find, IntPtr.Add(instance.Ai, 8), "Init") == 0, "native lookup chooses first exact duplicate event");
+ }
+
+ var embeddedNulEvents = (Event[])package.Events.Clone();
+ for (int i = 0; i < embeddedNulEvents.Length; i++) {
+ if (Encoding.ASCII.GetString(embeddedNulEvents[i].Name) == "Init\0") {
+ embeddedNulEvents[i] = new Event(Encoding.ASCII.GetBytes("Init\0suffix\0"), embeddedNulEvents[i].EventWord, embeddedNulEvents[i].Instructions);
+ break;
+ }
+ }
+ var embeddedNulPackage = new Package(package.HandlerCount, embeddedNulEvents);
+ using (Instance instance = BuildInstance(embeddedNulPackage, 500.2857f, 752.0f, 0, IntPtr.Add(module, Handler19Rva))) {
+ Expect(LookupEvent(find, IntPtr.Add(instance.Ai, 8), "Init") == 0, "native lookup stops at an embedded NUL and ignores trailing name bytes");
+ }
+
+ var missingEvents = (Event[])package.Events.Clone();
+ for (int i = 0; i < missingEvents.Length; i++) {
+ if (Encoding.ASCII.GetString(missingEvents[i].Name) == "Init\0") {
+ missingEvents[i] = new Event(Encoding.ASCII.GetBytes("NoInit\0"), missingEvents[i].EventWord, missingEvents[i].Instructions);
+ }
+ }
+ var missingPackage = new Package(package.HandlerCount, missingEvents);
+ using (Instance instance = BuildInstance(missingPackage, 500.2857f, 752.0f, 0, IntPtr.Add(module, Handler19Rva))) {
+ IntPtr vm = IntPtr.Add(instance.Ai, 8);
+ int eventIndex = LookupEvent(find, vm, "Init");
+ Expect(eventIndex == -1, "native lookup reports missing exact event");
+ Expect((dispatch(vm, eventIndex) & 0xff) == 0, "dispatching missing event performs no callback");
+ Expect(Marshal.ReadInt32(instance.Cells, 0) == 0 && Marshal.ReadInt32(instance.Cells, 4) == 0 && Marshal.ReadInt32(instance.Cells, 8) == 0,
+ "missing event makes no varset writes");
+ }
+ Console.WriteLine("original event lookup: first duplicate wins; Init\\0suffix also matches; missing Init returns -1 without writes");
+ }
+
+ private static int LookupEvent(FindEvent find, IntPtr vm, string nameText) {
+ byte[] nameBytes = Encoding.ASCII.GetBytes(nameText + "\0");
+ IntPtr name = Marshal.AllocHGlobal(nameBytes.Length);
+ try {
+ Marshal.Copy(nameBytes, 0, name, nameBytes.Length);
+ return find(vm, name);
+ }
+ finally { Marshal.FreeHGlobal(name); }
+ }
+
+ private static void RunDwordToHandlerRoundTrip(Package package, FindEvent find, DispatchEvent dispatch, ReadVariable readVariable, SetDwordPosition setDwordPosition, IntPtr module) {
+ IntPtr position = Marshal.AllocHGlobal(12);
+ try {
+ Zero(position, 12);
+ setDwordPosition(position, UInt32.MaxValue, 10001);
+ float x = BitConverter.ToSingle(BitConverter.GetBytes(ReadWord(position, 1)), 0);
+ float y = BitConverter.ToSingle(BitConverter.GetBytes(ReadWord(position, 2)), 0);
+ Expect(x == 4294967296.0f && y == 10001.0f, "FUN_10005c70 converts DWORD coordinates to the expected single-precision fields");
+ Console.WriteLine("FUN_10005c70 input=({0:X8}, {1}) fields=({2:R}, {3:R})", UInt32.MaxValue, 10001, x, y);
+ RunInstance(package, find, dispatch, readVariable, module, x, y, 2, 0, 10001);
+ }
+ finally { Marshal.FreeHGlobal(position); }
+ }
+
+ private static void RunInstance(Package package, FindEvent find, DispatchEvent dispatch, ReadVariable readVariable, IntPtr module, float x, float y, uint clanId, uint expectedX, uint expectedY) {
+ using (Instance instance = BuildInstance(package, x, y, clanId, IntPtr.Add(module, Handler19Rva))) {
+ IntPtr vm = IntPtr.Add(instance.Ai, 8);
+ IntPtr name = Marshal.AllocHGlobal(5);
+ try {
+ Marshal.Copy(Encoding.ASCII.GetBytes("Init\0"), 0, name, 5);
+ int eventIndex = find(vm, name);
+ Expect(eventIndex == 0, "original name lookup returns Init index 0");
+ uint result = dispatch(vm, eventIndex);
+ uint first = ReadWord(instance.Cells, 0);
+ uint second = ReadWord(instance.Cells, 1);
+ uint third = ReadWord(instance.Cells, 2);
+ uint getterX = readVariable(IntPtr.Add(instance.VarRecords, 224 * VarSetStride));
+ uint getterY = readVariable(IntPtr.Add(instance.VarRecords, 225 * VarSetStride));
+ uint getterClan = readVariable(IntPtr.Add(instance.VarRecords, 226 * VarSetStride));
+ Console.WriteLine("probe clan={0}: cell_words=({1:X8}, {2:X8}, {3:X8}), native_getter=({4:X8}, {5:X8}, {6:X8})", clanId, first, second, third, getterX, getterY, getterClan);
+ Expect((result & 0xff) == 1, "dispatcher reports completion");
+ Expect(Marshal.ReadInt32(vm, 0x40) == 0, "current event field +0x48 is Init index");
+ Expect(Marshal.ReadInt32(vm, 0x44) == 1, "current instruction field +0x4c advanced once");
+ Expect(Marshal.ReadInt32(vm, 0x6c) == 0, "condition stack depth reset to zero");
+ Expect(Marshal.ReadInt32(instance.Ai, 0x50) == 0, "handler result slot +0x50 cleared");
+ Expect(first == expectedX && second == expectedY && third == clanId, "Handler(19) writes the three referenced DWORD cells in order");
+ Expect(getterX == first && getterY == second && getterClan == third, "native DWORD getter reads the values written by Handler(19)");
+ Console.WriteLine("clan={0}: Init={1}, instruction={2}, result={3}, stack={4}, result_slot={5}, cell_words=({6:X8}, {7:X8}, {8:X8}), typed_getters=({9:X8}, {10:X8}, {11:X8})",
+ clanId, eventIndex, Marshal.ReadInt32(vm, 0x44), result, Marshal.ReadInt32(vm, 0x6c), Marshal.ReadInt32(instance.Ai, 0x50), first, second, third, getterX, getterY, getterClan);
+ }
+ finally { Marshal.FreeHGlobal(name); }
+ }
+ }
+
+ private static Instance BuildInstance(Package package, float x, float y, uint clanId, IntPtr handler19) {
+ var instance = new Instance();
+ try {
+ instance.Ai = instance.Allocate(0x8b0);
+ instance.EventTable = instance.Allocate(package.Events.Length * EventStride);
+ instance.HandlerTable = instance.Allocate((int)package.HandlerCount * 4);
+ instance.VarRecords = instance.Allocate(227 * VarSetStride);
+ instance.Cells = instance.Allocate(3 * 4);
+ IntPtr vm = IntPtr.Add(instance.Ai, 8);
+ Marshal.WriteInt32(vm, 0, package.Events.Length);
+ Marshal.WriteInt32(vm, 4, instance.EventTable.ToInt32());
+ Marshal.WriteInt32(vm, 8, (int)package.HandlerCount);
+ Marshal.WriteInt32(vm, 0x0c, instance.HandlerTable.ToInt32());
+ Marshal.WriteInt32(vm, 0x3c, instance.Ai.ToInt32());
+ Marshal.WriteInt32(instance.HandlerTable, 19 * 4, handler19.ToInt32());
+ for (int eventIndex = 0; eventIndex < package.Events.Length; eventIndex++) {
+ Event sourceEvent = package.Events[eventIndex];
+ IntPtr name = instance.Allocate(sourceEvent.Name.Length);
+ Marshal.Copy(sourceEvent.Name, 0, name, sourceEvent.Name.Length);
+ IntPtr records = sourceEvent.Instructions.Length == 0 ? IntPtr.Zero : instance.Allocate(sourceEvent.Instructions.Length * InstructionStride);
+ int eventOffset = eventIndex * EventStride;
+ Marshal.WriteInt32(instance.EventTable, eventOffset, name.ToInt32());
+ Marshal.WriteInt32(instance.EventTable, eventOffset + 4, sourceEvent.Name.Length - 1);
+ Marshal.WriteInt32(instance.EventTable, eventOffset + 8, unchecked((int)sourceEvent.EventWord));
+ Marshal.WriteInt32(instance.EventTable, eventOffset + 0x0c, sourceEvent.Instructions.Length);
+ Marshal.WriteInt32(instance.EventTable, eventOffset + 0x10, records.ToInt32());
+ for (int instructionIndex = 0; instructionIndex < sourceEvent.Instructions.Length; instructionIndex++) {
+ Instruction sourceInstruction = sourceEvent.Instructions[instructionIndex];
+ IntPtr refs = sourceInstruction.References.Length == 0 ? IntPtr.Zero : instance.Allocate(sourceInstruction.References.Length * 4);
+ for (int referenceIndex = 0; referenceIndex < sourceInstruction.References.Length; referenceIndex++)
+ Marshal.WriteInt32(refs, referenceIndex * 4, unchecked((int)sourceInstruction.References[referenceIndex]));
+ IntPtr instruction = IntPtr.Add(records, instructionIndex * InstructionStride);
+ for (int word = 0; word < 5; word++) Marshal.WriteInt32(instruction, word * 4, unchecked((int)sourceInstruction.Words[word]));
+ Marshal.WriteInt32(instruction, 0x14, refs.ToInt32());
+ Marshal.WriteInt32(instruction, 0x18, sourceInstruction.References.Length);
+ Marshal.WriteInt32(instruction, 0x1c, unchecked((int)sourceInstruction.Words[6]));
+ }
+ }
+ Marshal.WriteInt32(instance.Ai, 0x7c, unchecked((int)clanId));
+ Marshal.WriteInt32(instance.Ai, 0x80, BitConverter.ToInt32(BitConverter.GetBytes(x), 0));
+ Marshal.WriteInt32(instance.Ai, 0x84, BitConverter.ToInt32(BitConverter.GetBytes(y), 0));
+ // AI+0x18 is the original varset vector object: +4=count and +0xc=record array.
+ Marshal.WriteInt32(instance.Ai, 0x1c, 227);
+ Marshal.WriteInt32(instance.Ai, 0x24, instance.VarRecords.ToInt32());
+ uint[] targets = { 224, 225, 226 };
+ for (int i = 0; i < targets.Length; i++) {
+ IntPtr record = IntPtr.Add(instance.VarRecords, checked((int)targets[i] * VarSetStride));
+ IntPtr cell = IntPtr.Add(instance.Cells, i * 4);
+ Marshal.WriteInt32(record, 8, 5); // original parser's DWORD type tag
+ Marshal.WriteInt32(record, 0x20, cell.ToInt32());
+ }
+ return instance;
+ }
+ catch { instance.Dispose(); throw; }
+ }
+
+ private static Package ReadPackage(string path) {
+ using (BinaryReader reader = new BinaryReader(File.OpenRead(path), Encoding.ASCII)) {
+ uint handlerCount = reader.ReadUInt32();
+ uint eventCount = reader.ReadUInt32();
+ if (eventCount > 4096 || handlerCount > 4096) throw new InvalidDataException("Unreasonable package header.");
+ var events = new Event[eventCount];
+ for (int e = 0; e < events.Length; e++) {
+ uint nameLength = reader.ReadUInt32();
+ if (nameLength > 1024) throw new InvalidDataException("Unreasonable event name length.");
+ byte[] name = reader.ReadBytes(checked((int)nameLength + 1));
+ if (name.Length != nameLength + 1 || name[name.Length - 1] != 0) throw new InvalidDataException("Bad event name.");
+ uint eventWord = reader.ReadUInt32();
+ uint instructionCount = reader.ReadUInt32();
+ if (instructionCount > 65536) throw new InvalidDataException("Unreasonable event instruction count.");
+ var instructions = new Instruction[instructionCount];
+ for (int i = 0; i < instructions.Length; i++) {
+ uint[] words = new uint[6];
+ for (int w = 0; w < words.Length; w++) words[w] = reader.ReadUInt32();
+ if (words[5] > 4096) throw new InvalidDataException("Unreasonable reference count.");
+ uint[] refs = new uint[words[5]];
+ for (int r = 0; r < refs.Length; r++) refs[r] = reader.ReadUInt32();
+ uint trailing = reader.ReadUInt32();
+ instructions[i] = new Instruction(new uint[] { words[0], words[1], words[2], words[3], words[4], words[5], trailing }, refs);
+ }
+ events[e] = new Event(name, eventWord, instructions);
+ }
+ if (reader.BaseStream.Position != reader.BaseStream.Length) throw new InvalidDataException("Trailing bytes in default.scr.");
+ return new Package(handlerCount, events);
+ }
+ }
+
+ private static uint ReadWord(IntPtr cells, int index) { return unchecked((uint)Marshal.ReadInt32(cells, index * 4)); }
+ private static string Hash(string path) {
+ using (SHA256 sha = SHA256.Create()) using (FileStream input = File.OpenRead(path)) {
+ byte[] digest = sha.ComputeHash(input); var hex = new StringBuilder();
+ foreach (byte value in digest) hex.Append(value.ToString("x2")); return hex.ToString();
+ }
+ }
+ private static void Expect(bool value, string message) { if (!value) throw new InvalidOperationException("original dispatcher oracle failed: " + message); }
+ private static void Zero(IntPtr buffer, int length) { for (int offset = 0; offset < length; offset++) Marshal.WriteByte(buffer, offset, 0); }
+}
diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs
index 010c62a..088b51f 100644
--- a/crates/fparkan-script/src/lib.rs
+++ b/crates/fparkan-script/src/lib.rs
@@ -2,8 +2,8 @@
//! Lossless, bounded reader for compiled AI `.scr` packages.
//!
//! This module preserves the layout proven by the GOG `ai.dll` reader. It
-//! deliberately does not assign semantics to instruction words or execute
-//! bytecode: that requires handler-specific evidence.
+//! deliberately does not implement a general bytecode VM. It contains a
+//! narrow, handler-specific resolver for the confirmed `Init` path.
use fparkan_binary::{checked_allocation_len, Cursor, DecodeError, Limits};
use std::sync::Arc;
@@ -151,6 +151,35 @@ pub struct Handler19DwordWrite {
pub value: u32,
}
+/// Result of resolving the first event whose case-sensitive C-string name is
+/// `Init` (bytes through the first NUL) with the supported
+/// `Handler(19)` action subset.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum Handler19InitResolution {
+ /// No event has the case-sensitive C-string name `Init`; the native lookup
+ /// makes no call.
+ Missing,
+ /// The first `Init\0` event was walked in source order.
+ Executed {
+ /// Zero-based event table index selected by the native name lookup.
+ event_index: usize,
+ /// Opaque event word retained from the selected record.
+ event_word: u32,
+ /// Ordered writes emitted by every supported instruction in the event.
+ writes: Vec<Handler19DwordWrite>,
+ },
+ /// The first unsupported instruction stopped this restricted resolver.
+ /// Any earlier local writes are discarded because the event did not finish.
+ Unsupported {
+ /// Zero-based event table index selected by the native name lookup.
+ event_index: usize,
+ /// Zero-based instruction index within that event.
+ instruction_index: usize,
+ /// Unsupported action shape.
+ error: Handler19ResolveError,
+ },
+}
+
/// Inputs already expressed at the original `Handler(19)` setter ABI boundary.
///
/// The first two words are results of original x87 `__ftol` calls. Keeping
@@ -169,6 +198,21 @@ pub struct Handler19InitInput {
/// Error resolving the three-target `Handler(19)` Init contract.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Handler19ResolveError {
+ /// The selected action is not the recovered `Handler(19)` slot.
+ UnsupportedSelector {
+ /// Recovered selector shape.
+ found: ScriptDispatchSelector,
+ },
+ /// Only action instructions with control word `0xffff_ffff` are supported.
+ UnsupportedControlWord {
+ /// Opaque control word from instruction header word one.
+ found: u32,
+ },
+ /// Only instructions with no result-save reference are supported.
+ UnsupportedResultSave {
+ /// Opaque variable reference from instruction header word two.
+ found: u32,
+ },
/// One of the three required references was absent.
MissingReference {
/// Zero-based required target position.
@@ -193,6 +237,17 @@ pub enum Handler19ResolveError {
impl std::fmt::Display for Handler19ResolveError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
+ Self::UnsupportedSelector { found } => {
+ write!(formatter, "unsupported script selector {found:?} in Init")
+ }
+ Self::UnsupportedControlWord { found } => write!(
+ formatter,
+ "unsupported Init control word 0x{found:08x}; expected 0xffffffff"
+ ),
+ Self::UnsupportedResultSave { found } => write!(
+ formatter,
+ "unsupported Init result-save reference 0x{found:08x}; expected 0xffffffff"
+ ),
Self::MissingReference { position } => {
write!(formatter, "Handler(19) is missing reference {position}")
}
@@ -528,6 +583,73 @@ impl VarSet {
])
}
+ /// Resolves only the first event whose case-sensitive C-string name is
+ /// `Init` (bytes through the first NUL), and its supported
+ /// `Handler(19)` action instructions in source order.
+ ///
+ /// A missing event is a normal no-write result, matching the native event
+ /// lookup. Unsupported selector/control/result-save forms stop at their
+ /// first instruction and return no partial writes. Malformed handler
+ /// references and declaration types remain errors.
+ pub fn resolve_handler19_init(
+ &self,
+ package: &ScriptPackage,
+ input: Handler19InitInput,
+ ) -> Result<Handler19InitResolution, Handler19ResolveError> {
+ let Some((event_index, event)) = package.events.iter().enumerate().find(|(_, event)| {
+ event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])
+ }) else {
+ return Ok(Handler19InitResolution::Missing);
+ };
+
+ let mut writes = Vec::new();
+ for (instruction_index, instruction) in event.instructions.iter().enumerate() {
+ let instruction_writes =
+ match self.resolve_handler19_init_instruction(instruction, input) {
+ Ok(writes) => writes,
+ Err(error @ Handler19ResolveError::UnsupportedSelector { .. })
+ | Err(error @ Handler19ResolveError::UnsupportedControlWord { .. })
+ | Err(error @ Handler19ResolveError::UnsupportedResultSave { .. }) => {
+ return Ok(Handler19InitResolution::Unsupported {
+ event_index,
+ instruction_index,
+ error,
+ });
+ }
+ Err(error) => return Err(error),
+ };
+ writes.extend(instruction_writes);
+ }
+
+ Ok(Handler19InitResolution::Executed {
+ event_index,
+ event_word: event.event_word,
+ writes,
+ })
+ }
+
+ fn resolve_handler19_init_instruction(
+ &self,
+ instruction: &ScriptInstruction,
+ input: Handler19InitInput,
+ ) -> Result<[Handler19DwordWrite; 3], Handler19ResolveError> {
+ let selector = instruction.dispatch_selector();
+ if selector != ScriptDispatchSelector::Handler(19) {
+ return Err(Handler19ResolveError::UnsupportedSelector { found: selector });
+ }
+ if instruction.header_words[1] != u32::MAX {
+ return Err(Handler19ResolveError::UnsupportedControlWord {
+ found: instruction.header_words[1],
+ });
+ }
+ if instruction.header_words[2] != u32::MAX {
+ return Err(Handler19ResolveError::UnsupportedResultSave {
+ found: instruction.header_words[2],
+ });
+ }
+ self.resolve_handler19(instruction, input)
+ }
+
fn resolve_handler19_target(
&self,
instruction: &ScriptInstruction,
@@ -1196,10 +1318,11 @@ fn read_instruction(
mod tests {
use super::{
decode, decode_with_limits, parse_varset, Handler15ResolveError, Handler15TargetPayload,
- Handler19DwordWrite, Handler19InitInput, Handler19ResolveError, Handler2RecordInput,
- Handler2RecordScheduler, Handler30ResolveError, Handler8Reset, Handler8ResolveError,
- ScriptDispatchSelector, ScriptInstruction, VarSetDefault, VarSetError, VarSetType,
- VmHostCallbackCommand, GOG_HANDLER_COUNT, INSTRUCTION_WORDS,
+ Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError,
+ Handler2RecordInput, Handler2RecordScheduler, Handler30ResolveError, Handler8Reset,
+ Handler8ResolveError, ScriptDispatchSelector, ScriptEvent, ScriptInstruction,
+ ScriptPackage, VarSetDefault, VarSetError, VarSetType, VmHostCallbackCommand,
+ GOG_HANDLER_COUNT, INSTRUCTION_WORDS,
};
use fparkan_binary::{DecodeError, Limits};
@@ -1561,6 +1684,281 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
+ fn handler_nineteen_init_uses_first_exact_event_and_keeps_instruction_order() {
+ let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
+ .expect("DWORD targets");
+ let action = |references: Vec<u32>| ScriptInstruction {
+ header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
+ references,
+ };
+ let package = ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![
+ ScriptEvent {
+ name_len: 11,
+ name_raw: b"Init\0suffix\0".to_vec(),
+ event_word: 0x1234,
+ instructions: vec![action(vec![0, 1, 2]), action(vec![1, 0, 2])],
+ },
+ ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0x5678,
+ instructions: vec![ScriptInstruction {
+ header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0],
+ references: Vec::new(),
+ }],
+ },
+ ],
+ trailing_bytes: Vec::new(),
+ raw: std::sync::Arc::from(&b""[..]),
+ };
+
+ let result = varset
+ .resolve_handler19_init(
+ &package,
+ Handler19InitInput {
+ first_x87_word: 7,
+ second_x87_word: 8,
+ third_word: 9,
+ },
+ )
+ .expect("valid first Init event");
+ assert_eq!(
+ result,
+ Handler19InitResolution::Executed {
+ event_index: 0,
+ event_word: 0x1234,
+ writes: vec![
+ Handler19DwordWrite { index: 0, value: 7 },
+ Handler19DwordWrite { index: 1, value: 8 },
+ Handler19DwordWrite { index: 2, value: 9 },
+ Handler19DwordWrite { index: 1, value: 7 },
+ Handler19DwordWrite { index: 0, value: 8 },
+ Handler19DwordWrite { index: 2, value: 9 },
+ ],
+ }
+ );
+ }
+
+ #[test]
+ fn handler_nineteen_init_missing_exact_name_has_no_writes() {
+ let varset = parse_varset(b"").expect("empty varset");
+ let package = ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![
+ ScriptEvent {
+ name_len: 4,
+ name_raw: b"init\0".to_vec(),
+ event_word: 1,
+ instructions: Vec::new(),
+ },
+ ScriptEvent {
+ name_len: 4,
+ name_raw: b"INIT\0".to_vec(),
+ event_word: 2,
+ instructions: Vec::new(),
+ },
+ ],
+ trailing_bytes: Vec::new(),
+ raw: std::sync::Arc::from(&b""[..]),
+ };
+
+ assert_eq!(
+ varset.resolve_handler19_init(
+ &package,
+ Handler19InitInput {
+ first_x87_word: 7,
+ second_x87_word: 8,
+ third_word: 9,
+ },
+ ),
+ Ok(Handler19InitResolution::Missing)
+ );
+ }
+
+ #[test]
+ fn handler_nineteen_init_accepts_empty_first_event_as_executed_with_no_writes() {
+ let varset = parse_varset(b"").expect("empty varset");
+ let package = ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![
+ ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0x1234,
+ instructions: Vec::new(),
+ },
+ ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0x5678,
+ instructions: vec![ScriptInstruction {
+ header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0],
+ references: Vec::new(),
+ }],
+ },
+ ],
+ trailing_bytes: Vec::new(),
+ raw: std::sync::Arc::from(&b""[..]),
+ };
+
+ assert_eq!(
+ varset.resolve_handler19_init(
+ &package,
+ Handler19InitInput {
+ first_x87_word: 7,
+ second_x87_word: 8,
+ third_word: 9,
+ },
+ ),
+ Ok(Handler19InitResolution::Executed {
+ event_index: 0,
+ event_word: 0x1234,
+ writes: Vec::new(),
+ })
+ );
+ }
+
+ #[test]
+ fn handler_nineteen_init_reports_unsupported_selector_and_discards_prior_writes() {
+ let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
+ .expect("DWORD targets");
+ let package = ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0,
+ instructions: vec![
+ ScriptInstruction {
+ header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
+ references: vec![0, 1, 2],
+ },
+ ScriptInstruction {
+ header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0],
+ references: Vec::new(),
+ },
+ ScriptInstruction {
+ header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
+ references: vec![0, 1, 2],
+ },
+ ],
+ }],
+ trailing_bytes: Vec::new(),
+ raw: std::sync::Arc::from(&b""[..]),
+ };
+
+ assert_eq!(
+ varset.resolve_handler19_init(
+ &package,
+ Handler19InitInput {
+ first_x87_word: 7,
+ second_x87_word: 8,
+ third_word: 9,
+ },
+ ),
+ Ok(Handler19InitResolution::Unsupported {
+ event_index: 0,
+ instruction_index: 1,
+ error: Handler19ResolveError::UnsupportedSelector {
+ found: super::ScriptDispatchSelector::Handler(18),
+ },
+ })
+ );
+ }
+
+ #[test]
+ fn handler_nineteen_init_stops_at_unsupported_selector_before_any_write() {
+ let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
+ .expect("DWORD targets");
+ let package = ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0,
+ instructions: vec![
+ ScriptInstruction {
+ header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0],
+ references: Vec::new(),
+ },
+ ScriptInstruction {
+ header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
+ references: vec![0, 1, 2],
+ },
+ ],
+ }],
+ trailing_bytes: Vec::new(),
+ raw: std::sync::Arc::from(&b""[..]),
+ };
+
+ assert_eq!(
+ varset.resolve_handler19_init(
+ &package,
+ Handler19InitInput {
+ first_x87_word: 7,
+ second_x87_word: 8,
+ third_word: 9,
+ },
+ ),
+ Ok(Handler19InitResolution::Unsupported {
+ event_index: 0,
+ instruction_index: 0,
+ error: Handler19ResolveError::UnsupportedSelector {
+ found: super::ScriptDispatchSelector::Handler(18),
+ },
+ })
+ );
+ }
+
+ #[test]
+ fn handler_nineteen_init_types_unsupported_control_and_result_save() {
+ let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
+ .expect("DWORD targets");
+ let input = Handler19InitInput {
+ first_x87_word: 7,
+ second_x87_word: 8,
+ third_word: 9,
+ };
+ let instruction = |control_word, result_word| ScriptInstruction {
+ header_words: [19, control_word, result_word, 0, 0, 3, 0],
+ references: vec![0, 1, 2],
+ };
+ let resolve = |instruction| {
+ let package = ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0,
+ instructions: vec![instruction],
+ }],
+ trailing_bytes: Vec::new(),
+ raw: std::sync::Arc::from(&b""[..]),
+ };
+ varset.resolve_handler19_init(&package, input)
+ };
+
+ assert_eq!(
+ resolve(instruction(0, u32::MAX)),
+ Ok(Handler19InitResolution::Unsupported {
+ event_index: 0,
+ instruction_index: 0,
+ error: Handler19ResolveError::UnsupportedControlWord { found: 0 },
+ })
+ );
+ assert_eq!(
+ resolve(instruction(u32::MAX, 2)),
+ Ok(Handler19InitResolution::Unsupported {
+ event_index: 0,
+ instruction_index: 0,
+ error: Handler19ResolveError::UnsupportedResultSave { found: 2 },
+ })
+ );
+ }
+
+ #[test]
fn handler_fifteen_resolves_exact_typed_target_by_place_operands() {
let varset = parse_varset(
b"VAR( DWORD, d0, 1)\nVAR( DWORD, d1, 2)\nVAR( DWORD, d2, 3)\nVAR( DWORD, d3, 4)\nVAR( float, f4, 1.5)\nVAR( float, f5, 2.5)\nVAR( float, f6, 3.5)\nVAR( float, f7, 4.5)\nVAR( DWORD, mode, 0x0202)\nVAR( DWORD, place0, 12)\nVAR( DWORD, place1, 13)\n",
diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md
index af89d49..a45d752 100644
--- a/docs/appendices/knowledge-boundaries.md
+++ b/docs/appendices/knowledge-boundaries.md
@@ -44,6 +44,58 @@ state ещё не восстановлены.
вывод по одному известному `ai.dll`, а не утверждение о совпадении во время
исполнения.
+Для начального сценарного события закрыт более узкий путь. В проверенной GOG
+сборке `ai.dll` SHA-256
+`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` функции
+`FUN_10011f40` (RVA `0x11f40`) и `FUN_10011f10` (RVA `0x11f10`) выбирают первое
+имя-событие C-string `Init` и запускают его инструкции по порядку. Для координат
+из TMA найден и проверен ещё один участок: `iron3d.dll` SHA-256
+`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`,
+`FUN_100392e0` (RVA `0x392e0`) выполняет x87 `FISTP qword` и передаёт младшие
+DWORD координат пятым и шестым аргументами `CreateSuperAI`. Затем
+`ai.dll!FUN_10005c70` (RVA `0x5c70`) преобразует DWORD в `float`, а `Handler(19)`
+возвращает его к DWORD усечением.
+
+Две x86-проверки вызвали эти оригинальные DLL-функции на изолированной
+синтетической памяти. Caller oracle подтвердил AutoDemo-координаты
+`(500.2857, 751.8217) -> (500, 752)`, ties-to-even в nearest-режиме и переход
+`-0.6 -> 0xffffffff`. AI oracle проверил оба набора AutoDemo anchor-ов,
+дублирующий `Init`, имя `Init\\0suffix\\0`, отсутствие события, а также
+нативную связку `FUN_10005c70` → `Handler(19)`: DWORD `(0xffffffff, 10001)` стал
+полями `(2^32, 10001.0)` и varset-записями `(0, 10001, 2)`. Эти проверки
+подтверждают только изолированные участки ABI.
+
+В одном read-only snapshot запущенной GOG AutoDemo были связаны записи кланов
+Iron3D и указатели `GetSuperAI` из `ai.dll` (таблица RVA `0x55398`). Клан 0 имел
+исходные float-координаты `(500.2857, 751.8217)` и поля AI
+`(+0x80, +0x84, +0x7c) = (500.0, 752.0, 0)`; клан 1 имел `(728.0, 449.0)` и
+`(728.0, 449.0, 1)`. Первый набор связывает те же значения в одном процессе и
+выбирает ближайшее округление среди направлений округления x87. Синтетические
+tie-векторы проверяют, как сам `FISTP` реагирует на каждый RC, но не измеряют
+control word процесса.
+
+Отдельно проверены загрузка лицензированного AutoDemo в Rust и preview
+лицензированной кампании. AutoDemo даёт raw TMA anchor-ы
+`[(500.2857, 751.8217), (728.0, 449.0)]` и записи `[(500, 752, 0),
+(728, 449, 1)]`; preview кампании сохраняет package и статус `Unsupported`,
+не применяя частичные записи. Это подтверждает загрузчик и узкий Init resolver
+Rust, но не доказывает, что штатный host вызывает `Init` при старте оригинальной
+миссии.
+
+Нативный конструктор показывает предупреждение при координате выше `10000`, но
+продолжает работу. Rust моделирует числовую запись, а не это UI-предупреждение;
+на первом `FISTP qword` он явно завершает загрузку ошибкой для NaN, infinity и
+значений вне signed-qword диапазона. В первом событии `Init` Rust поддерживает
+только action-форму `Handler(19)` (`control word` и `result reference` равны
+`0xffffffff`). Неизвестная инструкция даёт `Unsupported` в preview, не применяя
+накопленные частичные записи; при полной загрузке это ошибка. Это ограничение
+Rust исполнителя, не правило оригинального dispatcher-а. Ошибочные ссылки и
+типы остаются ошибками в обоих режимах.
+
+Открыты вызов `Init` при штатном старте Iron3D и проверка другой миссии с
+непустым `Init`. Не восстановлены host-ingress `Init` в игровом цикле и общий
+dispatcher остальных событий.
+
Для `Handler(30)` отдельно подтверждены обратный вызов `iron3d.dll` RVA
`0x611d0` (SHA-256 `d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`),
начальные поля IGame и проверки режима/состояния. Обработчик выбора миссии
diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md
index 0bffbe5..d12363a 100644
--- a/docs/appendices/script-vm.md
+++ b/docs/appendices/script-vm.md
@@ -268,8 +268,9 @@ scheduler path внутри запущенной игры.
На границе mission runtime выбранный TMA clan `first_resource`
материализуется как отдельный `MissionScriptBundle`: loader нормализует
`<base>.scr`, декодирует его тем же bounded reader-ом и публикует immutable
-package вместе с clan provenance. Это именно wiring входных данных, не VM
-execution: Init и остальные events пока не dispatch-ятся, а ошибка чтения сохраняет
+package вместе с clan provenance. Для первого события `Init` runtime исполняет
+только подтверждённый фрагмент dispatcher-а с `Handler(19)`; остальные события
+пока остаются только прочитанными данными. Ошибка чтения по-прежнему сохраняет
transactional rollback mission loader-а.
TMA properties остаются four raw `u32` words плюс имя, пока consumer/schema не
@@ -283,58 +284,136 @@ layout prerequisites/modifiers/unlocks. Formula evaluator требует strict
grammar/version, typed operands, deterministic numeric policy, bounded stack и
явных errors; x87-compatible rounding нужен там, где оно выбирает ветку.
-### Handler(19): AutoDemo Init varset initialization
-
-`Handler(19)` is the twentieth VM-table entry at GOG `ai.dll` VA `0x1000aa38`.
-It is the only instruction in the `Init` event of the two `default.scr` bundles
-referenced by `MISSIONS\\Autodemo.00\\data.tma`; together those bundles account for
-the observed 18 named script events. Each instruction references varset records
-`224`, `225`, and `226`: `ClanBaseX`, `ClanBaseY`, and `ClanID` respectively.
-
-The original writes three raw DWORD values in order. It converts the VM fields
-at `+0x80` and `+0x84` through the x87 `__ftol` helper and stores the resulting
-words into references 0 and 1. It copies the raw word from `+0x7c` into reference
-2, then clears VM field `+0x50`. The default targets are `DWORD` records; the
-shared setter preserves the incoming word for that type. Therefore this is not
-a license to replace the first two conversions with Rust float casts: their
-rounding behavior remains an x87 compatibility boundary until it has captured
-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
-writes. The runtime then materializes an independent declaration-ordered value
-array for each selected clan and applies those writes, so later recovered
-handlers can consume `ClanBaseX`, `ClanBaseY`, and `ClanID` as runtime cells
-rather than loader defaults. 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` and `script_varset_states=2`.
-
-`GetSuperAI` returns element `n` of the 64-pointer global table at preferred
-`ai.dll + 0x55398` for `n <= 63`. A read-only capture of the running GOG
-AutoDemo process observed `(500, 752, 0)` for entry 0 and `(728, 449, 1)` for
-entry 1 at fields
-`(+0x80, +0x84, +0x7c)`. These values are integral samples, not a rounding
-profile.
-
-The Rust reader exposes `VarSet::resolve_handler19`. It accepts the already
-converted first two words and the third raw word, produces three typed writes,
-and rejects missing, out-of-range, or non-`DWORD` targets. The runtime only
-binds it to the proven creation/anchor path above; it does not guess the
-remaining script event semantics.
+### Первый фрагмент dispatcher-а: `Init` и Handler(19)
+
+Этот контракт относится к проверенной сборке GOG: `ai.dll`, SHA-256
+`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`, PE32 с
+image base `0x10000000`. В ней `FUN_10011f40` (RVA `0x11f40`) ищет имя
+события как C-строку: сравнивает байты до первого NUL, с учётом регистра, и
+возвращает индекс первого совпадения. Поле `name_len` при поиске не используется.
+Если совпадения нет, поиск возвращает `-1`; `FUN_10011f10` (RVA `0x11f10`) не
+вызывает обработчик для такого индекса. Поэтому повторные записи `Init` не
+исполняются все подряд: выбирается первая.
+
+`FUN_10011f10` передаёт выбранный индекс внутреннему циклу `FUN_10011fb0`
+(RVA `0x11fb0`). Цикл начинает с первой вложенной инструкции, сбрасывает индекс
+инструкции и глубину стека условий, затем идёт по записям в порядке файла.
+В исходном `default.scr` событие `Init` содержит одну инструкцию selector `19`,
+control word `0xffffffff` (действие) и result reference `0xffffffff` (результат
+не сохраняется). Это наблюдаемая форма пакета; поведение оригинала для остальных
+форм здесь не утверждается.
+
+В GOG `default.scr` события `Init` в двух AutoDemo bundles имеют по одной такой
+инструкции. Её ссылки `224`, `225`, `226` ведут к объявлениям `DWORD`
+`ClanBaseX`, `ClanBaseY` и `ClanID`. `Handler(19)` (двадцатая запись таблицы,
+VA `0x1000aa38`, RVA `0xaa38`) пишет их по порядку: поля SuperAI `+0x80` и
+`+0x84` переводит через x87 helper `__ftol`, а поле `+0x7c` копирует как
+неизменённое слово. После вызова он очищает поле SuperAI `+0x50`; соответствующее
+смещение в VM, начинающейся с SuperAI `+8`, равно `+0x48`. Поскольку цели имеют
+тип `DWORD`, общий setter сохраняет полученные слова без дополнительного
+преобразования. Helper `__ftol` в `ai.dll` (VA `0x1001df70`, RVA `0x1df70`)
+сохраняет x87 control word, временно задаёт округление к нулю, выполняет
+`FISTP qword` и восстанавливает control word.
+
+Входные координаты идут через два DLL. В проверенной сборке `iron3d.dll`
+(SHA-256 `d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`)
+функция `FUN_100392e0` преобразует две координаты TMA из `float` в signed
+64-bit через x87 `FISTP qword` (RVA `0x39307` и `0x3932f`), а пятый и шестой
+аргументы `CreateSuperAI` получает как младшие DWORD этих результатов. Десятый
+аргумент — указатель callback. В прямом вызове оригинала пара
+`(500.2857, 751.8217)` дала `(500, 752)`, а tie-векторы `(500.5, 501.5)` при
+ближайшем режиме дали `(500, 502)`. Например, `-0.6` сначала округляется до
+`-1`, а его младший DWORD равен `0xffffffff`.
+
+`CreateSuperAI` в `ai.dll` (VA `0x1000f710`, RVA `0xf710`) выделяет `0x8b0`
+байт и вызывает конструктор `FUN_10001000`. Тот передаёт `clan_id`, `base_x` и
+`base_y` в `FUN_10006340`, которая заполняет поля позднее читаемые
+`Handler(19)`. Значения больше `10000` вызывают исходное предупреждение через
+`MessageBoxA`, после которого конструктор продолжает работу; это не отказ и не
+ограничение координат. `FUN_10005c70` (RVA `0x5c70`) затем расширяет DWORD до
+qword, загружает его через x87 и записывает `float` в поля структуры `+4` и
+`+8` — в этой цепочке это SuperAI `+0x80` и `+0x84`. Поэтому `0xffffffff`
+превращается в `2^32` при округлении до single precision.
+Сам `Handler(19)` применяет `__ftol` с усечением к этим полям и берёт младший
+DWORD: для `2^32` получается `0`, а для `10001.0` — `10001`.
+
+Rust повторяет весь числовой путь до DWORD, записываемого в `VarSet`:
+nearest-even `FISTP qword`, младший DWORD, преобразование DWORD в `f32`, затем
+усечение результата до qword и его младший DWORD. В частности, это сохраняет
+наблюдаемый переход `-0.6 -> 0xffffffff -> 2^32 -> 0` и не отвергает `10001`.
+Координата, не конечная или не представимая в signed qword на первом шаге,
+остаётся явной ошибкой: поведение такой области здесь не моделируется. Rust не
+показывает исходное предупреждение `MessageBoxA`; оно находится за пределами
+этого числового resolver-а.
+
+Ограниченный Rust-исполнитель `VarSet::resolve_handler19_init` ищет только
+первое событие `Init`, проходит его инструкции по порядку и возвращает
+упорядоченные записи. Runtime применяет их к отдельной копии varset выбранного
+клана лишь после успешного прохода всего события. Отсутствующий `Init` даёт
+статус `Missing` и не создаёт записей инициализации; пустой первый `Init` даёт
+`Executed` без записей. Если форма инструкции пока не поддержана, package
+остаётся доступен в preview режиме со статусом `Unsupported`, а default-значения
+ячеек сохраняются; при полной загрузке миссии такая же форма возвращает ошибку с
+индексами события и инструкции. Предшествующие частичные записи не применяются.
+Повреждённая ссылка или цель неправильного типа остаётся ошибкой в обоих режимах.
+Это правила ограниченного Rust-исполнителя, а не обнаруженное поведение
+оригинального dispatcher-а.
+
+Для проверки использованы два x86 helper-а на синтетической памяти. Первый
+вызывает оригинальные `iron3d.dll` entry points и проверяет преобразование
+TMA-координат в аргументы `CreateSuperAI`. Второй загружает оригинальную
+`ai.dll`, вызывает `FUN_10011f40` и `FUN_10011f10`, а записи читает штатным
+getter-ом `FUN_10013570` (RVA `0x13570`). В нём проверены оба набора AutoDemo,
+первая дублирующаяся запись `Init`, имя `Init\\0suffix\\0`, отсутствие записи
+при пропущенном `Init` и связка `FUN_10005c70` → Handler(19): для входных DWORD
+`(0xffffffff, 10001)` поля стали `(2^32, 10001.0)`, а varset получил
+`(0, 10001, 2)`. Наборы AutoDemo дали `(500, 752, 0)` и `(728, 449, 1)`.
+Эти синтетические вызовы подтверждают изолированные участки ABI, но не доказывают,
+кто в Iron3D запускает `Init` при полном старте игровой миссии.
+
+Обе проверки можно повторить из корня репозитория в Windows PowerShell. Первая
+вызывает оригинальный caller `iron3d.dll`; вторая — original dispatcher, setter
+и getter `ai.dll`:
+
+```powershell
+$env:FPARKAN_ORIGINAL_IRON3D_DLL = 'C:\GOG Games\Parkan - Iron Strategy\iron3d.dll'
+$env:FPARKAN_ORIGINAL_AI_DLL = 'C:\GOG Games\Parkan - Iron Strategy\ai.dll'
+$env:FPARKAN_ORIGINAL_DEFAULT_SCR = 'C:\GOG Games\Parkan - Iron Strategy\MISSIONS\SCRIPTS\default.scr'
+New-Item -ItemType Directory -Force -Path target\handler19-native-oracles | Out-Null
+& "$env:WINDIR\Microsoft.NET\Framework\v4.0.30319\csc.exe" /nologo /platform:x86 /out:target\handler19-native-oracles\AnchorOracle.exe crates\fparkan-runtime\tests\handler19_anchor_native_oracle.cs
+if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+& "$env:WINDIR\Microsoft.NET\Framework\v4.0.30319\csc.exe" /nologo /platform:x86 /out:target\handler19-native-oracles\InitOracle.exe crates\fparkan-runtime\tests\handler19_init_native_oracle.cs
+if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+& .\target\handler19-native-oracles\AnchorOracle.exe
+if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+& .\target\handler19-native-oracles\InitOracle.exe
+exit $LASTEXITCODE
+```
+
+Caller oracle проверяет AutoDemo пару `(500.2857, 751.8217)`, четыре режима
+x87 на tie-векторах, границу `10000` и младший DWORD. AI oracle отдельно
+проверяет, что `FUN_10005c70(0xffffffff, 10001)` даёт поля `(2^32, 10001.0)`,
+а обработчик записывает `(0, 10001, 2)`.
+
+`GetSuperAI` возвращает элемент `n` из таблицы на 64 указателя по RVA `0x55398`
+в `ai.dll`, если `n <= 63`. В режиме только чтения у запущенной GOG AutoDemo
+одновременно сняты исходные координаты кланов в `iron3d.dll` и поля созданных
+SuperAI в этой таблице. Для первого клана raw TMA-слова `0x43fa2492` и
+`0x443bf497` декодируются как `(500.2857, 751.8217)`, а соответствующие поля
+SuperAI `+0x80`, `+0x84`, `+0x7c` содержали `(500.0, 752.0, 0)`. Для второго
+клана сырые координаты и поля совпали: `(728.0, 449.0, 1)`. Связанный снимок
+исходных и конечных значений исключает усечение, направление вверх и направление
+вниз в реально запущенной игре. Отдельный caller oracle вызывает оригинальный
+`FUN_100392e0` при явно заданных x87 RC-режимах, включая nearest; сравнение
+tie-векторов подтверждает реакцию `FISTP`, но не измеряет control word процесса.
+Профиль живой игры выводится из связанной пары raw X/Y → SuperAI.
+
+Нижнеуровневый `VarSet::resolve_handler19` принимает уже преобразованные первые
+два слова и третье исходное слово, выдаёт три типизированные записи и отклоняет
+отсутствующие, выходящие за диапазон или не-`DWORD` цели. Метод
+`resolve_handler19_init` добавляет выбор события и остановку на неподдержанной
+форме инструкции. Всё остальное содержимое `.scr` сохраняется reader-ом, но
+остаётся вне этого исполнения.
### Аргументы и обратный вызов Handler(30)
diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md
index 3742772..f5c7a7b 100644
--- a/docs/tomes/06-behavior.md
+++ b/docs/tomes/06-behavior.md
@@ -151,6 +151,32 @@ handler contracts и runtime traces.
handler лишь инициирует execution context (`+0x50 = 1`); его gameplay meaning
пока не назван.
+Когда в `.scr` есть событие `Init`, сценарная машина ищет его имя как C-строку:
+сравнение учитывает регистр, останавливается на первом NUL, а среди повторов
+выбирает первое событие. В проверенном AutoDemo это событие содержит один
+`Handler(19)`. Он записывает в переменные клана координаты его основания и номер
+клана:
+
+```text
+первый Init -> действия по порядку -> ClanBaseX, ClanBaseY, ClanID
+```
+
+На пути к этим переменным координаты сначала округляются оригиналом до ближайшего
+целого с правилом ties-to-even, затем сохраняется младшее 32-битное слово.
+Например, `-0.6` даёт `0xffffffff`. AI хранит это слово как `float`, поэтому
+`0xffffffff` становится `2^32`; `Handler(19)` усекает значение и снова берёт
+младшие 32 бита, что даёт `0`. Значение `10001` также проходит дальше: оригинал
+показывает предупреждение для чисел выше `10000`, но не отменяет создание AI.
+
+Rust исполняет только подтверждённую форму этого действия. Поддержанные записи
+применяются к состоянию лишь после успешного прохода всего первого `Init`.
+Если встретилась неподдержанная форма, preview сохраняет пакет, сообщает
+`Unsupported` и оставляет значения по умолчанию; при полной загрузке та же форма
+становится ошибкой. Это политика Rust для ограниченного исполнителя, а не правило
+оригинального обработчика. Отсутствующее событие не создаёт записей; пустое
+событие считается исполненным без записей. Остальные события пока не образуют
+исполняемую VM.
+
Первый часто встречающийся table entry с side effect — `Handler(2)` (176
records в corpus). Он берёт active instruction, разрешает семь slots через
varset, приводит три значения к float по observed kinds `5`/`3`, затем