aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-runtime
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-10-20 04:15:11 +0300
committerValentin Popov <valentin@popov.link>2026-10-20 04:15:11 +0300
commit89a0d1da6180767dd20fccc47227580b83f9a874 (patch)
tree8daaa0d1ef4061084b998be09c33d0affaa85172 /crates/fparkan-runtime
parent31fd9d2ea9b7fb01a952eef38a2af31eceebbe86 (diff)
downloadfparkan-89a0d1da6180767dd20fccc47227580b83f9a874.tar.xz
fparkan-89a0d1da6180767dd20fccc47227580b83f9a874.zip
fix(runtime): warm behavior before mission Init
Diffstat (limited to 'crates/fparkan-runtime')
-rw-r--r--crates/fparkan-runtime/src/lib.rs427
-rw-r--r--crates/fparkan-runtime/tests/behavior_warm_start_native_oracle.cs164
-rw-r--r--crates/fparkan-runtime/tests/handler19_init_native_oracle.cs151
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);