diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 427 | ||||
| -rw-r--r-- | crates/fparkan-runtime/tests/behavior_warm_start_native_oracle.cs | 164 | ||||
| -rw-r--r-- | crates/fparkan-runtime/tests/handler19_init_native_oracle.cs | 151 |
3 files changed, 724 insertions, 18 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 00e9cc5..80ce024 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -33,6 +33,7 @@ pub use fparkan_assets::MissionAssets; const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256; const FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var"; +const INITIAL_BEHAVIOR_RNG_STATE: u32 = 1; /// Injectable engine services used by composition roots. #[derive(Clone, Default)] @@ -168,6 +169,37 @@ pub struct MissionScriptBundle { pub package: ScriptPackage, /// Outcome of the restricted first-`Init` resolution for this bundle. pub init_execution: MissionScriptInitExecution, + /// Recovered per-AI Behavior state computed by a full mission startup. + /// Static previews leave this empty because they do not run script startup. + pub behavior_warm_start: Option<BehaviorWarmStartState>, +} + +/// Initial Behavior distributor fields recovered from the original warm-start path. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BehaviorWarmStartState { + /// Queue-clock value copied to distributor offset `+0x10` when it is zero. + pub first_time_ms: u32, + /// Initial scheduled time written to distributor offset `+0x14`. + pub next_deadline_ms: u32, + /// Opaque distributor words at offsets `+0x30..=+0x40`, cleared on first start. + pub cleared_words: [u32; 5], +} + +impl BehaviorWarmStartState { + fn start(queue_time_ms: u32, rng_state: &mut u32) -> Self { + const BASE_INTERVAL_MS: u32 = 3 * 64; + const JITTER_RANGE_MS: u32 = 64; + + *rng_state = (*rng_state).wrapping_mul(0x343fd).wrapping_add(0x269ec3); + let random = (*rng_state >> 16) & 0x7fff; + let jitter_ms = ((random & 0xff) * JITTER_RANGE_MS) >> 8; + + Self { + first_time_ms: queue_time_ms, + next_deadline_ms: queue_time_ms.wrapping_add(BASE_INTERVAL_MS + jitter_ms), + cleared_words: [0; 5], + } + } } /// Numeric script defaults selected for the mission's first script bundle. @@ -381,6 +413,7 @@ pub struct Engine { services: EngineServices, world: World, loaded: Option<LoadedMissionState>, + behavior_rng_state: u32, } struct LoadedMissionState { @@ -665,22 +698,35 @@ pub fn create(services: EngineServices) -> Result<Engine, EngineError> { services, world: new_world(WorldConfig), loaded: None, + behavior_rng_state: INITIAL_BEHAVIOR_RNG_STATE, }) } -/// Loads mission transactionally. +/// Loads a mission transactionally and runs the recovered initial script subset. +/// +/// `queue_time_ms` is the caller-supplied 32-bit queue-clock sample used to +/// initialize the first Behavior deadline for each script bundle. This runtime +/// entry point does not acquire the native queue clock or reproduce the full +/// SuperAI host lifecycle. /// /// # Errors /// -/// Returns [`EngineError`] when VFS services are missing, mission paths are +/// Returns [`EngineError`] if VFS services are missing, mission paths are /// invalid, required files cannot be read, disk formats fail validation, terrain -/// runtime data cannot be built, prototype graph roots do not resolve, or -/// object registration fails. +/// runtime data cannot be built, prototype graph roots do not resolve, object +/// registration fails, or a full mission's first `Init` contains an unsupported +/// action form. pub fn load_mission( engine: &mut Engine, request: MissionRequest, + queue_time_ms: u32, ) -> Result<LoadedMission, EngineError> { - load_mission_with_options(engine, request, MissionLoadOptions::default()) + load_mission_with_options( + engine, + request, + MissionLoadOptions::default(), + Some(queue_time_ms), + ) } /// Loads a mission using the bounded static-preview asset scope. @@ -706,6 +752,7 @@ pub fn load_mission_static_preview( asset_scope: MissionAssetScope::FirstMeshPreview, ..MissionLoadOptions::default() }, + None, ) } @@ -729,6 +776,7 @@ pub fn load_mission_static_preview_roots( asset_scope: MissionAssetScope::PreviewRoots(root_count), ..MissionLoadOptions::default() }, + None, ) } @@ -736,6 +784,7 @@ fn load_mission_with_options( engine: &mut Engine, request: MissionRequest, options: MissionLoadOptions, + queue_time_ms: Option<u32>, ) -> Result<LoadedMission, EngineError> { let vfs = engine.services.vfs.clone().ok_or(EngineError::MissingVfs)?; let mission_path = normalize_engine_path("mission", &request.key)?; @@ -786,7 +835,7 @@ fn load_mission_with_options( source, } })?; - let loaded_scripts = load_mission_scripts(&vfs, &mission, options.asset_scope)?; + let mut loaded_scripts = load_mission_scripts(&vfs, &mission, options.asset_scope)?; let repository = CachedResourceRepository::with_payload_cache_budget( vfs.clone(), MISSION_DECODED_PAYLOAD_CACHE_ENTRIES, @@ -902,6 +951,20 @@ fn load_mission_with_options( registered_objects += 1; } + let mut next_behavior_rng_state = engine.behavior_rng_state; + if options.asset_scope == MissionAssetScope::Full { + let queue_time_ms = queue_time_ms.expect("full mission loading requires a queue time"); + if let Err(error) = start_mission_scripts( + &mission, + &mut loaded_scripts, + queue_time_ms, + &mut next_behavior_rng_state, + ) { + let _ = fparkan_world::shutdown(new_runtime_world); + return Err(error); + } + } + let summary = LoadedMission { key: request.key, path_count: mission.paths.len(), @@ -951,6 +1014,7 @@ fn load_mission_with_options( }; engine.world = new_runtime_world; + engine.behavior_rng_state = next_behavior_rng_state; engine.loaded = Some(LoadedMissionState { summary: summary.clone(), mission, @@ -1272,14 +1336,17 @@ fn load_mission_scripts( script_path: normalized.as_str().to_string(), package, init_execution: MissionScriptInitExecution::Missing, + behavior_warm_start: None, }); } let varset = first_base_path .as_deref() .map(|base_path| load_script_varset(vfs, base_path)) .transpose()?; - let init_states = match &varset { - Some(varset) => resolve_handler19_init_states( + let (init_states, varset_states) = if asset_scope == MissionAssetScope::Full { + (Vec::new(), Vec::new()) + } else if let Some(varset) = &varset { + let init_states = resolve_handler19_init_states( &mission .clans .iter() @@ -1288,12 +1355,11 @@ fn load_mission_scripts( &mut bundles, varset, asset_scope, - )?, - None => Vec::new(), - }; - let varset_states = match &varset { - Some(varset) => materialize_script_varset_states(&bundles, varset, &init_states)?, - None => Vec::new(), + )?; + let varset_states = materialize_script_varset_states(&bundles, varset, &init_states)?; + (init_states, varset_states) + } else { + (Vec::new(), Vec::new()) }; Ok(LoadedMissionScripts { bundles, @@ -1303,6 +1369,46 @@ fn load_mission_scripts( }) } +fn start_mission_scripts( + mission: &MissionDocument, + scripts: &mut LoadedMissionScripts, + queue_time_ms: u32, + behavior_rng_state: &mut u32, +) -> Result<(), EngineError> { + // The recovered slot refreshes one Behavior instance before dispatching + // that same instance's Init event. The Rust loader waits until its new + // world is registered before it starts this per-bundle sequence. + let clan_anchors = mission + .clans + .iter() + .map(|clan| clan.anchor) + .collect::<Vec<_>>(); + let mut init_states = Vec::new(); + for bundle in &mut scripts.bundles { + bundle.behavior_warm_start = Some(BehaviorWarmStartState::start( + queue_time_ms, + behavior_rng_state, + )); + + if let Some(varset) = scripts.varset.as_ref() { + init_states.extend(resolve_handler19_init_states( + &clan_anchors, + std::slice::from_mut(bundle), + varset, + MissionAssetScope::Full, + )?); + } + } + scripts.init_states = init_states; + scripts.varset_states = match scripts.varset.as_ref() { + Some(varset) => { + materialize_script_varset_states(&scripts.bundles, varset, &scripts.init_states)? + } + None => Vec::new(), + }; + Ok(()) +} + fn materialize_script_varset_states( bundles: &[MissionScriptBundle], varset: &MissionScriptVarSet, @@ -1556,6 +1662,134 @@ mod tests { } #[test] + fn behavior_warm_start_uses_native_clock_and_shared_rng_vectors() { + let mut rng_state = 1; + let first = BehaviorWarmStartState::start(100_000, &mut rng_state); + assert_eq!(first.first_time_ms, 100_000); + assert_eq!(first.next_deadline_ms, 100_202); + assert_eq!(first.cleared_words, [0; 5]); + assert_eq!(rng_state, 0x0029_e2c0); + + let second = BehaviorWarmStartState::start(100_500, &mut rng_state); + assert_eq!(second.first_time_ms, 100_500); + assert_eq!(second.next_deadline_ms, 100_700); + assert_eq!(second.cleared_words, [0; 5]); + assert_eq!(rng_state, 0xc823_f683); + + let zero = BehaviorWarmStartState::start(0, &mut rng_state); + assert_eq!(zero.first_time_ms, 0); + assert_eq!(zero.next_deadline_ms, 239); + assert_eq!(rng_state, 0x18be_873a); + + // A second fresh AI with a zero queue clock receives its own LCG draw. + let second_zero_clock_start = BehaviorWarmStartState::start(0, &mut rng_state); + assert_eq!(second_zero_clock_start.first_time_ms, 0); + assert_eq!(second_zero_clock_start.next_deadline_ms, 225); + assert_eq!(rng_state, 0xe784_7115); + + let wrapping = BehaviorWarmStartState::start(0xffff_ff80, &mut rng_state); + assert_eq!(wrapping.next_deadline_ms, 120); + assert_eq!(rng_state, 0xcae1_df84); + } + + #[test] + fn mission_script_start_stops_before_warming_later_bundles() { + fn push_u32(bytes: &mut Vec<u8>, value: u32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + fn push_lp(bytes: &mut Vec<u8>, value: &[u8]) { + push_u32(bytes, u32::try_from(value.len()).expect("length fits u32")); + bytes.extend_from_slice(value); + } + + let mut tma = Vec::new(); + push_u32(&mut tma, 1); // TMA format version. + push_u32(&mut tma, 0); // Paths. + push_u32(&mut tma, 6); // Clan section version. + push_u32(&mut tma, 2); // Two clans provide Handler(19) anchors. + for (x, y) in [(500.0_f32, 752.0_f32), (728.0_f32, 449.0_f32)] { + push_lp(&mut tma, b""); + push_u32(&mut tma, u32::MAX); // Raw clan id -1. + tma.extend_from_slice(&x.to_le_bytes()); + tma.extend_from_slice(&y.to_le_bytes()); + push_u32(&mut tma, 1); // Standard clan body. + push_lp(&mut tma, b""); + push_u32(&mut tma, 0); + push_lp(&mut tma, b""); + push_u32(&mut tma, 0); + push_u32(&mut tma, 0); // Relations. + } + push_u32(&mut tma, 10); // Object section version. + push_u32(&mut tma, 0); // Objects. + push_lp(&mut tma, b""); // Landscape path. + push_u32(&mut tma, 0); // Mission flag. + push_lp(&mut tma, b""); // Description. + push_u32(&mut tma, 1); // Extra section version. + push_u32(&mut tma, 0); // Extras. + let mission = decode_mission_payload(Arc::from(tma), TmaProfile::Strict) + .expect("two-clan startup fixture"); + + let varset = MissionScriptVarSet { + path: FALLBACK_SCRIPT_VARSET_PATH.to_string(), + used_fallback: true, + declarations: fparkan_script::parse_varset( + b"VAR( DWORD, ClanBaseX, 0)\nVAR( DWORD, ClanBaseY, 0)\nVAR( DWORD, ClanID, 0)\n", + ) + .expect("varset"), + }; + let bundle = |clan_index, selector, references: Vec<u32>| MissionScriptBundle { + clan_index, + 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: 0, + instructions: vec![ScriptInstruction { + header_words: [selector, u32::MAX, u32::MAX, 0, 0, 3, 0], + references, + }], + }], + trailing_bytes: Vec::new(), + raw: Arc::from(&b""[..]), + }, + init_execution: MissionScriptInitExecution::Missing, + behavior_warm_start: None, + }; + let mut scripts = LoadedMissionScripts { + bundles: vec![bundle(0, 18, Vec::new()), bundle(1, 19, vec![0, 1, 2])], + varset: Some(varset), + ..LoadedMissionScripts::default() + }; + let mut rng_state = INITIAL_BEHAVIOR_RNG_STATE; + + let error = start_mission_scripts(&mission, &mut scripts, 100_000, &mut rng_state) + .expect_err("unsupported first Init stops startup before the second bundle"); + + assert!(matches!( + error, + EngineError::ScriptInitUnsupported { + source: Handler19ResolveError::UnsupportedSelector { + found: fparkan_script::ScriptDispatchSelector::Handler(18), + }, + .. + } + )); + assert_eq!( + scripts.bundles[0].behavior_warm_start, + Some(BehaviorWarmStartState { + first_time_ms: 100_000, + next_deadline_ms: 100_202, + cleared_words: [0; 5], + }) + ); + assert!(scripts.bundles[1].behavior_warm_start.is_none()); + assert_eq!(rng_state, 0x0029_e2c0); + } + + #[test] fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() { let mut engine = create(EngineServices::default()).expect("engine"); let before = step_headless(&mut engine, InputSnapshot).expect("step"); @@ -1565,6 +1799,7 @@ mod tests { key: "MISSIONS/Autodemo.00/data.tma".to_string(), mode: MissionMode::SingleMission, }, + 0, ) .expect_err("missing VFS"); assert!(matches!(err, EngineError::MissingVfs)); @@ -1651,6 +1886,7 @@ mod tests { raw: Arc::from(&b""[..]), }, init_execution: MissionScriptInitExecution::Missing, + behavior_warm_start: None, }]; let states = resolve_handler19_init_states( @@ -1747,6 +1983,7 @@ mod tests { script_path: "MISSIONS/SCRIPTS/default.scr".to_string(), package: package.clone(), init_execution: MissionScriptInitExecution::Missing, + behavior_warm_start: None, }; let anchors = [[500.0, 752.0]]; let mut preview_bundles = vec![bundle()]; @@ -2002,6 +2239,7 @@ mod tests { key: "MISSIONS/Autodemo.00/data.tma".to_string(), mode: MissionMode::AutoDemo, }, + 100_000, ) .expect("load licensed AutoDemo"); @@ -2022,6 +2260,18 @@ mod tests { .iter() .all(|bundle| bundle.init_execution == MissionScriptInitExecution::Executed { event_index: 0 })); + assert_eq!(engine.behavior_rng_state, 0xc823_f683); + assert_eq!( + loaded_mission_script_bundles(&engine) + .expect("warmed script bundles") + .iter() + .map(|bundle| bundle + .behavior_warm_start + .expect("full startup warms the AI")) + .map(|warm_start| warm_start.next_deadline_ms) + .collect::<Vec<_>>(), + vec![100_202, 100_200] + ); let varsets = loaded_mission_script_varset_states(&engine).expect("per-clan varsets"); let clan_zero = varsets @@ -2067,6 +2317,10 @@ mod tests { .expect("load licensed campaign preview"); let bundles = loaded_mission_script_bundles(&engine).expect("script bundles"); + assert!(bundles + .iter() + .all(|bundle| bundle.behavior_warm_start.is_none())); + assert_eq!(engine.behavior_rng_state, INITIAL_BEHAVIOR_RNG_STATE); let unsupported = bundles .iter() .find(|bundle| { @@ -2211,6 +2465,7 @@ mod tests { key: "MISSIONS/Autodemo.00/data.tma".to_string(), mode: MissionMode::SingleMission, }, + 0, ) .expect("load mission"); @@ -2283,7 +2538,7 @@ mod tests { }); let mut engine = create(EngineServices::new(vfs)).expect("engine"); let before = step_headless(&mut engine, InputSnapshot).expect("before"); - let err = load_mission(&mut engine, mission).expect_err("load error"); + let err = load_mission(&mut engine, mission, 0).expect_err("load error"); match denied { DenyRule::Suffix("Land.map") => assert!(matches!(err, EngineError::Vfs { .. })), DenyRule::Suffix("objects.rlb") => { @@ -2315,6 +2570,7 @@ mod tests { fail_after_registered_objects: Some(1), ..MissionLoadOptions::default() }, + Some(0), ) .expect_err("forced registration failure"); @@ -2333,6 +2589,73 @@ mod tests { #[test] #[ignore = "requires licensed corpus"] + fn full_startup_init_failure_keeps_the_previous_world_and_rng() { + let root = licensed_root("IS"); + let original_script = std::fs::read(root.join("MISSIONS/SCRIPTS/default.scr")) + .expect("read licensed default.scr"); + let mut package = fparkan_script::decode(&original_script).expect("decode default.scr"); + let init_index = package + .events + .iter() + .position(|event| event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])) + .expect("Init event"); + assert_eq!( + package.events[init_index].instructions[0].header_words[0], + 19 + ); + package.events[init_index].instructions[0].header_words[0] = 18; + let replacement = Arc::<[u8]>::from(encode_script_package(&package)); + + let vfs = Arc::new(SwappingScriptVfs { + inner: DirectoryVfs::new(&root), + replacement, + enabled: std::sync::atomic::AtomicBool::new(false), + }); + let services_vfs: Arc<dyn Vfs> = vfs.clone(); + let mut engine = create(EngineServices::new(services_vfs)).expect("engine"); + let mission = || MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".to_string(), + mode: MissionMode::AutoDemo, + }; + load_mission(&mut engine, mission(), 100_000).expect("initial full startup"); + + let previous_loaded = loaded_mission(&engine).expect("previous mission").clone(); + let previous_bundles = loaded_mission_script_bundles(&engine) + .expect("previous bundles") + .to_vec(); + let previous_rng = engine.behavior_rng_state; + let before = step_headless(&mut engine, InputSnapshot) + .expect("step previous world") + .snapshot + .objects; + + vfs.enabled.store(true, std::sync::atomic::Ordering::SeqCst); + let error = load_mission(&mut engine, mission(), 100_500) + .expect_err("unsupported Init must fail after new-world registration"); + assert!(matches!( + error, + EngineError::ScriptInitUnsupported { + source: Handler19ResolveError::UnsupportedSelector { + found: ScriptDispatchSelector::Handler(18), + }, + .. + } + )); + assert_eq!(loaded_mission(&engine), Some(&previous_loaded)); + assert_eq!( + loaded_mission_script_bundles(&engine), + Some(previous_bundles.as_slice()) + ); + assert_eq!(engine.behavior_rng_state, previous_rng); + let after = step_headless(&mut engine, InputSnapshot) + .expect("step retained world") + .snapshot + .objects; + assert_eq!(before, after); + } + + #[test] + #[ignore = "requires licensed corpus"] fn selected_is_and_is2_missions_execute_10000_deterministic_ticks() { for case in [ HeadlessCase { @@ -2381,6 +2704,7 @@ mod tests { key: case.1.to_string(), mode: MissionMode::SingleMission, }, + 0, ) .expect("load mission"); let drafts = loaded_mission_object_drafts(&engine).expect("object drafts"); @@ -2494,6 +2818,7 @@ mod tests { key: case.mission.to_string(), mode: MissionMode::SingleMission, }, + 0, ) .expect("load selected mission"); assert_eq!(loaded.object_count, case.object_count); @@ -2523,6 +2848,7 @@ mod tests { key: mission, mode: MissionMode::SingleMission, }, + 0, ) .expect("load mission foundation"); assert_eq!(loaded.object_count, loaded.registered_objects); @@ -2629,6 +2955,39 @@ mod tests { root } + fn encode_script_package(package: &ScriptPackage) -> Vec<u8> { + fn push_u32(bytes: &mut Vec<u8>, value: u32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + + let mut bytes = Vec::new(); + push_u32(&mut bytes, package.opcode_handler_count); + push_u32( + &mut bytes, + u32::try_from(package.events.len()).expect("event count fits u32"), + ); + for event in &package.events { + push_u32(&mut bytes, event.name_len); + bytes.extend_from_slice(&event.name_raw); + push_u32(&mut bytes, event.event_word); + push_u32( + &mut bytes, + u32::try_from(event.instructions.len()).expect("instruction count fits u32"), + ); + for instruction in &event.instructions { + for word in &instruction.header_words[..6] { + push_u32(&mut bytes, *word); + } + for reference in &instruction.references { + push_u32(&mut bytes, *reference); + } + push_u32(&mut bytes, instruction.header_words[6]); + } + } + bytes.extend_from_slice(&package.trailing_bytes); + bytes + } + #[derive(Clone, Copy)] enum DenyRule { Suffix(&'static str), @@ -2639,6 +2998,44 @@ mod tests { denied: DenyRule, } + struct SwappingScriptVfs { + inner: DirectoryVfs, + replacement: Arc<[u8]>, + enabled: std::sync::atomic::AtomicBool, + } + + impl SwappingScriptVfs { + fn use_replacement(&self, path: &NormalizedPath) -> bool { + self.enabled.load(std::sync::atomic::Ordering::SeqCst) + && path + .as_str() + .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.scr") + } + } + + impl Vfs for SwappingScriptVfs { + fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> { + if self.use_replacement(path) { + return Ok(VfsMetadata { + len: self.replacement.len() as u64, + fingerprint: [0x18; 32], + }); + } + self.inner.metadata(path) + } + + fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> { + if self.use_replacement(path) { + return Ok(self.replacement.clone()); + } + self.inner.read(path) + } + + fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> { + self.inner.list(prefix) + } + } + impl DenyVfs { fn denied(&self, path: &NormalizedPath) -> bool { match self.denied { diff --git a/crates/fparkan-runtime/tests/behavior_warm_start_native_oracle.cs b/crates/fparkan-runtime/tests/behavior_warm_start_native_oracle.cs new file mode 100644 index 0000000..3620bf3 --- /dev/null +++ b/crates/fparkan-runtime/tests/behavior_warm_start_native_oracle.cs @@ -0,0 +1,164 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; + +internal static class BehaviorWarmStartOracle { + private const uint LoadWithAlteredSearchPath = 0x00000008; + private const string ExpectedBehaviorSha256 = "ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99"; + private const int WarmStartRva = 0x1adb0; + private const int GetQueueSlotRva = 0x59110; + private const int RandomStateRva = 0x63c1c; + private static uint currentQueueTime; + private static IntPtr queue; + private static bool queuePointerMatched; + private static QueueGetter queueGetter = GetCurrentQueue; + private static QueueTimeGetter queueTimeGetter = ReadQueueTime; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr LoadLibraryEx(string fileName, IntPtr file, uint flags); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool FreeLibrary(IntPtr module); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool VirtualProtect(IntPtr address, UIntPtr size, uint protect, out uint oldProtect); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate IntPtr QueueGetter(); + [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate uint QueueTimeGetter(IntPtr self); + [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void WarmStart(IntPtr self); + + private static IntPtr GetCurrentQueue() { return queue; } + private static uint ReadQueueTime(IntPtr self) { queuePointerMatched &= self == queue; return currentQueueTime; } + + private static int Main() { + if (IntPtr.Size != 4) { Console.Error.WriteLine("Build with /platform:x86."); return 2; } + string path = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_BEHAVIOR_DLL"); + if (String.IsNullOrEmpty(path)) path = @"C:\GOG Games\Parkan - Iron Strategy\Behavior.dll"; + if (!File.Exists(path)) { Console.Error.WriteLine("Set FPARKAN_ORIGINAL_BEHAVIOR_DLL to the installed Behavior.dll."); return 3; } + string hash = Hash(path); + if (hash != ExpectedBehaviorSha256) { Console.Error.WriteLine("Unexpected Behavior.dll SHA-256: " + hash); return 4; } + IntPtr module = LoadLibraryEx(path, IntPtr.Zero, LoadWithAlteredSearchPath); + if (module == IntPtr.Zero) { Console.Error.WriteLine("LoadLibraryEx(Behavior.dll) failed: " + Marshal.GetLastWin32Error()); return 5; } + IntPtr slot = IntPtr.Add(module, GetQueueSlotRva); + IntPtr oldQueueGetter = Marshal.ReadIntPtr(slot); + int oldRandomValue = Marshal.ReadInt32(IntPtr.Add(module, RandomStateRva)); + IntPtr queueVtable = Marshal.AllocHGlobal(0x40); + queue = Marshal.AllocHGlobal(0x80); + IntPtr record = Marshal.AllocHGlobal(0x44); + IntPtr secondRecord = Marshal.AllocHGlobal(0x44); + IntPtr zeroClockRecord = Marshal.AllocHGlobal(0x44); + IntPtr wrappingClockRecord = Marshal.AllocHGlobal(0x44); + uint oldSlotProtection = 0; + bool slotProtectionChanged = false; + try { + Zero(queueVtable, 0x40); Zero(queue, 0x80); + Zero(record, 0x44); Zero(secondRecord, 0x44); Zero(zeroClockRecord, 0x44); Zero(wrappingClockRecord, 0x44); + Marshal.WriteIntPtr(queue, 0, queueVtable); + Marshal.WriteIntPtr(queueVtable, 0x38, Marshal.GetFunctionPointerForDelegate(queueTimeGetter)); + // The synthetic queue controls only GetQueue and the +0x38 clock slot. + if (!VirtualProtect(slot, new UIntPtr((uint)IntPtr.Size), 0x40, out oldSlotProtection)) + throw new InvalidOperationException("VirtualProtect(GetQueue IAT) failed: " + Marshal.GetLastWin32Error()); + slotProtectionChanged = true; + Marshal.WriteIntPtr(slot, Marshal.GetFunctionPointerForDelegate(queueGetter)); + WarmStart prepare = (WarmStart)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, WarmStartRva), typeof(WarmStart)); + + uint rngState = 1; + Expect(unchecked((uint)oldRandomValue) == rngState, "fresh DLL RNG state starts at the statically observed seed"); + InitializeRecord(record); + currentQueueTime = 100000; + queuePointerMatched = true; + prepare(record); + Expect(queuePointerMatched, "native clock vslot receives the synthetic queue object"); + uint previousRandomState = rngState; + rngState = NextRandomState(rngState); + VerifyFirstPreparation(record, currentQueueTime, previousRandomState, rngState, module); + + uint savedDeadline = ReadWord(record, 0x14); + for (int offset = 0x30; offset <= 0x40; offset += 4) Marshal.WriteInt32(record, offset, 0x5a5a5a5a); + currentQueueTime = ReadWord(record, 0x10); + queuePointerMatched = true; + prepare(record); + Expect(queuePointerMatched, "repeat native clock vslot receives the synthetic queue object"); + Expect(ReadWord(record, 0x14) == savedDeadline, "repeat before deadline preserves the deadline"); + Expect(ReadWord(IntPtr.Add(module, RandomStateRva), 0) == rngState, "repeat before deadline consumes no RNG draw"); + for (int offset = 0x30; offset <= 0x40; offset += 4) + Expect(ReadWord(record, offset) == 0x5a5a5a5a, "nonzero first-time field skips the warm-preparation clear pass"); + + InitializeRecord(secondRecord); + currentQueueTime = 100500; + queuePointerMatched = true; + prepare(secondRecord); + Expect(queuePointerMatched, "second record uses the same synthetic queue"); + previousRandomState = rngState; + rngState = NextRandomState(rngState); + VerifyFirstPreparation(secondRecord, currentQueueTime, previousRandomState, rngState, module); + + InitializeRecord(zeroClockRecord); + currentQueueTime = 0; + queuePointerMatched = true; + prepare(zeroClockRecord); + Expect(queuePointerMatched, "zero-clock record uses the synthetic queue"); + previousRandomState = rngState; + rngState = NextRandomState(rngState); + VerifyFirstPreparation(zeroClockRecord, 0, previousRandomState, rngState, module); + for (int offset = 0x30; offset <= 0x40; offset += 4) Marshal.WriteInt32(zeroClockRecord, offset, 0x6b6b6b6b); + queuePointerMatched = true; + prepare(zeroClockRecord); + Expect(queuePointerMatched, "zero-clock record uses the synthetic queue on repeat"); + previousRandomState = rngState; + rngState = NextRandomState(rngState); + VerifyFirstPreparation(zeroClockRecord, 0, previousRandomState, rngState, module); + + InitializeRecord(wrappingClockRecord); + currentQueueTime = 0xffffff80; + queuePointerMatched = true; + prepare(wrappingClockRecord); + Expect(queuePointerMatched, "wrapping-clock record uses the synthetic queue"); + previousRandomState = rngState; + rngState = NextRandomState(rngState); + VerifyFirstPreparation(wrappingClockRecord, currentQueueTime, previousRandomState, rngState, module); + Expect(ReadWord(wrappingClockRecord, 0x14) < currentQueueTime, "deadline addition wraps as a DWORD near UINT32_MAX"); + Console.WriteLine("original Behavior.dll warm-start oracle passed (RVA 0x1adb0; shared RNG, zero-clock repeat, and DWORD wrap)."); + return 0; + } + finally { + if (slotProtectionChanged) { + Marshal.WriteIntPtr(slot, oldQueueGetter); + uint ignored; + VirtualProtect(slot, new UIntPtr((uint)IntPtr.Size), oldSlotProtection, out ignored); + } + Marshal.WriteInt32(IntPtr.Add(module, RandomStateRva), oldRandomValue); + Marshal.FreeHGlobal(record); Marshal.FreeHGlobal(secondRecord); Marshal.FreeHGlobal(zeroClockRecord); Marshal.FreeHGlobal(wrappingClockRecord); + Marshal.FreeHGlobal(queue); Marshal.FreeHGlobal(queueVtable); + GC.KeepAlive(queueGetter); GC.KeepAlive(queueTimeGetter); + FreeLibrary(module); + } + } + + private static uint ReadWord(IntPtr address, int offset) { return unchecked((uint)Marshal.ReadInt32(address, offset)); } + private static uint NextRandomState(uint state) { return unchecked(state * 0x343fd + 0x269ec3); } + private static uint ExpectedDeadline(uint queueTime, uint previousRandomState) { + uint random = (NextRandomState(previousRandomState) >> 16) & 0x7fff; + uint jitter = ((random & 0xff) * 64) >> 8; + return unchecked(queueTime + 3 * 64 + jitter); + } + private static void InitializeRecord(IntPtr record) { + Zero(record, 0x44); + Marshal.WriteInt16(record, 0x18, 3); // Three 64-ms base units. + Marshal.WriteInt16(record, 0x1a, 1); // One 64-ms jitter range. + for (int offset = 0x30; offset <= 0x40; offset += 4) + Marshal.WriteInt32(record, offset, unchecked((int)(0xa0a00000u + (uint)offset))); + } + private static void VerifyFirstPreparation(IntPtr record, uint queueTime, uint previousRandomState, uint randomState, IntPtr module) { + Expect(ReadWord(record, 0x10) == queueTime, "warm preparation stores the queue clock in distributor+0x10"); + Expect(ReadWord(record, 0x14) == ExpectedDeadline(queueTime, previousRandomState), "deadline matches base, configured interval, and native RNG jitter"); + Expect(ReadWord(IntPtr.Add(module, RandomStateRva), 0) == randomState, "shared RNG advances by exactly one LCG draw"); + for (int offset = 0x30; offset <= 0x40; offset += 4) + Expect(ReadWord(record, offset) == 0, "warm preparation clears distributor field +0x" + offset.ToString("x")); + } + 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 Behavior 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-runtime/tests/handler19_init_native_oracle.cs b/crates/fparkan-runtime/tests/handler19_init_native_oracle.cs index 5dc319a..bd2e581 100644 --- a/crates/fparkan-runtime/tests/handler19_init_native_oracle.cs +++ b/crates/fparkan-runtime/tests/handler19_init_native_oracle.cs @@ -8,10 +8,13 @@ using System.Text; internal static class Handler19DispatchOracle { private const uint LoadWithAlteredSearchPath = 0x00000008; private const string ExpectedAiSha256 = "c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc"; + private const string ExpectedIron3dSha256 = "d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa"; private const string ExpectedScriptSha256 = "db1c6fde57aff43181619f19e19e1cacf7301ebb859e8cb883db6b4b7181eced"; private const int EventLookupRva = 0x11f40; private const int EventDispatchRva = 0x11f10; private const int Handler19Rva = 0x0aa38; + private const int AiWarmStartRva = 0x01ae0; + private const int Iron3dClanRefreshRva = 0x39440; private const int EventStride = 0x14; private const int InstructionStride = 0x20; private const int VarSetStride = 0x30; @@ -24,6 +27,32 @@ internal static class Handler19DispatchOracle { [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); + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void RefreshClan(IntPtr record, uint requestedMode); + [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void HostInterfaceRefresh(IntPtr host); + + private sealed class WarmStartObservation { + public int Calls; + public IntPtr Host; + public IntPtr Record; + public IntPtr Ai; + public IntPtr Cells; + public int EntryMode; + public int AiMode; + public byte Latch; + public uint XCellBeforeDispatch; + } + private static WarmStartObservation CurrentWarmStartObservation; + private static HostInterfaceRefresh WarmStartRecorder = RecordWarmStart; + + private static void RecordWarmStart(IntPtr host) { + WarmStartObservation observation = CurrentWarmStartObservation; + observation.Calls++; + observation.Host = host; + observation.EntryMode = Marshal.ReadInt32(observation.Record, 0x18); + observation.AiMode = Marshal.ReadInt32(observation.Ai, 0x3fc); + observation.Latch = Marshal.ReadByte(observation.Ai, 0x8a8); + observation.XCellBeforeDispatch = ReadWord(observation.Cells, 0); + } private sealed class Instruction { public readonly uint[] Words; @@ -59,11 +88,13 @@ internal static class Handler19DispatchOracle { 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 iron3dPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_IRON3D_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 (String.IsNullOrEmpty(aiPath) || !File.Exists(aiPath) || String.IsNullOrEmpty(iron3dPath) || !File.Exists(iron3dPath) || String.IsNullOrEmpty(scriptPath) || !File.Exists(scriptPath)) { + Console.Error.WriteLine("Set FPARKAN_ORIGINAL_AI_DLL, FPARKAN_ORIGINAL_IRON3D_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(iron3dPath) != ExpectedIron3dSha256) { Console.Error.WriteLine("Unexpected iron3d.dll SHA-256: " + Hash(iron3dPath)); 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."); @@ -84,12 +115,126 @@ internal static class Handler19DispatchOracle { 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"); + RunOriginalClanRefreshPath(package, module, iron3dPath); + Console.WriteLine("original ai.dll dispatcher passed for default.scr Init instances and DWORD round-trip; original iron3d clan-refresh to AI-vtable Init path also passed; code bytes were not modified"); return 0; } finally { FreeLibrary(module); } } + private static void RunOriginalClanRefreshPath(Package package, IntPtr aiModule, string iron3dPath) { + IntPtr iron3d = LoadLibraryEx(iron3dPath, IntPtr.Zero, LoadWithAlteredSearchPath); + if (iron3d == IntPtr.Zero) throw new InvalidOperationException("LoadLibraryEx(iron3d.dll) failed: " + Marshal.GetLastWin32Error()); + try { + RefreshClan refresh = (RefreshClan)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(iron3d, Iron3dClanRefreshRva), typeof(RefreshClan)); + IntPtr method = IntPtr.Add(aiModule, AiWarmStartRva); + RunClanRefreshCase(package, aiModule, refresh, method, 1, 2, 2, "forward requested mode"); + RunClanRefreshCase(package, aiModule, refresh, method, 0, 2, 1, "zero flag forces mode one"); + RunClanRefreshCase(package, aiModule, refresh, method, 1, 1, 1, "forward mode one"); + RunClanRefreshMissingCase(package, aiModule, refresh, method); + RunClanRefreshBusyCase(package, aiModule, refresh, method); + RunClanRefreshNullAiCase(refresh); + } + finally { FreeLibrary(iron3d); } + } + + private static void RunClanRefreshCase(Package package, IntPtr aiModule, RefreshClan refresh, IntPtr method, int forceFlag, uint requestedMode, uint expectedMode, string label) { + using (Instance instance = BuildInstance(package, 500.0f, 752.0f, 0, IntPtr.Add(aiModule, Handler19Rva))) { + IntPtr host = ConfigureClanRefresh(instance, method); + IntPtr record = PrepareClanRecord(instance, forceFlag); + + CurrentWarmStartObservation = new WarmStartObservation { Record = record, Ai = instance.Ai, Cells = instance.Cells }; + refresh(record, requestedMode); + WarmStartObservation observed = CurrentWarmStartObservation; + Expect(Marshal.ReadInt32(record, 0x18) == requestedMode, label + ": Iron3D stores the requested per-clan mode"); + Expect(Marshal.ReadInt32(instance.Ai, 0x3fc) == expectedMode, label + ": AI callback receives the forced/forwarded mode"); + Expect(observed.Calls == 1 && observed.Host == host, label + ": original AI slot called its host interface first"); + Expect(observed.EntryMode == requestedMode && observed.AiMode == expectedMode && observed.Latch == 0 && observed.XCellBeforeDispatch == 0, + label + ": record and AI mode are stored before host callback, and Init dispatch follows it"); + Expect(Marshal.ReadByte(instance.Ai, 0x78) == 0, label + ": normal Init dispatch clears the recursion guard"); + Expect(Marshal.ReadByte(instance.Ai, 0x8a8) == 1, label + ": successful Init lookup latches after dispatch"); + Expect(ReadWord(instance.Cells, 0) == 500 && ReadWord(instance.Cells, 1) == 752 && ReadWord(instance.Cells, 2) == 0, + label + ": original Init handler writes X/Y/clan after host callback"); + Console.WriteLine("iron3d 39440 -> ai 01ae0 ({0}): entry_mode={1}, ai_mode={2}, latch={3}", label, requestedMode, expectedMode, Marshal.ReadByte(instance.Ai, 0x8a8)); + + Marshal.WriteInt32(instance.Cells, 0, unchecked((int)0xfeedface)); + refresh(record, requestedMode); + Expect(observed.Calls == 2 && Marshal.ReadInt32(instance.Ai, 0x3fc) == expectedMode, label + ": repeat still reaches interface before latch check"); + Expect(ReadWord(instance.Cells, 0) == 0xfeedface, label + ": Init latch suppresses a second event dispatch"); + } + } + + private static void RunClanRefreshMissingCase(Package package, IntPtr aiModule, RefreshClan refresh, IntPtr method) { + Event[] events = (Event[])package.Events.Clone(); + bool replaced = false; + for (int i = 0; i < events.Length; i++) { + if (Encoding.ASCII.GetString(events[i].Name) == "Init\0") { + events[i] = new Event(Encoding.ASCII.GetBytes("Missing\0"), events[i].EventWord, events[i].Instructions); + replaced = true; break; + } + } + Expect(replaced, "missing-path package contains Init to remove"); + using (Instance instance = BuildInstance(new Package(package.HandlerCount, events), 500.0f, 752.0f, 0, IntPtr.Add(aiModule, Handler19Rva))) { + ConfigureClanRefresh(instance, method); + IntPtr record = PrepareClanRecord(instance, 1); + CurrentWarmStartObservation = new WarmStartObservation { Record = record, Ai = instance.Ai, Cells = instance.Cells }; + refresh(record, 2); + Expect(Marshal.ReadByte(instance.Ai, 0x8a8) == 0 && CurrentWarmStartObservation.Calls == 1, "missing Init leaves the native latch clear after the host callback"); + Expect(ReadWord(instance.Cells, 0) == 0 && ReadWord(instance.Cells, 1) == 0 && ReadWord(instance.Cells, 2) == 0, "missing Init has no Handler(19) writes"); + } + } + + private static void RunClanRefreshBusyCase(Package package, IntPtr aiModule, RefreshClan refresh, IntPtr method) { + using (Instance instance = BuildInstance(package, 500.0f, 752.0f, 0, IntPtr.Add(aiModule, Handler19Rva))) { + ConfigureClanRefresh(instance, method); + Marshal.WriteByte(instance.Ai, 0x78, 1); + IntPtr record = PrepareClanRecord(instance, 1); + CurrentWarmStartObservation = new WarmStartObservation { Record = record, Ai = instance.Ai, Cells = instance.Cells }; + refresh(record, 2); + Expect(CurrentWarmStartObservation.Calls == 1 && ReadWord(instance.Cells, 0) == 0, "busy recursion guard suppresses Init dispatch"); + Expect(Marshal.ReadByte(instance.Ai, 0x78) == 1 && Marshal.ReadByte(instance.Ai, 0x8a8) == 1, "found Init latches even when the preexisting busy guard suppresses dispatch"); + Marshal.WriteByte(instance.Ai, 0x78, 0); + Marshal.WriteInt32(instance.Cells, 0, unchecked((int)0xfeedface)); + refresh(record, 2); + Expect(CurrentWarmStartObservation.Calls == 2 && Marshal.ReadByte(instance.Ai, 0x78) == 0 && Marshal.ReadByte(instance.Ai, 0x8a8) == 1, + "clearing the busy guard cannot bypass the already-set Init latch"); + Expect(ReadWord(instance.Cells, 0) == 0xfeedface, "busy-path latch suppresses a later Init dispatch after the guard clears"); + } + } + + private static void RunClanRefreshNullAiCase(RefreshClan refresh) { + IntPtr record = Marshal.AllocHGlobal(0x68); + try { + Zero(record, 0x68); + Marshal.WriteInt32(record, 0x0c, 1); + Marshal.WriteInt32(record, 0x18, -1); + refresh(record, 2); + Expect(Marshal.ReadInt32(record, 0x18) == 2, "null AI still stores the requested record mode"); + } + finally { Marshal.FreeHGlobal(record); } + } + + private static IntPtr ConfigureClanRefresh(Instance instance, IntPtr method) { + IntPtr vtable = instance.Allocate(0x18); + IntPtr host = instance.Allocate(4); + IntPtr hostVtable = instance.Allocate(0x30); + Marshal.WriteInt32(vtable, 0x14, method.ToInt32()); + Marshal.WriteInt32(instance.Ai, 0, vtable.ToInt32()); + Marshal.WriteInt32(host, 0, hostVtable.ToInt32()); + Marshal.WriteInt32(hostVtable, 0x2c, Marshal.GetFunctionPointerForDelegate(WarmStartRecorder).ToInt32()); + Marshal.WriteInt32(instance.Ai, 0x438, host.ToInt32()); + Marshal.WriteByte(instance.Ai, 0x78, 0); + Marshal.WriteByte(instance.Ai, 0x8a8, 0); + return host; + } + + private static IntPtr PrepareClanRecord(Instance instance, int forceFlag) { + IntPtr record = instance.Allocate(0x68); + Marshal.WriteInt32(record, 0x0c, forceFlag); + Marshal.WriteInt32(record, 0x50, instance.Ai.ToInt32()); + return record; + } + 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); |
