From 0b2184429ff9da457662bf094d88b048595b3ec8 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 31 Oct 2026 11:26:32 +0400 Subject: feat(runtime): initialize per-clan AI record slots --- crates/fparkan-runtime/src/lib.rs | 429 +++++++++++++++++++++++++++++++- docs/appendices/knowledge-boundaries.md | 28 +++ docs/tomes/06-behavior.md | 33 +++ 3 files changed, 489 insertions(+), 1 deletion(-) diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 9013019..f141f50 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -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, } +/// 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, +} + /// 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, + ai_startup_states: Vec, varset: Option, init_states: Vec, varset_states: Vec, @@ -1008,6 +1019,7 @@ struct LoadedMissionState { control_device_tables: Vec, behavior_control_groupings: Vec, research_trf_tables: Vec, + ai_startup_states: Vec, script_bundles: Vec, script_varset: Option, script_init_states: Vec, @@ -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, value: u32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + fn push_lp(bytes: &mut Vec, 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 { + 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 { + 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::>(), + [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::>(), + [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![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::>(), + [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 = 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::>(), + [9, 9] + ); + assert_eq!( + full.ai_startup_states + .iter() + .map(|state| state.clan_index) + .collect::>(), + [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 { + 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 { fn push_u32(bytes: &mut Vec, 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::>(), + [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( diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index b313375..9f77c86 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -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 diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index 6684b13..710553d 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -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 мс. Эта -- cgit v1.2.3