fix(runtime): warm behavior before mission Init
Docs Deploy / Build and Deploy MkDocs (push) Successful in 40s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 40s
This commit is contained in:
6 files changed
+866
-48
No files matched your search
@@ -33,6 +33,7 @@ pub use fparkan_assets::MissionAssets;
|
|||||||
|
|
||||||
const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256;
|
const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256;
|
||||||
const FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var";
|
const FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var";
|
||||||
|
const INITIAL_BEHAVIOR_RNG_STATE: u32 = 1;
|
||||||
|
|
||||||
/// Injectable engine services used by composition roots.
|
/// Injectable engine services used by composition roots.
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
@@ -168,6 +169,37 @@ pub struct MissionScriptBundle {
|
|||||||
pub package: ScriptPackage,
|
pub package: ScriptPackage,
|
||||||
/// Outcome of the restricted first-`Init` resolution for this bundle.
|
/// Outcome of the restricted first-`Init` resolution for this bundle.
|
||||||
pub init_execution: MissionScriptInitExecution,
|
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.
|
/// Numeric script defaults selected for the mission's first script bundle.
|
||||||
@@ -381,6 +413,7 @@ pub struct Engine {
|
|||||||
services: EngineServices,
|
services: EngineServices,
|
||||||
world: World,
|
world: World,
|
||||||
loaded: Option<LoadedMissionState>,
|
loaded: Option<LoadedMissionState>,
|
||||||
|
behavior_rng_state: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LoadedMissionState {
|
struct LoadedMissionState {
|
||||||
@@ -665,22 +698,35 @@ pub fn create(services: EngineServices) -> Result<Engine, EngineError> {
|
|||||||
services,
|
services,
|
||||||
world: new_world(WorldConfig),
|
world: new_world(WorldConfig),
|
||||||
loaded: None,
|
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
|
/// # 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
|
/// invalid, required files cannot be read, disk formats fail validation, terrain
|
||||||
/// runtime data cannot be built, prototype graph roots do not resolve, or
|
/// runtime data cannot be built, prototype graph roots do not resolve, object
|
||||||
/// object registration fails.
|
/// registration fails, or a full mission's first `Init` contains an unsupported
|
||||||
|
/// action form.
|
||||||
pub fn load_mission(
|
pub fn load_mission(
|
||||||
engine: &mut Engine,
|
engine: &mut Engine,
|
||||||
request: MissionRequest,
|
request: MissionRequest,
|
||||||
|
queue_time_ms: u32,
|
||||||
) -> Result<LoadedMission, EngineError> {
|
) -> 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.
|
/// Loads a mission using the bounded static-preview asset scope.
|
||||||
@@ -706,6 +752,7 @@ pub fn load_mission_static_preview(
|
|||||||
asset_scope: MissionAssetScope::FirstMeshPreview,
|
asset_scope: MissionAssetScope::FirstMeshPreview,
|
||||||
..MissionLoadOptions::default()
|
..MissionLoadOptions::default()
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,6 +776,7 @@ pub fn load_mission_static_preview_roots(
|
|||||||
asset_scope: MissionAssetScope::PreviewRoots(root_count),
|
asset_scope: MissionAssetScope::PreviewRoots(root_count),
|
||||||
..MissionLoadOptions::default()
|
..MissionLoadOptions::default()
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,6 +784,7 @@ fn load_mission_with_options(
|
|||||||
engine: &mut Engine,
|
engine: &mut Engine,
|
||||||
request: MissionRequest,
|
request: MissionRequest,
|
||||||
options: MissionLoadOptions,
|
options: MissionLoadOptions,
|
||||||
|
queue_time_ms: Option<u32>,
|
||||||
) -> Result<LoadedMission, EngineError> {
|
) -> Result<LoadedMission, EngineError> {
|
||||||
let vfs = engine.services.vfs.clone().ok_or(EngineError::MissingVfs)?;
|
let vfs = engine.services.vfs.clone().ok_or(EngineError::MissingVfs)?;
|
||||||
let mission_path = normalize_engine_path("mission", &request.key)?;
|
let mission_path = normalize_engine_path("mission", &request.key)?;
|
||||||
@@ -786,7 +835,7 @@ fn load_mission_with_options(
|
|||||||
source,
|
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(
|
let repository = CachedResourceRepository::with_payload_cache_budget(
|
||||||
vfs.clone(),
|
vfs.clone(),
|
||||||
MISSION_DECODED_PAYLOAD_CACHE_ENTRIES,
|
MISSION_DECODED_PAYLOAD_CACHE_ENTRIES,
|
||||||
@@ -902,6 +951,20 @@ fn load_mission_with_options(
|
|||||||
registered_objects += 1;
|
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 {
|
let summary = LoadedMission {
|
||||||
key: request.key,
|
key: request.key,
|
||||||
path_count: mission.paths.len(),
|
path_count: mission.paths.len(),
|
||||||
@@ -951,6 +1014,7 @@ fn load_mission_with_options(
|
|||||||
};
|
};
|
||||||
|
|
||||||
engine.world = new_runtime_world;
|
engine.world = new_runtime_world;
|
||||||
|
engine.behavior_rng_state = next_behavior_rng_state;
|
||||||
engine.loaded = Some(LoadedMissionState {
|
engine.loaded = Some(LoadedMissionState {
|
||||||
summary: summary.clone(),
|
summary: summary.clone(),
|
||||||
mission,
|
mission,
|
||||||
@@ -1272,14 +1336,17 @@ fn load_mission_scripts(
|
|||||||
script_path: normalized.as_str().to_string(),
|
script_path: normalized.as_str().to_string(),
|
||||||
package,
|
package,
|
||||||
init_execution: MissionScriptInitExecution::Missing,
|
init_execution: MissionScriptInitExecution::Missing,
|
||||||
|
behavior_warm_start: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let varset = first_base_path
|
let varset = first_base_path
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|base_path| load_script_varset(vfs, base_path))
|
.map(|base_path| load_script_varset(vfs, base_path))
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
let init_states = match &varset {
|
let (init_states, varset_states) = if asset_scope == MissionAssetScope::Full {
|
||||||
Some(varset) => resolve_handler19_init_states(
|
(Vec::new(), Vec::new())
|
||||||
|
} else if let Some(varset) = &varset {
|
||||||
|
let init_states = resolve_handler19_init_states(
|
||||||
&mission
|
&mission
|
||||||
.clans
|
.clans
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1288,12 +1355,11 @@ fn load_mission_scripts(
|
|||||||
&mut bundles,
|
&mut bundles,
|
||||||
varset,
|
varset,
|
||||||
asset_scope,
|
asset_scope,
|
||||||
)?,
|
)?;
|
||||||
None => Vec::new(),
|
let varset_states = materialize_script_varset_states(&bundles, varset, &init_states)?;
|
||||||
};
|
(init_states, varset_states)
|
||||||
let varset_states = match &varset {
|
} else {
|
||||||
Some(varset) => materialize_script_varset_states(&bundles, varset, &init_states)?,
|
(Vec::new(), Vec::new())
|
||||||
None => Vec::new(),
|
|
||||||
};
|
};
|
||||||
Ok(LoadedMissionScripts {
|
Ok(LoadedMissionScripts {
|
||||||
bundles,
|
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(
|
fn materialize_script_varset_states(
|
||||||
bundles: &[MissionScriptBundle],
|
bundles: &[MissionScriptBundle],
|
||||||
varset: &MissionScriptVarSet,
|
varset: &MissionScriptVarSet,
|
||||||
@@ -1555,6 +1661,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]
|
#[test]
|
||||||
fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() {
|
fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() {
|
||||||
let mut engine = create(EngineServices::default()).expect("engine");
|
let mut engine = create(EngineServices::default()).expect("engine");
|
||||||
@@ -1565,6 +1799,7 @@ mod tests {
|
|||||||
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
||||||
mode: MissionMode::SingleMission,
|
mode: MissionMode::SingleMission,
|
||||||
},
|
},
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
.expect_err("missing VFS");
|
.expect_err("missing VFS");
|
||||||
assert!(matches!(err, EngineError::MissingVfs));
|
assert!(matches!(err, EngineError::MissingVfs));
|
||||||
@@ -1651,6 +1886,7 @@ mod tests {
|
|||||||
raw: Arc::from(&b""[..]),
|
raw: Arc::from(&b""[..]),
|
||||||
},
|
},
|
||||||
init_execution: MissionScriptInitExecution::Missing,
|
init_execution: MissionScriptInitExecution::Missing,
|
||||||
|
behavior_warm_start: None,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let states = resolve_handler19_init_states(
|
let states = resolve_handler19_init_states(
|
||||||
@@ -1747,6 +1983,7 @@ mod tests {
|
|||||||
script_path: "MISSIONS/SCRIPTS/default.scr".to_string(),
|
script_path: "MISSIONS/SCRIPTS/default.scr".to_string(),
|
||||||
package: package.clone(),
|
package: package.clone(),
|
||||||
init_execution: MissionScriptInitExecution::Missing,
|
init_execution: MissionScriptInitExecution::Missing,
|
||||||
|
behavior_warm_start: None,
|
||||||
};
|
};
|
||||||
let anchors = [[500.0, 752.0]];
|
let anchors = [[500.0, 752.0]];
|
||||||
let mut preview_bundles = vec![bundle()];
|
let mut preview_bundles = vec![bundle()];
|
||||||
@@ -2002,6 +2239,7 @@ mod tests {
|
|||||||
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
||||||
mode: MissionMode::AutoDemo,
|
mode: MissionMode::AutoDemo,
|
||||||
},
|
},
|
||||||
|
100_000,
|
||||||
)
|
)
|
||||||
.expect("load licensed AutoDemo");
|
.expect("load licensed AutoDemo");
|
||||||
|
|
||||||
@@ -2022,6 +2260,18 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.all(|bundle| bundle.init_execution
|
.all(|bundle| bundle.init_execution
|
||||||
== MissionScriptInitExecution::Executed { event_index: 0 }));
|
== 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 varsets = loaded_mission_script_varset_states(&engine).expect("per-clan varsets");
|
||||||
let clan_zero = varsets
|
let clan_zero = varsets
|
||||||
@@ -2067,6 +2317,10 @@ mod tests {
|
|||||||
.expect("load licensed campaign preview");
|
.expect("load licensed campaign preview");
|
||||||
|
|
||||||
let bundles = loaded_mission_script_bundles(&engine).expect("script bundles");
|
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
|
let unsupported = bundles
|
||||||
.iter()
|
.iter()
|
||||||
.find(|bundle| {
|
.find(|bundle| {
|
||||||
@@ -2211,6 +2465,7 @@ mod tests {
|
|||||||
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
||||||
mode: MissionMode::SingleMission,
|
mode: MissionMode::SingleMission,
|
||||||
},
|
},
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
.expect("load mission");
|
.expect("load mission");
|
||||||
|
|
||||||
@@ -2283,7 +2538,7 @@ mod tests {
|
|||||||
});
|
});
|
||||||
let mut engine = create(EngineServices::new(vfs)).expect("engine");
|
let mut engine = create(EngineServices::new(vfs)).expect("engine");
|
||||||
let before = step_headless(&mut engine, InputSnapshot).expect("before");
|
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 {
|
match denied {
|
||||||
DenyRule::Suffix("Land.map") => assert!(matches!(err, EngineError::Vfs { .. })),
|
DenyRule::Suffix("Land.map") => assert!(matches!(err, EngineError::Vfs { .. })),
|
||||||
DenyRule::Suffix("objects.rlb") => {
|
DenyRule::Suffix("objects.rlb") => {
|
||||||
@@ -2315,6 +2570,7 @@ mod tests {
|
|||||||
fail_after_registered_objects: Some(1),
|
fail_after_registered_objects: Some(1),
|
||||||
..MissionLoadOptions::default()
|
..MissionLoadOptions::default()
|
||||||
},
|
},
|
||||||
|
Some(0),
|
||||||
)
|
)
|
||||||
.expect_err("forced registration failure");
|
.expect_err("forced registration failure");
|
||||||
|
|
||||||
@@ -2331,6 +2587,73 @@ mod tests {
|
|||||||
assert_eq!(before.snapshot.objects, after.snapshot.objects);
|
assert_eq!(before.snapshot.objects, after.snapshot.objects);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
#[ignore = "requires licensed corpus"]
|
#[ignore = "requires licensed corpus"]
|
||||||
fn selected_is_and_is2_missions_execute_10000_deterministic_ticks() {
|
fn selected_is_and_is2_missions_execute_10000_deterministic_ticks() {
|
||||||
@@ -2381,6 +2704,7 @@ mod tests {
|
|||||||
key: case.1.to_string(),
|
key: case.1.to_string(),
|
||||||
mode: MissionMode::SingleMission,
|
mode: MissionMode::SingleMission,
|
||||||
},
|
},
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
.expect("load mission");
|
.expect("load mission");
|
||||||
let drafts = loaded_mission_object_drafts(&engine).expect("object drafts");
|
let drafts = loaded_mission_object_drafts(&engine).expect("object drafts");
|
||||||
@@ -2494,6 +2818,7 @@ mod tests {
|
|||||||
key: case.mission.to_string(),
|
key: case.mission.to_string(),
|
||||||
mode: MissionMode::SingleMission,
|
mode: MissionMode::SingleMission,
|
||||||
},
|
},
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
.expect("load selected mission");
|
.expect("load selected mission");
|
||||||
assert_eq!(loaded.object_count, case.object_count);
|
assert_eq!(loaded.object_count, case.object_count);
|
||||||
@@ -2523,6 +2848,7 @@ mod tests {
|
|||||||
key: mission,
|
key: mission,
|
||||||
mode: MissionMode::SingleMission,
|
mode: MissionMode::SingleMission,
|
||||||
},
|
},
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
.expect("load mission foundation");
|
.expect("load mission foundation");
|
||||||
assert_eq!(loaded.object_count, loaded.registered_objects);
|
assert_eq!(loaded.object_count, loaded.registered_objects);
|
||||||
@@ -2629,6 +2955,39 @@ mod tests {
|
|||||||
root
|
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)]
|
#[derive(Clone, Copy)]
|
||||||
enum DenyRule {
|
enum DenyRule {
|
||||||
Suffix(&'static str),
|
Suffix(&'static str),
|
||||||
@@ -2639,6 +2998,44 @@ mod tests {
|
|||||||
denied: DenyRule,
|
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 {
|
impl DenyVfs {
|
||||||
fn denied(&self, path: &NormalizedPath) -> bool {
|
fn denied(&self, path: &NormalizedPath) -> bool {
|
||||||
match self.denied {
|
match self.denied {
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -8,10 +8,13 @@ using System.Text;
|
|||||||
internal static class Handler19DispatchOracle {
|
internal static class Handler19DispatchOracle {
|
||||||
private const uint LoadWithAlteredSearchPath = 0x00000008;
|
private const uint LoadWithAlteredSearchPath = 0x00000008;
|
||||||
private const string ExpectedAiSha256 = "c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc";
|
private const string ExpectedAiSha256 = "c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc";
|
||||||
|
private const string ExpectedIron3dSha256 = "d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa";
|
||||||
private const string ExpectedScriptSha256 = "db1c6fde57aff43181619f19e19e1cacf7301ebb859e8cb883db6b4b7181eced";
|
private const string ExpectedScriptSha256 = "db1c6fde57aff43181619f19e19e1cacf7301ebb859e8cb883db6b4b7181eced";
|
||||||
private const int EventLookupRva = 0x11f40;
|
private const int EventLookupRva = 0x11f40;
|
||||||
private const int EventDispatchRva = 0x11f10;
|
private const int EventDispatchRva = 0x11f10;
|
||||||
private const int Handler19Rva = 0x0aa38;
|
private const int Handler19Rva = 0x0aa38;
|
||||||
|
private const int AiWarmStartRva = 0x01ae0;
|
||||||
|
private const int Iron3dClanRefreshRva = 0x39440;
|
||||||
private const int EventStride = 0x14;
|
private const int EventStride = 0x14;
|
||||||
private const int InstructionStride = 0x20;
|
private const int InstructionStride = 0x20;
|
||||||
private const int VarSetStride = 0x30;
|
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 DispatchEvent(IntPtr vm, int eventIndex);
|
||||||
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate uint ReadVariable(IntPtr record);
|
[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 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 {
|
private sealed class Instruction {
|
||||||
public readonly uint[] Words;
|
public readonly uint[] Words;
|
||||||
@@ -59,11 +88,13 @@ internal static class Handler19DispatchOracle {
|
|||||||
private static int Main() {
|
private static int Main() {
|
||||||
if (IntPtr.Size != 4) { Console.Error.WriteLine("Build with /platform:x86."); return 2; }
|
if (IntPtr.Size != 4) { Console.Error.WriteLine("Build with /platform:x86."); return 2; }
|
||||||
string aiPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_AI_DLL");
|
string aiPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_AI_DLL");
|
||||||
|
string iron3dPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_IRON3D_DLL");
|
||||||
string scriptPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_DEFAULT_SCR");
|
string scriptPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_DEFAULT_SCR");
|
||||||
if (String.IsNullOrEmpty(aiPath) || !File.Exists(aiPath) || String.IsNullOrEmpty(scriptPath) || !File.Exists(scriptPath)) {
|
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 and FPARKAN_ORIGINAL_DEFAULT_SCR to the installed GOG files."); return 3;
|
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(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; }
|
if (Hash(scriptPath) != ExpectedScriptSha256) { Console.Error.WriteLine("Unexpected default.scr SHA-256: " + Hash(scriptPath)); return 5; }
|
||||||
Package package = ReadPackage(scriptPath);
|
Package package = ReadPackage(scriptPath);
|
||||||
if (package.HandlerCount != 73 || package.Events.Length != 9) throw new InvalidDataException("Unexpected default.scr header.");
|
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, 500.2857f, 752.0f, 0, 500, 752);
|
||||||
RunInstance(package, find, dispatch, readVariable, module, 728.0f, 449.0f, 1, 728, 449);
|
RunInstance(package, find, dispatch, readVariable, module, 728.0f, 449.0f, 1, 728, 449);
|
||||||
RunDwordToHandlerRoundTrip(package, find, dispatch, readVariable, setDwordPosition, module);
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
finally { FreeLibrary(module); }
|
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) {
|
private static void VerifyEventLookupVectors(Package package, FindEvent find, DispatchEvent dispatch, IntPtr module) {
|
||||||
var duplicateEvents = new Event[package.Events.Length + 1];
|
var duplicateEvents = new Event[package.Events.Length + 1];
|
||||||
Array.Copy(package.Events, duplicateEvents, package.Events.Length);
|
Array.Copy(package.Events, duplicateEvents, package.Events.Length);
|
||||||
|
|||||||
@@ -79,8 +79,7 @@ control word процесса.
|
|||||||
`[(500.2857, 751.8217), (728.0, 449.0)]` и записи `[(500, 752, 0),
|
`[(500.2857, 751.8217), (728.0, 449.0)]` и записи `[(500, 752, 0),
|
||||||
(728, 449, 1)]`; preview кампании сохраняет package и статус `Unsupported`,
|
(728, 449, 1)]`; preview кампании сохраняет package и статус `Unsupported`,
|
||||||
не применяя частичные записи. Это подтверждает загрузчик и узкий Init resolver
|
не применяя частичные записи. Это подтверждает загрузчик и узкий Init resolver
|
||||||
Rust, но не доказывает, что штатный host вызывает `Init` при старте оригинальной
|
Rust.
|
||||||
миссии.
|
|
||||||
|
|
||||||
Нативный конструктор показывает предупреждение при координате выше `10000`, но
|
Нативный конструктор показывает предупреждение при координате выше `10000`, но
|
||||||
продолжает работу. Rust моделирует числовую запись, а не это UI-предупреждение;
|
продолжает работу. Rust моделирует числовую запись, а не это UI-предупреждение;
|
||||||
@@ -92,9 +91,44 @@ Rust, но не доказывает, что штатный host вызывае
|
|||||||
Rust исполнителя, не правило оригинального dispatcher-а. Ошибочные ссылки и
|
Rust исполнителя, не правило оригинального dispatcher-а. Ошибочные ссылки и
|
||||||
типы остаются ошибками в обоих режимах.
|
типы остаются ошибками в обоих режимах.
|
||||||
|
|
||||||
Открыты вызов `Init` при штатном старте Iron3D и проверка другой миссии с
|
Для GOG-сборки также найден статический host call path в `Init`. В
|
||||||
непустым `Init`. Не восстановлены host-ingress `Init` в игровом цикле и общий
|
`iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи
|
||||||
dispatcher остальных событий.
|
кланов и вызывает для них `FUN_100392e0`, затем вызывает `FUN_10039440` (RVA
|
||||||
|
`0x39440`). Эта функция передаёт AI callback-у пару явных аргументов через
|
||||||
|
vtable slot `+0x14`. В `ai.dll!FUN_10001ae0` (RVA `0x1ae0`) аргумент callback-а
|
||||||
|
записывается в `AI+0x3fc`; затем объект `AI+0x438` получает вызов Behavior
|
||||||
|
vslot `+0x2c`. В проверенной `Behavior.dll` (SHA-256
|
||||||
|
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`)
|
||||||
|
`FUN_1001adb0` (RVA `0x1adb0`) выполняет начальную подготовку расписания.
|
||||||
|
Метод ищет первый `Init` только при нулевом latch
|
||||||
|
`AI+0x8a8`, а при dispatch временно ставит и снимает recursion guard
|
||||||
|
`AI+0x78`. В этой ветви поэтому известны callback ingress и порядок
|
||||||
|
warm-start → `Init`, но смысл аргумента callback-а здесь не сопоставлен с Rust
|
||||||
|
`MissionMode`.
|
||||||
|
|
||||||
|
Изолированный x86 oracle вызывает исходные `iron3d.dll!FUN_10039440`,
|
||||||
|
`ai.dll!FUN_10001ae0` и script dispatcher на синтетических записях и проверяет
|
||||||
|
режим, guard, latch, Handler(19) writes и повторный вызов. Отдельный oracle
|
||||||
|
исполняет `Behavior.dll!FUN_1001adb0` с искусственно заданной очередью, сверяя
|
||||||
|
срок, шаг LCG и очистку полей. Это подтверждает ABI и локальные переходы исходных
|
||||||
|
функций, но не доказывает, что полная штатная загрузка выбранной миссии в
|
||||||
|
оригинальном процессе доходит до этой ветви в том же порядке. Метод
|
||||||
|
`FUN_10001ae0` оставляет latch `+0x8a8` сброшенным, когда `Init` не найдено;
|
||||||
|
если оно найдено при уже занятом guard `+0x78`, dispatch пропускается, но latch
|
||||||
|
всё равно устанавливается. Rust пока запускает обработанный поднабор при каждой
|
||||||
|
полной загрузке и не моделирует аргумент callback-а `AI+0x3fc`, guard `+0x78` или
|
||||||
|
latch `+0x8a8`.
|
||||||
|
|
||||||
|
Rust `load_mission` принимает явный `queue_time_ms`, а не читает оригинальное
|
||||||
|
время очереди. Новый `Engine` начинает локальный Behavior RNG с seed `1` и
|
||||||
|
учитывает шаги генератора этой стартовой последовательности; не восстановлены
|
||||||
|
другие потребители исходного генератора, поэтому равенство полной случайной
|
||||||
|
последовательности не утверждается. Начальная фаза выполняется после регистрации
|
||||||
|
объектов в создаваемом мире Rust;
|
||||||
|
это интеграционный выбор runtime, а не подтверждённая эквивалентность всех
|
||||||
|
host construction phases. Не проверена другая лицензированная миссия с
|
||||||
|
непустым `Init`, не восстановлены игровой tick Behavior и общий dispatcher
|
||||||
|
остальных событий.
|
||||||
|
|
||||||
Для `Handler(30)` отдельно подтверждены обратный вызов `iron3d.dll` RVA
|
Для `Handler(30)` отдельно подтверждены обратный вызов `iron3d.dll` RVA
|
||||||
`0x611d0` (SHA-256 `d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`),
|
`0x611d0` (SHA-256 `d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`),
|
||||||
|
|||||||
@@ -33,12 +33,13 @@ consumer-inferred.
|
|||||||
|
|
||||||
## Безопасная модель исполнения
|
## Безопасная модель исполнения
|
||||||
|
|
||||||
Новая VM разделяет immutable package (bytecode, symbols, events, constants),
|
Целевая VM должна разделять immutable package (bytecode, symbols, events,
|
||||||
per-mission variables/timers/frames, bindings logical-name/ObjectId/clan/
|
constants), per-mission variables/timers/frames, bindings
|
||||||
research key и typed commands к World3D/Behavior/UI/campaign. После varset
|
logical-name/ObjectId/clan/research key и typed commands к
|
||||||
defaults и bindings она dispatches Init/start, на каждом tick обновляет timers,
|
World3D/Behavior/UI/campaign. После varset defaults и bindings она будет
|
||||||
ставит готовые events в стабильную очередь и исполняет bounded instruction
|
запускать Init/start, обновлять timers на каждом tick, ставить готовые events
|
||||||
budget. Опасное удаление идёт через World3D queue и общий deferred lifecycle.
|
в стабильную очередь и исполнять bounded instruction budget. Опасное удаление
|
||||||
|
должно идти через World3D queue и общий deferred lifecycle.
|
||||||
|
|
||||||
До восстановления opcode table package mode читает header/strings/symbols/
|
До восстановления opcode table package mode читает header/strings/symbols/
|
||||||
event offsets/raw bytecode losslessly. Статический анализ уже выделил отдельный
|
event offsets/raw bytecode losslessly. Статический анализ уже выделил отдельный
|
||||||
@@ -267,11 +268,14 @@ scheduler path внутри запущенной игры.
|
|||||||
|
|
||||||
На границе mission runtime выбранный TMA clan `first_resource`
|
На границе mission runtime выбранный TMA clan `first_resource`
|
||||||
материализуется как отдельный `MissionScriptBundle`: loader нормализует
|
материализуется как отдельный `MissionScriptBundle`: loader нормализует
|
||||||
`<base>.scr`, декодирует его тем же bounded reader-ом и публикует immutable
|
`<base>.scr`, декодирует его тем же bounded reader-ом и сохраняет clan
|
||||||
package вместе с clan provenance. Для первого события `Init` runtime исполняет
|
provenance. Полная загрузка получает от вызывающей стороны значение
|
||||||
только подтверждённый фрагмент dispatcher-а с `Handler(19)`; остальные события
|
32-битного времени очереди, затем для каждого bundle вычисляет начальное
|
||||||
пока остаются только прочитанными данными. Ошибка чтения по-прежнему сохраняет
|
состояние Behavior и пробует выполнить только восстановленный поднабор первого
|
||||||
transactional rollback mission loader-а.
|
`Init` с `Handler(19)`. Остальные события пока остаются прочитанными данными.
|
||||||
|
Статический просмотр не запускает warm-start Behavior, но сохраняет результат
|
||||||
|
поддержанных `Handler(19)` writes и создаёт ячейки varset; при неподдержанной
|
||||||
|
инструкции оставляет default-значения и статус `Unsupported`.
|
||||||
|
|
||||||
TMA properties остаются four raw `u32` words плюс имя, пока consumer/schema не
|
TMA properties остаются four raw `u32` words плюс имя, пока consumer/schema не
|
||||||
задаст тип (integer/float bits/ObjectId/enum/fixed-point/index). В том числе
|
задаст тип (integer/float bits/ObjectId/enum/fixed-point/index). В том числе
|
||||||
@@ -359,34 +363,78 @@ nearest-even `FISTP qword`, младший DWORD, преобразование D
|
|||||||
Это правила ограниченного Rust-исполнителя, а не обнаруженное поведение
|
Это правила ограниченного Rust-исполнителя, а не обнаруженное поведение
|
||||||
оригинального dispatcher-а.
|
оригинального dispatcher-а.
|
||||||
|
|
||||||
Для проверки использованы два x86 helper-а на синтетической памяти. Первый
|
### Первый вызов Behavior и сценарного `Init`
|
||||||
вызывает оригинальные `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. Первая
|
В той же GOG-сборке `Behavior.dll` (SHA-256
|
||||||
вызывает оригинальный caller `iron3d.dll`; вторая — original dispatcher, setter
|
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) функция
|
||||||
и getter `ai.dll`:
|
`FUN_1001adb0` (RVA `0x1adb0`) обновляет один Behavior distributor. Она получает
|
||||||
|
текущее время через `GetQueue` и queue vslot `+0x38`. Если distributor `+0x10`
|
||||||
|
равен нулю, функция записывает туда текущее время очереди, считает начальный
|
||||||
|
deadline `+0x14` по формуле `время + 3*64 + jitter`, где `jitter` лежит в
|
||||||
|
`0..63` и получен одним шагом глобального MSVCRT LCG. В этой ветви она также
|
||||||
|
обнуляет пять слов `+0x30..+0x40`. При повторном вызове до deadline ненулевой
|
||||||
|
`+0x10` оставляет deadline прежним, не расходует новое случайное число и не
|
||||||
|
очищает эти слова. Поведение после истечения deadline этим опытом не проверено.
|
||||||
|
Если само время очереди равно нулю, `+0x10` остаётся нулевым, и повторный вызов
|
||||||
|
снова проходит ветвь начальной подготовки.
|
||||||
|
|
||||||
|
Путь host callback в проверенной паре DLL теперь статически известен. В
|
||||||
|
`iron3d.dll` функция `FUN_1005ff40` (RVA `0x5ff40`) сначала вызывает
|
||||||
|
`FUN_100392e0` для TMA clan records, а затем для записей вызывает
|
||||||
|
`FUN_10039440` (RVA `0x39440`). Последняя передаёт AI callback-у аргументы через
|
||||||
|
vtable slot `+0x14`. Метод `ai.dll!FUN_10001ae0` (RVA `0x1ae0`) сохраняет это
|
||||||
|
слово аргумента в AI `+0x3fc`, сначала обновляет Behavior через объект `AI+0x438`
|
||||||
|
и его vslot `+0x2c`, а затем, если latch `AI+0x8a8` ещё сброшен, находит первый
|
||||||
|
`Init`. При отсутствии события latch остаётся нулевым. Если событие найдено,
|
||||||
|
метод запускает dispatcher только при нулевом recursion guard `AI+0x78`,
|
||||||
|
временно ставит guard и снимает его после dispatch; затем ставит latch, даже
|
||||||
|
если занятый guard пропустил dispatch. Это определяет одну штатную
|
||||||
|
callback-точку входа и порядок внутри неё, но не раскрывает всех смыслов
|
||||||
|
входного слова аргумента и не подменяет динамическую трассу полного запуска игры.
|
||||||
|
|
||||||
|
`fparkan-runtime::load_mission` получает значение времени очереди явным
|
||||||
|
аргументом `queue_time_ms`; Rust не читает оригинальную очередь. Для полной
|
||||||
|
загрузки Rust после регистрации объектов запускает последовательность
|
||||||
|
подготовки Behavior → ограниченный `Init` по каждому bundle. Путь использует
|
||||||
|
локальный RNG engine, начиная с состояния `1`; его шаги LCG общие между
|
||||||
|
bundle, но остальные потребители исходного глобального RNG не моделируются.
|
||||||
|
В Rust также нет значения аргумента в `AI+0x3fc`, recursion guard `AI+0x78`
|
||||||
|
или одноразового latch `AI+0x8a8`. Поэтому это связанный начальный поднабор, а
|
||||||
|
не полная активация SuperAI и не обновление Behavior на игровых тиках. При
|
||||||
|
ошибке ограниченного `Init` Rust отклоняет полную загрузку и сохраняет прежний
|
||||||
|
мир и состояние генератора; такая политика отката принадлежит Rust loader-у.
|
||||||
|
|
||||||
|
Для проверки использованы три x86 helper-а на синтетической памяти. Caller
|
||||||
|
oracle вызывает оригинальные `iron3d.dll` entry points и проверяет преобразование
|
||||||
|
TMA-координат в аргументы `CreateSuperAI`. AI oracle проходит оригинальный
|
||||||
|
`iron3d.dll!FUN_10039440` → `ai.dll!FUN_10001ae0` → реальный `Init` dispatcher;
|
||||||
|
также проверяет оба AutoDemo-набора, повторный/missing/busy callback и связку
|
||||||
|
`FUN_10005c70` → Handler(19). Для входных DWORD `(0xffffffff, 10001)` поля стали
|
||||||
|
`(2^32, 10001.0)`, а varset получил `(0, 10001, 2)`. Behavior oracle вызывает
|
||||||
|
`FUN_1001adb0` с искусственно заданной очередью и проверяет шаг генератора,
|
||||||
|
срок, очистку и повторный вызов. Эти проверки исполняют оригинальные DLL-функции на заданных
|
||||||
|
структурах, но не являются трассой штатного старта или последующего игрового
|
||||||
|
цикла.
|
||||||
|
|
||||||
|
Проверки можно повторить из корня репозитория в Windows PowerShell:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:FPARKAN_ORIGINAL_IRON3D_DLL = 'C:\GOG Games\Parkan - Iron Strategy\iron3d.dll'
|
$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_AI_DLL = 'C:\GOG Games\Parkan - Iron Strategy\ai.dll'
|
||||||
|
$env:FPARKAN_ORIGINAL_BEHAVIOR_DLL = 'C:\GOG Games\Parkan - Iron Strategy\Behavior.dll'
|
||||||
$env:FPARKAN_ORIGINAL_DEFAULT_SCR = 'C:\GOG Games\Parkan - Iron Strategy\MISSIONS\SCRIPTS\default.scr'
|
$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
|
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
|
& "$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 }
|
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
|
& "$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 }
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
& "$env:WINDIR\Microsoft.NET\Framework\v4.0.30319\csc.exe" /nologo /platform:x86 /out:target\handler19-native-oracles\BehaviorWarmStartOracle.exe crates\fparkan-runtime\tests\behavior_warm_start_native_oracle.cs
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
& .\target\handler19-native-oracles\AnchorOracle.exe
|
& .\target\handler19-native-oracles\AnchorOracle.exe
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
& .\target\handler19-native-oracles\InitOracle.exe
|
& .\target\handler19-native-oracles\InitOracle.exe
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
& .\target\handler19-native-oracles\BehaviorWarmStartOracle.exe
|
||||||
exit $LASTEXITCODE
|
exit $LASTEXITCODE
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,36 @@ Rust исполняет только подтверждённую форму э
|
|||||||
событие считается исполненным без записей. Остальные события пока не образуют
|
событие считается исполненным без записей. Остальные события пока не образуют
|
||||||
исполняемую VM.
|
исполняемую VM.
|
||||||
|
|
||||||
|
### Первый запуск сценария
|
||||||
|
|
||||||
|
До регулярной работы Behavior настраивает расписание своего первого шага. Он
|
||||||
|
берёт текущее 32-битное время игровой очереди и назначает проверку через три
|
||||||
|
интервала по 64 мс, добавляя небольшую случайную задержку от 0 до 63 мс. Эта
|
||||||
|
задержка нужна каждому экземпляру, но случайные числа берутся из общего
|
||||||
|
генератора, поэтому порядок запуска кланов влияет на их значения. При первой
|
||||||
|
подготовке controller также очищает несколько внутренних слов. В проверенном
|
||||||
|
повторном вызове до назначенного срока ненулевое сохранённое время оставило
|
||||||
|
срок прежним, не потребовало нового случайного числа и не очистило эти слова;
|
||||||
|
поведение после срока пока неизвестно.
|
||||||
|
|
||||||
|
В оригинале вызов идёт через обратный вызов движка: Iron3D сначала создаёт
|
||||||
|
SuperAI для записей кланов, затем обновляет Behavior и ищет `Init` для
|
||||||
|
соответствующего AI. Если `Init` не найдено, одноразовая метка остаётся
|
||||||
|
выключенной. Если оно найдено, диспетчер запускается только при свободной защите
|
||||||
|
от повторного входа; при занятой защите вызов пропускается, но одноразовая метка
|
||||||
|
всё равно устанавливается. В полной загрузке Rust вызывающая сторона передаёт
|
||||||
|
значение времени очереди; после регистрации объектов загрузчик последовательно
|
||||||
|
подготавливает каждый сценарный пакет и выполняет только подтверждённые действия
|
||||||
|
`Handler(19)` из первого `Init`. Статический просмотр не запускает Behavior, но
|
||||||
|
может сохранить подтверждённые записи `Init` в сценарном varset.
|
||||||
|
|
||||||
|
Это полезное начало сценария, но ещё не целая игровая жизнь AI. Текущий runtime
|
||||||
|
не воспроизводит аргумент обратного вызова в `AI+0x3fc`, защиту от повторного
|
||||||
|
входа `AI+0x78`, одноразовую метку `AI+0x8a8` или обновления Behavior на игровых
|
||||||
|
тиках. Он также не подменяет очередь оригинала: вызывающая сторона сама
|
||||||
|
передаёт время, а локальный генератор Rust пока учитывает только случайные
|
||||||
|
числа этого восстановленного шага.
|
||||||
|
|
||||||
Первый часто встречающийся table entry с side effect — `Handler(2)` (176
|
Первый часто встречающийся table entry с side effect — `Handler(2)` (176
|
||||||
records в corpus). Он берёт active instruction, разрешает семь slots через
|
records в corpus). Он берёт active instruction, разрешает семь slots через
|
||||||
varset, приводит три значения к float по observed kinds `5`/`3`, затем
|
varset, приводит три значения к float по observed kinds `5`/`3`, затем
|
||||||
|
|||||||
Reference in new issue
Block a user