feat(runtime): initialize per-clan AI record slots
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m17s

This commit is contained in:
Valentin Popov committed 2026-10-31 11:26:32 +04:00
1 parent 8c403ab4f7
commit 0b2184429f
3 files changed
+489 -1

No files matched your search

+428 -1
View File
@@ -44,6 +44,7 @@ pub use fparkan_assets::MissionAssets;
pub use mesh_collision::{query_building_segment_world, BuildingMeshHit, BuildingMeshNode}; pub use mesh_collision::{query_building_segment_world, BuildingMeshHit, BuildingMeshNode};
const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256; 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 FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var";
const INITIAL_BEHAVIOR_RNG_STATE: u32 = 1; const INITIAL_BEHAVIOR_RNG_STATE: u32 = 1;
const UNIT_DAT_CONTROL_CLASS_HIGH_BIT: u32 = 0x8000_0000; const UNIT_DAT_CONTROL_CLASS_HIGH_BIT: u32 = 0x8000_0000;
@@ -712,6 +713,15 @@ pub struct MissionScriptBundle {
pub behavior_warm_start: Option<BehaviorWarmStartState>, 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. /// One decoded research table associated with its TMA clan owner and source path.
#[derive(Debug)] #[derive(Debug)]
pub struct MissionResearchTrfTable { pub struct MissionResearchTrfTable {
@@ -879,6 +889,7 @@ struct MissionLoadOptions {
#[derive(Clone, Debug, Default, Eq, PartialEq)] #[derive(Clone, Debug, Default, Eq, PartialEq)]
struct LoadedMissionScripts { struct LoadedMissionScripts {
bundles: Vec<MissionScriptBundle>, bundles: Vec<MissionScriptBundle>,
ai_startup_states: Vec<MissionAiStartupState>,
varset: Option<MissionScriptVarSet>, varset: Option<MissionScriptVarSet>,
init_states: Vec<MissionScriptInitState>, init_states: Vec<MissionScriptInitState>,
varset_states: Vec<MissionScriptVarSetState>, varset_states: Vec<MissionScriptVarSetState>,
@@ -1008,6 +1019,7 @@ struct LoadedMissionState {
control_device_tables: Vec<MissionControlDeviceTableState>, control_device_tables: Vec<MissionControlDeviceTableState>,
behavior_control_groupings: Vec<MissionBehaviorControlGroupingState>, behavior_control_groupings: Vec<MissionBehaviorControlGroupingState>,
research_trf_tables: Vec<MissionResearchTrfTable>, research_trf_tables: Vec<MissionResearchTrfTable>,
ai_startup_states: Vec<MissionAiStartupState>,
script_bundles: Vec<MissionScriptBundle>, script_bundles: Vec<MissionScriptBundle>,
script_varset: Option<MissionScriptVarSet>, script_varset: Option<MissionScriptVarSet>,
script_init_states: Vec<MissionScriptInitState>, script_init_states: Vec<MissionScriptInitState>,
@@ -1079,6 +1091,17 @@ pub enum EngineError {
/// Handler-specific diagnostic. /// Handler-specific diagnostic.
message: String, 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` decode error.
Nres { Nres {
/// Resource path. /// Resource path.
@@ -1258,6 +1281,15 @@ impl std::fmt::Display for EngineError {
clan_index, clan_index,
message, message,
} => write!(f, "clan {clan_index}: script runtime failed: {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::Nres { path, source } => write!(f, "{path}: {source}"),
Self::ResearchTrf { Self::ResearchTrf {
clan_index, clan_index,
@@ -1318,6 +1350,7 @@ impl std::error::Error for EngineError {
| Self::VarSet { .. } | Self::VarSet { .. }
| Self::ScriptInit { .. } | Self::ScriptInit { .. }
| Self::ScriptRuntime { .. } | Self::ScriptRuntime { .. }
| Self::MissionAiRecordSlots { .. }
| Self::Movement(_) | Self::Movement(_)
| Self::ResearchTrfPath { .. } | Self::ResearchTrfPath { .. }
| Self::PrototypeGraph { .. } | Self::PrototypeGraph { .. }
@@ -1844,6 +1877,7 @@ fn load_mission_with_options(
control_device_tables, control_device_tables,
behavior_control_groupings, behavior_control_groupings,
research_trf_tables, research_trf_tables,
ai_startup_states: loaded_scripts.ai_startup_states,
script_bundles: loaded_scripts.bundles, script_bundles: loaded_scripts.bundles,
script_varset: loaded_scripts.varset, script_varset: loaded_scripts.varset,
script_init_states: loaded_scripts.init_states, 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()) .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. /// Returns the per-clan TRF tables loaded during full mission startup.
#[must_use] #[must_use]
pub fn loaded_mission_research_trf_tables(engine: &Engine) -> Option<&[MissionResearchTrfTable]> { 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 script_bases = mission_script_bundle_bases(mission);
let mut bundles = Vec::with_capacity(script_bases.len()); let mut bundles = Vec::with_capacity(script_bases.len());
let mut first_base_path = None; 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 { for script_base in script_bases {
let base_path = String::from_utf8_lossy(&script_base.path_raw).into_owned(); 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") { let script_path = if base_path.to_ascii_lowercase().ends_with(".scr") {
@@ -6680,6 +6771,7 @@ fn load_mission_scripts(
}; };
Ok(LoadedMissionScripts { Ok(LoadedMissionScripts {
bundles, bundles,
ai_startup_states,
varset, varset,
init_states, init_states,
varset_states, varset_states,
@@ -10254,6 +10346,7 @@ mod tests {
}; };
let mut scripts = LoadedMissionScripts { let mut scripts = LoadedMissionScripts {
bundles: vec![bundle(0, 18, Vec::new()), bundle(1, 19, vec![0, 1, 2])], bundles: vec![bundle(0, 18, Vec::new()), bundle(1, 19, vec![0, 1, 2])],
ai_startup_states: Vec::new(),
varset: Some(varset), varset: Some(varset),
..LoadedMissionScripts::default() ..LoadedMissionScripts::default()
}; };
@@ -10283,6 +10376,193 @@ mod tests {
assert_eq!(rng_state, 0x0029_e2c0); 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] #[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");
@@ -15041,6 +15321,50 @@ mod tests {
assert_eq!(before, after); 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] #[test]
#[ignore = "requires licensed corpus"] #[ignore = "requires licensed corpus"]
fn licensed_single_preview_loads_each_formula_table_and_stops_at_handler43() { fn licensed_single_preview_loads_each_formula_table_and_stops_at_handler43() {
@@ -15916,6 +16240,15 @@ mod tests {
script_bases: &[Option<&[u8]>], script_bases: &[Option<&[u8]>],
research_paths: &[&[u8]], research_paths: &[&[u8]],
trf: Option<&[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> { ) -> Arc<dyn Vfs> {
fn push_u32(bytes: &mut Vec<u8>, value: u32) { fn push_u32(bytes: &mut Vec<u8>, value: u32) {
bytes.extend_from_slice(&value.to_le_bytes()); bytes.extend_from_slice(&value.to_le_bytes());
@@ -15944,7 +16277,7 @@ mod tests {
push_lp(&mut mission, script_base.unwrap_or_default()); push_lp(&mut mission, script_base.unwrap_or_default());
push_u32(&mut mission, 0); push_u32(&mut mission, 0);
push_lp(&mut mission, research_path); 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, 0);
} }
push_u32(&mut mission, 10); push_u32(&mut mission, 10);
@@ -16000,6 +16333,100 @@ mod tests {
Arc::new(files) 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] #[test]
fn full_load_keeps_independent_trf_tables_for_each_script_bundle_clan() { fn full_load_keeps_independent_trf_tables_for_each_script_bundle_clan() {
let vfs = research_startup_test_vfs( let vfs = research_startup_test_vfs(
+28
View File
@@ -184,6 +184,34 @@ core и `ResearchCandidateSet::refresh_selection`: последний получ
создаёт владельца живого контекста, не передаёт реальные AI candidates и не создаёт владельца живого контекста, не передаёт реальные AI candidates и не
выбирает результат Handler(43). Это не общий dispatcher и не загрузка игры. выбирает результат 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`. В Для GOG-сборки также найден статический host call path в `Init`. В
`iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи `iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи
кланов и вызывает для них `FUN_100392e0`, затем вызывает `FUN_10039440` (RVA кланов и вызывает для них `FUN_100392e0`, затем вызывает `FUN_10039440` (RVA
+33
View File
@@ -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 настраивает расписание своего первого шага. Он До регулярной работы Behavior настраивает расписание своего первого шага. Он
берёт текущее 32-битное время игровой очереди и назначает проверку через три берёт текущее 32-битное время игровой очереди и назначает проверку через три
интервала по 64 мс, добавляя небольшую случайную задержку от 0 до 63 мс. Эта интервала по 64 мс, добавляя небольшую случайную задержку от 0 до 63 мс. Эта