feat(runtime): initialize per-clan AI record slots
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m17s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m17s
This commit is contained in:
3 files changed
+489
-1
No files matched your search
@@ -44,6 +44,7 @@ pub use fparkan_assets::MissionAssets;
|
||||
pub use mesh_collision::{query_building_segment_world, BuildingMeshHit, BuildingMeshNode};
|
||||
|
||||
const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256;
|
||||
const MAX_MISSION_AI_RECORD_SLOTS: usize = 1_000_000;
|
||||
const FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var";
|
||||
const INITIAL_BEHAVIOR_RNG_STATE: u32 = 1;
|
||||
const UNIT_DAT_CONTROL_CLASS_HIGH_BIT: u32 = 0x8000_0000;
|
||||
@@ -712,6 +713,15 @@ pub struct MissionScriptBundle {
|
||||
pub behavior_warm_start: Option<BehaviorWarmStartState>,
|
||||
}
|
||||
|
||||
/// Initial native record-slot words owned by one mission clan's AI instance.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MissionAiStartupState {
|
||||
/// TMA clan index that owns this AI instance.
|
||||
pub clan_index: usize,
|
||||
/// Sentinel words appended during AI startup, in native insertion order.
|
||||
pub record_slots: Vec<u32>,
|
||||
}
|
||||
|
||||
/// One decoded research table associated with its TMA clan owner and source path.
|
||||
#[derive(Debug)]
|
||||
pub struct MissionResearchTrfTable {
|
||||
@@ -879,6 +889,7 @@ struct MissionLoadOptions {
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
struct LoadedMissionScripts {
|
||||
bundles: Vec<MissionScriptBundle>,
|
||||
ai_startup_states: Vec<MissionAiStartupState>,
|
||||
varset: Option<MissionScriptVarSet>,
|
||||
init_states: Vec<MissionScriptInitState>,
|
||||
varset_states: Vec<MissionScriptVarSetState>,
|
||||
@@ -1008,6 +1019,7 @@ struct LoadedMissionState {
|
||||
control_device_tables: Vec<MissionControlDeviceTableState>,
|
||||
behavior_control_groupings: Vec<MissionBehaviorControlGroupingState>,
|
||||
research_trf_tables: Vec<MissionResearchTrfTable>,
|
||||
ai_startup_states: Vec<MissionAiStartupState>,
|
||||
script_bundles: Vec<MissionScriptBundle>,
|
||||
script_varset: Option<MissionScriptVarSet>,
|
||||
script_init_states: Vec<MissionScriptInitState>,
|
||||
@@ -1079,6 +1091,17 @@ pub enum EngineError {
|
||||
/// Handler-specific diagnostic.
|
||||
message: String,
|
||||
},
|
||||
/// A clan's initial AI record slots exceeded the Rust allocation budget or could not be reserved.
|
||||
MissionAiRecordSlots {
|
||||
/// Owning TMA clan index.
|
||||
clan_index: usize,
|
||||
/// Raw second-resource tag interpreted as unsigned bits.
|
||||
raw_count: u32,
|
||||
/// Slots already reserved for earlier clans in this mission.
|
||||
allocated_slots: usize,
|
||||
/// Budget or allocation diagnostic.
|
||||
message: String,
|
||||
},
|
||||
/// `NRes` decode error.
|
||||
Nres {
|
||||
/// Resource path.
|
||||
@@ -1258,6 +1281,15 @@ impl std::fmt::Display for EngineError {
|
||||
clan_index,
|
||||
message,
|
||||
} => write!(f, "clan {clan_index}: script runtime failed: {message}"),
|
||||
Self::MissionAiRecordSlots {
|
||||
clan_index,
|
||||
raw_count,
|
||||
allocated_slots,
|
||||
message,
|
||||
} => write!(
|
||||
f,
|
||||
"clan {clan_index} AI initial slots (unsigned raw count {raw_count}, after {allocated_slots} mission slots): {message}"
|
||||
),
|
||||
Self::Nres { path, source } => write!(f, "{path}: {source}"),
|
||||
Self::ResearchTrf {
|
||||
clan_index,
|
||||
@@ -1318,6 +1350,7 @@ impl std::error::Error for EngineError {
|
||||
| Self::VarSet { .. }
|
||||
| Self::ScriptInit { .. }
|
||||
| Self::ScriptRuntime { .. }
|
||||
| Self::MissionAiRecordSlots { .. }
|
||||
| Self::Movement(_)
|
||||
| Self::ResearchTrfPath { .. }
|
||||
| Self::PrototypeGraph { .. }
|
||||
@@ -1844,6 +1877,7 @@ fn load_mission_with_options(
|
||||
control_device_tables,
|
||||
behavior_control_groupings,
|
||||
research_trf_tables,
|
||||
ai_startup_states: loaded_scripts.ai_startup_states,
|
||||
script_bundles: loaded_scripts.bundles,
|
||||
script_varset: loaded_scripts.varset,
|
||||
script_init_states: loaded_scripts.init_states,
|
||||
@@ -1925,6 +1959,15 @@ pub fn loaded_mission_script_bundles(engine: &Engine) -> Option<&[MissionScriptB
|
||||
.map(|state| state.script_bundles.as_slice())
|
||||
}
|
||||
|
||||
/// Returns the per-clan AI record slots initialized during full mission startup.
|
||||
#[must_use]
|
||||
pub fn loaded_mission_ai_startup_states(engine: &Engine) -> Option<&[MissionAiStartupState]> {
|
||||
engine
|
||||
.loaded
|
||||
.as_ref()
|
||||
.map(|state| state.ai_startup_states.as_slice())
|
||||
}
|
||||
|
||||
/// Returns the per-clan TRF tables loaded during full mission startup.
|
||||
#[must_use]
|
||||
pub fn loaded_mission_research_trf_tables(engine: &Engine) -> Option<&[MissionResearchTrfTable]> {
|
||||
@@ -6629,6 +6672,54 @@ fn load_mission_scripts(
|
||||
let script_bases = mission_script_bundle_bases(mission);
|
||||
let mut bundles = Vec::with_capacity(script_bases.len());
|
||||
let mut first_base_path = None;
|
||||
let mut ai_startup_states = Vec::new();
|
||||
if asset_scope == MissionAssetScope::Full {
|
||||
let mut allocated_slots = 0usize;
|
||||
for (clan_index, clan) in mission.clans.iter().enumerate() {
|
||||
let second_resource = match &clan.body {
|
||||
ClanBody::Standard {
|
||||
second_resource, ..
|
||||
}
|
||||
| ClanBody::Spatial {
|
||||
second_resource, ..
|
||||
} => second_resource,
|
||||
};
|
||||
let raw_count = second_resource.tag as u32;
|
||||
let count =
|
||||
usize::try_from(raw_count).map_err(|_| EngineError::MissionAiRecordSlots {
|
||||
clan_index,
|
||||
raw_count,
|
||||
allocated_slots,
|
||||
message: "count does not fit this platform's address space".to_string(),
|
||||
})?;
|
||||
let requested_total = allocated_slots.checked_add(count);
|
||||
if requested_total.is_none_or(|total| total > MAX_MISSION_AI_RECORD_SLOTS) {
|
||||
return Err(EngineError::MissionAiRecordSlots {
|
||||
clan_index,
|
||||
raw_count,
|
||||
allocated_slots,
|
||||
message: format!(
|
||||
"cumulative mission count exceeds the Rust allocation budget of {MAX_MISSION_AI_RECORD_SLOTS} u32 slots"
|
||||
),
|
||||
});
|
||||
}
|
||||
let mut record_slots = Vec::new();
|
||||
record_slots.try_reserve_exact(count).map_err(|source| {
|
||||
EngineError::MissionAiRecordSlots {
|
||||
clan_index,
|
||||
raw_count,
|
||||
allocated_slots,
|
||||
message: format!("could not reserve {count} u32 slots: {source}"),
|
||||
}
|
||||
})?;
|
||||
record_slots.resize(count, u32::MAX);
|
||||
allocated_slots = requested_total.expect("checked cumulative AI slot count");
|
||||
ai_startup_states.push(MissionAiStartupState {
|
||||
clan_index,
|
||||
record_slots,
|
||||
});
|
||||
}
|
||||
}
|
||||
for script_base in script_bases {
|
||||
let base_path = String::from_utf8_lossy(&script_base.path_raw).into_owned();
|
||||
let script_path = if base_path.to_ascii_lowercase().ends_with(".scr") {
|
||||
@@ -6680,6 +6771,7 @@ fn load_mission_scripts(
|
||||
};
|
||||
Ok(LoadedMissionScripts {
|
||||
bundles,
|
||||
ai_startup_states,
|
||||
varset,
|
||||
init_states,
|
||||
varset_states,
|
||||
@@ -10254,6 +10346,7 @@ mod tests {
|
||||
};
|
||||
let mut scripts = LoadedMissionScripts {
|
||||
bundles: vec![bundle(0, 18, Vec::new()), bundle(1, 19, vec![0, 1, 2])],
|
||||
ai_startup_states: Vec::new(),
|
||||
varset: Some(varset),
|
||||
..LoadedMissionScripts::default()
|
||||
};
|
||||
@@ -10283,6 +10376,193 @@ mod tests {
|
||||
assert_eq!(rng_state, 0x0029_e2c0);
|
||||
}
|
||||
|
||||
fn script_slots_test_mission(clans: &[(bool, bool, i32)]) -> MissionDocument {
|
||||
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);
|
||||
push_u32(&mut tma, 0);
|
||||
push_u32(&mut tma, 6);
|
||||
push_u32(
|
||||
&mut tma,
|
||||
u32::try_from(clans.len()).expect("clan count fits u32"),
|
||||
);
|
||||
for (clan_index, (spatial, scripted, tag)) in clans.iter().enumerate() {
|
||||
push_lp(&mut tma, b"synthetic");
|
||||
push_u32(&mut tma, u32::MAX);
|
||||
tma.extend_from_slice(&(clan_index as f32).to_le_bytes());
|
||||
tma.extend_from_slice(&0.0_f32.to_le_bytes());
|
||||
let script_path: &[u8] = if *scripted {
|
||||
b"MISSIONS/SCRIPTS/shared"
|
||||
} else {
|
||||
b""
|
||||
};
|
||||
if *spatial {
|
||||
push_u32(&mut tma, 0);
|
||||
push_lp(&mut tma, script_path);
|
||||
push_u32(&mut tma, 0);
|
||||
} else {
|
||||
push_u32(&mut tma, 1);
|
||||
push_lp(&mut tma, script_path);
|
||||
push_u32(&mut tma, 0);
|
||||
}
|
||||
push_lp(&mut tma, b"unused.trf");
|
||||
push_u32(&mut tma, *tag as u32);
|
||||
push_u32(&mut tma, 0);
|
||||
}
|
||||
push_u32(&mut tma, 10);
|
||||
push_u32(&mut tma, 0);
|
||||
push_lp(&mut tma, b"");
|
||||
push_u32(&mut tma, 0);
|
||||
push_lp(&mut tma, b"");
|
||||
push_u32(&mut tma, 1);
|
||||
push_u32(&mut tma, 0);
|
||||
decode_mission_payload(Arc::from(tma), TmaProfile::Strict).expect("synthetic mission TMA")
|
||||
}
|
||||
|
||||
fn script_slots_test_tags(mission: &MissionDocument) -> Vec<i32> {
|
||||
mission
|
||||
.clans
|
||||
.iter()
|
||||
.map(|clan| match &clan.body {
|
||||
ClanBody::Standard {
|
||||
second_resource, ..
|
||||
}
|
||||
| ClanBody::Spatial {
|
||||
second_resource, ..
|
||||
} => second_resource.tag,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn script_slots_test_vfs() -> Arc<dyn Vfs> {
|
||||
let package = encode_script_package(&ScriptPackage {
|
||||
opcode_handler_count: 73,
|
||||
events: Vec::new(),
|
||||
trailing_bytes: Vec::new(),
|
||||
raw: Arc::from(&b""[..]),
|
||||
});
|
||||
let mut files = MemoryVfs::default();
|
||||
files.insert(
|
||||
normalize_engine_path("script slots test", "MISSIONS/SCRIPTS/shared.scr")
|
||||
.expect("script path"),
|
||||
Arc::from(package.into_boxed_slice()),
|
||||
);
|
||||
files.insert(
|
||||
normalize_engine_path("script slots test", "MISSIONS/SCRIPTS/shared.var")
|
||||
.expect("varset path"),
|
||||
Arc::from(&b"VAR(DWORD, value, 0)\n"[..]),
|
||||
);
|
||||
Arc::new(files)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_mission_script_loader_builds_independent_unsigned_ai_slots_per_clan() {
|
||||
let mission = script_slots_test_mission(&[
|
||||
(false, true, 2),
|
||||
(true, true, 3),
|
||||
(false, false, 4),
|
||||
(false, true, 0),
|
||||
]);
|
||||
let raw_tags = script_slots_test_tags(&mission);
|
||||
let vfs = script_slots_test_vfs();
|
||||
|
||||
let full = load_mission_scripts(&vfs, &mission, MissionAssetScope::Full)
|
||||
.expect("Full script bundle load");
|
||||
assert_eq!(
|
||||
full.bundles
|
||||
.iter()
|
||||
.map(|bundle| bundle.clan_index)
|
||||
.collect::<Vec<_>>(),
|
||||
[0, 1, 3],
|
||||
"empty-script clans do not produce fake script bundles"
|
||||
);
|
||||
assert_eq!(
|
||||
full.ai_startup_states
|
||||
.iter()
|
||||
.map(|state| state.clan_index)
|
||||
.collect::<Vec<_>>(),
|
||||
[0, 1, 2, 3],
|
||||
"Full startup retains a state for every TMA clan"
|
||||
);
|
||||
assert_eq!(
|
||||
full.ai_startup_states
|
||||
.iter()
|
||||
.map(|state| state.record_slots.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
vec![u32::MAX; 2],
|
||||
vec![u32::MAX; 3],
|
||||
vec![u32::MAX; 4],
|
||||
Vec::new(),
|
||||
]
|
||||
);
|
||||
assert_ne!(
|
||||
full.ai_startup_states[0].record_slots.as_ptr(),
|
||||
full.ai_startup_states[1].record_slots.as_ptr(),
|
||||
"each clan AI owns a separate vector"
|
||||
);
|
||||
assert_ne!(
|
||||
full.ai_startup_states[1].record_slots.as_ptr(),
|
||||
full.ai_startup_states[2].record_slots.as_ptr(),
|
||||
"empty-script AI also owns a separate vector"
|
||||
);
|
||||
assert!(full
|
||||
.bundles
|
||||
.iter()
|
||||
.all(|bundle| bundle.init_execution == MissionScriptInitExecution::Missing));
|
||||
|
||||
let preview = load_mission_scripts(&vfs, &mission, MissionAssetScope::FirstMeshPreview)
|
||||
.expect("preview script bundle load");
|
||||
assert!(preview.ai_startup_states.is_empty());
|
||||
assert_eq!(
|
||||
preview
|
||||
.bundles
|
||||
.iter()
|
||||
.map(|bundle| bundle.clan_index)
|
||||
.collect::<Vec<_>>(),
|
||||
[0, 1, 3]
|
||||
);
|
||||
assert_eq!(
|
||||
script_slots_test_tags(&mission),
|
||||
raw_tags,
|
||||
"loading leaves the signed TMA tag bits unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_mission_script_loader_rejects_unsigned_and_cumulative_ai_slot_overflow() {
|
||||
let vfs = script_slots_test_vfs();
|
||||
|
||||
let negative_tag = script_slots_test_mission(&[(false, false, -1)]);
|
||||
assert!(matches!(
|
||||
load_mission_scripts(&vfs, &negative_tag, MissionAssetScope::Full),
|
||||
Err(EngineError::MissionAiRecordSlots {
|
||||
clan_index: 0,
|
||||
raw_count: u32::MAX,
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
let over_budget =
|
||||
script_slots_test_mission(&[(false, true, 600_000), (true, false, 400_001)]);
|
||||
assert!(matches!(
|
||||
load_mission_scripts(&vfs, &over_budget, MissionAssetScope::Full),
|
||||
Err(EngineError::MissionAiRecordSlots {
|
||||
clan_index: 1,
|
||||
raw_count: 400_001,
|
||||
allocated_slots: 600_000,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() {
|
||||
let mut engine = create(EngineServices::default()).expect("engine");
|
||||
@@ -15041,6 +15321,50 @@ mod tests {
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires licensed corpus"]
|
||||
fn licensed_single_script_bundle_load_initializes_ai_slots_before_later_init_work() {
|
||||
let root = licensed_root("IS");
|
||||
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
|
||||
let path = normalize_engine_path("mission", "MISSIONS/Single.01/data.tma")
|
||||
.expect("Single.01 path");
|
||||
let mission = decode_mission_payload(
|
||||
read_vfs(&vfs, &path).expect("Single.01 TMA"),
|
||||
TmaProfile::Strict,
|
||||
)
|
||||
.expect("decode Single.01 TMA");
|
||||
assert_eq!(script_slots_test_tags(&mission), [9, 9]);
|
||||
|
||||
let full = load_mission_scripts(&vfs, &mission, MissionAssetScope::Full)
|
||||
.expect("Full script bundle load");
|
||||
assert_eq!(
|
||||
full.ai_startup_states
|
||||
.iter()
|
||||
.map(|state| state.record_slots.len())
|
||||
.collect::<Vec<_>>(),
|
||||
[9, 9]
|
||||
);
|
||||
assert_eq!(
|
||||
full.ai_startup_states
|
||||
.iter()
|
||||
.map(|state| state.clan_index)
|
||||
.collect::<Vec<_>>(),
|
||||
[0, 1]
|
||||
);
|
||||
assert!(full
|
||||
.ai_startup_states
|
||||
.iter()
|
||||
.all(|state| { state.record_slots == [u32::MAX; 9] }));
|
||||
assert!(full
|
||||
.bundles
|
||||
.iter()
|
||||
.all(|bundle| bundle.init_execution == MissionScriptInitExecution::Missing));
|
||||
|
||||
let preview = load_mission_scripts(&vfs, &mission, MissionAssetScope::FirstMeshPreview)
|
||||
.expect("preview script bundle load");
|
||||
assert!(preview.ai_startup_states.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires licensed corpus"]
|
||||
fn licensed_single_preview_loads_each_formula_table_and_stops_at_handler43() {
|
||||
@@ -15916,6 +16240,15 @@ mod tests {
|
||||
script_bases: &[Option<&[u8]>],
|
||||
research_paths: &[&[u8]],
|
||||
trf: Option<&[u8]>,
|
||||
) -> Arc<dyn Vfs> {
|
||||
research_startup_test_vfs_with_raw_tag(script_bases, research_paths, 9, trf)
|
||||
}
|
||||
|
||||
fn research_startup_test_vfs_with_raw_tag(
|
||||
script_bases: &[Option<&[u8]>],
|
||||
research_paths: &[&[u8]],
|
||||
raw_tag: i32,
|
||||
trf: Option<&[u8]>,
|
||||
) -> Arc<dyn Vfs> {
|
||||
fn push_u32(bytes: &mut Vec<u8>, value: u32) {
|
||||
bytes.extend_from_slice(&value.to_le_bytes());
|
||||
@@ -15944,7 +16277,7 @@ mod tests {
|
||||
push_lp(&mut mission, script_base.unwrap_or_default());
|
||||
push_u32(&mut mission, 0);
|
||||
push_lp(&mut mission, research_path);
|
||||
push_u32(&mut mission, 9);
|
||||
push_u32(&mut mission, raw_tag as u32);
|
||||
push_u32(&mut mission, 0);
|
||||
}
|
||||
push_u32(&mut mission, 10);
|
||||
@@ -16000,6 +16333,100 @@ mod tests {
|
||||
Arc::new(files)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_record_slot_budget_error_keeps_loaded_mission_clock_and_rng() {
|
||||
let valid_vfs = research_startup_test_vfs_with_raw_tag(
|
||||
&[Some(b"MISSIONS/SCRIPTS/shared"), None],
|
||||
&[b"", b""],
|
||||
9,
|
||||
None,
|
||||
);
|
||||
let mut engine = create(EngineServices::new(valid_vfs)).expect("startup test engine");
|
||||
let request = || MissionRequest {
|
||||
key: "MISSIONS/TEST/data.tma".to_string(),
|
||||
mode: MissionMode::SingleMission,
|
||||
};
|
||||
load_mission(
|
||||
&mut engine,
|
||||
request(),
|
||||
MissionStartupInput {
|
||||
queue_time_ms: 100_000,
|
||||
research_creation_gate: false,
|
||||
},
|
||||
)
|
||||
.expect("initial valid full load");
|
||||
let previous_mission = loaded_mission(&engine).expect("loaded mission").clone();
|
||||
let previous_bundles = loaded_mission_script_bundles(&engine)
|
||||
.expect("loaded script bundles")
|
||||
.to_vec();
|
||||
let previous_ai_startup_states = loaded_mission_ai_startup_states(&engine)
|
||||
.expect("loaded AI startup states")
|
||||
.to_vec();
|
||||
assert_eq!(previous_ai_startup_states.len(), 2);
|
||||
assert_eq!(
|
||||
previous_ai_startup_states
|
||||
.iter()
|
||||
.map(|state| state.clan_index)
|
||||
.collect::<Vec<_>>(),
|
||||
[0, 1]
|
||||
);
|
||||
assert!(previous_ai_startup_states
|
||||
.iter()
|
||||
.all(|state| state.record_slots == [u32::MAX; 9]));
|
||||
assert_eq!(previous_bundles.len(), 1);
|
||||
assert_eq!(previous_bundles[0].clan_index, 0);
|
||||
assert_eq!(
|
||||
previous_bundles[0].behavior_warm_start,
|
||||
Some(BehaviorWarmStartState {
|
||||
first_time_ms: 100_000,
|
||||
next_deadline_ms: 100_202,
|
||||
cleared_words: [0; 5],
|
||||
})
|
||||
);
|
||||
let previous_rng = engine.behavior_rng_state;
|
||||
assert_ne!(previous_rng, INITIAL_BEHAVIOR_RNG_STATE);
|
||||
let previous_world = fparkan_world::canonical_state_hash(&engine.world);
|
||||
|
||||
engine.services.vfs = Some(research_startup_test_vfs_with_raw_tag(
|
||||
&[None, None],
|
||||
&[b"", b""],
|
||||
-1,
|
||||
None,
|
||||
));
|
||||
let error = load_mission(
|
||||
&mut engine,
|
||||
request(),
|
||||
MissionStartupInput {
|
||||
queue_time_ms: 100_500,
|
||||
research_creation_gate: false,
|
||||
},
|
||||
)
|
||||
.expect_err("negative raw tag is unsigned u32::MAX and exceeds the budget");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
EngineError::MissionAiRecordSlots {
|
||||
clan_index: 0,
|
||||
raw_count: u32::MAX,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert_eq!(loaded_mission(&engine), Some(&previous_mission));
|
||||
assert_eq!(
|
||||
loaded_mission_script_bundles(&engine),
|
||||
Some(previous_bundles.as_slice())
|
||||
);
|
||||
assert_eq!(
|
||||
loaded_mission_ai_startup_states(&engine),
|
||||
Some(previous_ai_startup_states.as_slice())
|
||||
);
|
||||
assert_eq!(engine.behavior_rng_state, previous_rng);
|
||||
assert_eq!(
|
||||
fparkan_world::canonical_state_hash(&engine.world),
|
||||
previous_world
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_load_keeps_independent_trf_tables_for_each_script_bundle_clan() {
|
||||
let vfs = research_startup_test_vfs(
|
||||
|
||||
@@ -184,6 +184,34 @@ core и `ResearchCandidateSet::refresh_selection`: последний получ
|
||||
создаёт владельца живого контекста, не передаёт реальные AI candidates и не
|
||||
выбирает результат Handler(43). Это не общий dispatcher и не загрузка игры.
|
||||
|
||||
Начальный массив слота AI `+0x38` теперь восстановлен отдельно от этого
|
||||
исследовательского пути. В `iron3d.dll` SHA-256
|
||||
`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`
|
||||
`FUN_100392e0` (RVA `0x392e0`) вызывает слот для каждого значения счётчика
|
||||
`second_resource.tag`. В `ai.dll` SHA-256
|
||||
`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` метод
|
||||
RVA `0x2010` передаёт `AI+0x83c` в `FUN_10002980` (RVA `0x2980`), где
|
||||
`FUN_100048f0` (RVA `0x48f0`) добавляет переданный DWORD. Вызывающая сторона
|
||||
повторно передаёт `0xffffffff`; эта функция не записывает значение обратно.
|
||||
Поэтому подтверждён только начальный массив sentinel-слов. Неизвестно, какие
|
||||
поздние обработчики читают эти ячейки и заменяет ли кто-либо их ID. В начальном
|
||||
массиве нет назначенных ID объектов; дальнейший смысл этих слов пока не
|
||||
установлен. Сам массив не является списком
|
||||
`BehaviorDistributorState.registered_objects` и не определяет владельца
|
||||
контекста исследований.
|
||||
|
||||
Rust хранит этот снимок как `MissionAiStartupState` в состоянии загруженной
|
||||
миссии. Полная загрузка проходит по всем TMA-кланам, включая кланы без пакета
|
||||
сценария и без пути таблицы исследований; при предпросмотре список состояний AI
|
||||
остаётся пустым. Знаковое поле `second_resource.tag` читается по сырым битам как
|
||||
`u32`. Бюджет в 1 000 000 DWORD на миссию и выделение памяти с обработкой ошибки
|
||||
защищают только память Rust; это ограничение не приписывается оригинальному
|
||||
движку. Искусственные Rust-тесты проверяют обе формы TMA-клана, индекс клана,
|
||||
независимые массивы, пустой путь сценария, предпросмотр, чтение тега как
|
||||
беззнакового числа, лимит и откат при ошибке. Проверка на оригинальных ресурсах
|
||||
для `Single.01` подтверждает состояния обоих кланов (`9/9`) до выполнения
|
||||
`Init`; это не полный запуск миссии и не исполнение исходных DLL.
|
||||
|
||||
Для GOG-сборки также найден статический host call path в `Init`. В
|
||||
`iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи
|
||||
кланов и вызывает для них `FUN_100392e0`, затем вызывает `FUN_10039440` (RVA
|
||||
|
||||
@@ -195,6 +195,39 @@ Rust исполняет подтверждённый поднабор перво
|
||||
|
||||
### Первый запуск сценария
|
||||
|
||||
Перед настройкой первого шага каждый AI получает начальный массив DWORD. В
|
||||
`iron3d.dll` SHA-256
|
||||
`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`
|
||||
`FUN_100392e0` (RVA `0x392e0`) повторяет вызов слота AI `+0x38` столько раз,
|
||||
сколько задаёт `second_resource.tag` этой строки TMA. Этот слот ведёт в
|
||||
`ai.dll` SHA-256
|
||||
`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`:
|
||||
метод RVA `0x2010` передаёт коллекцию при `AI+0x83c` в `FUN_10002980`
|
||||
(RVA `0x2980`), а она добавляет один DWORD через `FUN_100048f0` (RVA
|
||||
`0x48f0`). Каждый вызов копирует `0xffffffff`; входное слово не меняется.
|
||||
Поэтому тег `3` даёт `[0xffffffff, 0xffffffff, 0xffffffff]`. Это число
|
||||
начальных ячеек, а не назначение трёх ID.
|
||||
|
||||
Изолированная x86-проверка вызовом оригинальной `ai.dll` подтвердила пары
|
||||
«число слотов/ёмкость» `0/0`, `1/17`, `17/17` и `18/34`. Все добавленные слова
|
||||
равны `0xffffffff`, а входное значение, контрольные поля, память вне заголовка
|
||||
массива и указатель стека сохраняются. Это подтверждает добавление начального
|
||||
слова, но не работу последующих обработчиков.
|
||||
|
||||
Rust хранит такой снимок в отдельном `MissionAiStartupState` для каждого TMA-клана,
|
||||
а не в пакете сценария. При полной загрузке (`Full`) создаётся запись для каждого
|
||||
клана, в том числе если у него нет пути сценария или пути таблицы исследований. Счётчик
|
||||
берётся из сырых битов `second_resource.tag` как `u32` у обеих форм записи —
|
||||
`Standard` и `Spatial`; нулевой счётчик даёт существующее состояние с пустым
|
||||
списком. Статический режим предпросмотра не создаёт состояний AI.
|
||||
|
||||
Загрузка отклоняет суммарный объём свыше 1 000 000 DWORD и резервирует память
|
||||
проверяемой операцией; это защита Rust от чрезмерного выделения памяти, а не
|
||||
ограничение оригинальной игры. Rust сохраняет начальные слова, но не
|
||||
восстанавливает работу последующих обработчиков и не назначает этим словам ID
|
||||
объектов. Этот массив также не является списком
|
||||
`BehaviorDistributorState.registered_objects`.
|
||||
|
||||
До регулярной работы Behavior настраивает расписание своего первого шага. Он
|
||||
берёт текущее 32-битное время игровой очереди и назначает проверку через три
|
||||
интервала по 64 мс, добавляя небольшую случайную задержку от 0 до 63 мс. Эта
|
||||
|
||||
Reference in new issue
Block a user