diff options
| author | Valentin Popov <valentin@popov.link> | 2026-10-31 10:43:24 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-10-31 10:43:24 +0300 |
| commit | 2a3b826c263683a391e8eb7993b255666e518427 (patch) | |
| tree | a5abe5f3a7b2ef6d4e7eb62f93f06303b80891c7 | |
| parent | 0b2184429ff9da457662bf094d88b048595b3ec8 (diff) | |
| download | fparkan-2a3b826c263683a391e8eb7993b255666e518427.tar.xz fparkan-2a3b826c263683a391e8eb7993b255666e518427.zip | |
fix(runtime): load research tables for every AI owner
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 99 | ||||
| -rw-r--r-- | docs/appendices/knowledge-boundaries.md | 40 | ||||
| -rw-r--r-- | docs/tomes/06-behavior.md | 31 |
3 files changed, 108 insertions, 62 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index f141f50..553c611 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -1510,7 +1510,7 @@ fn load_mission_with_options( let research_trf_tables = if options.asset_scope == MissionAssetScope::Full && startup_input.is_some_and(|input| input.research_creation_gate) { - load_mission_research_trf_tables(&vfs, &mission, &loaded_scripts.bundles)? + load_mission_research_trf_tables(&vfs, &mission, &loaded_scripts.ai_startup_states)? } else { Vec::new() }; @@ -6613,14 +6613,14 @@ fn load_script_formula_table( fn load_mission_research_trf_tables( vfs: &Arc<dyn Vfs>, mission: &MissionDocument, - bundles: &[MissionScriptBundle], + ai_startup_states: &[MissionAiStartupState], ) -> Result<Vec<MissionResearchTrfTable>, EngineError> { - let mut tables = Vec::with_capacity(bundles.len()); - for bundle in bundles { - let Some(clan) = mission.clans.get(bundle.clan_index) else { + let mut tables = Vec::with_capacity(ai_startup_states.len()); + for ai_state in ai_startup_states { + let Some(clan) = mission.clans.get(ai_state.clan_index) else { return Err(EngineError::ScriptRuntime { - clan_index: bundle.clan_index, - message: "script bundle clan index is outside the decoded mission".to_string(), + clan_index: ai_state.clan_index, + message: "AI startup state clan index is outside the decoded mission".to_string(), }); }; let second_resource = match &clan.body { @@ -6642,7 +6642,7 @@ fn load_mission_research_trf_tables( } if !raw_path.is_ascii() { return Err(EngineError::ResearchTrfPath { - clan_index: bundle.clan_index, + clan_index: ai_state.clan_index, path: raw_path.to_vec(), }); } @@ -6651,12 +6651,12 @@ fn load_mission_research_trf_tables( let bytes = read_vfs(vfs, &path)?; let document = ResearchTrfDocument::decode(bytes).map_err(|source| EngineError::ResearchTrf { - clan_index: bundle.clan_index, + clan_index: ai_state.clan_index, path: path.as_str().to_string(), source, })?; tables.push(MissionResearchTrfTable { - clan_index: bundle.clan_index, + clan_index: ai_state.clan_index, path: path.as_str().to_string(), document, }); @@ -16428,12 +16428,9 @@ mod tests { } #[test] - fn full_load_keeps_independent_trf_tables_for_each_script_bundle_clan() { + fn full_load_keeps_independent_trf_tables_for_each_ai_state_clan() { let vfs = research_startup_test_vfs( - &[ - Some(b"MISSIONS/SCRIPTS/shared"), - Some(b"MISSIONS/SCRIPTS/shared"), - ], + &[Some(b"MISSIONS/SCRIPTS/shared"), None], &[ b"DATA/Research/shared.trf\0ignored.trf", b"DATA/Research/shared.trf", @@ -16456,13 +16453,21 @@ mod tests { .expect("full load with explicit research gate"); let tables = loaded_mission_research_trf_tables(&engine).expect("TRF tables"); - assert_eq!(tables.len(), 2, "each loaded script clan owns a table"); + assert_eq!(tables.len(), 2, "each AI state owner gets a table"); + let bundles = loaded_mission_script_bundles(&engine).expect("script bundles"); + assert_eq!(bundles.len(), 1, "only the scripted clan has a bundle"); + assert_eq!(bundles[0].clan_index, 0); + let ai_states = loaded_mission_ai_startup_states(&engine).expect("AI states"); assert_eq!( - loaded_mission_script_bundles(&engine) - .expect("script bundles") - .len(), - 2 + ai_states + .iter() + .map(|state| state.clan_index) + .collect::<Vec<_>>(), + [0, 1] ); + assert!(ai_states + .iter() + .all(|state| state.record_slots == [u32::MAX; 9])); assert_eq!(tables[0].clan_index, 0); assert_eq!(tables[0].path, "DATA/Research/shared.trf"); assert_eq!(tables[1].clan_index, 1); @@ -16480,7 +16485,7 @@ mod tests { } #[test] - fn full_load_skips_research_trf_when_gate_bundle_or_c_string_is_missing() { + fn full_load_skips_research_trf_when_gate_or_c_string_is_missing() { for (script_bases, research_paths, gate, mode) in [ ( vec![Some(&b"MISSIONS/SCRIPTS/shared"[..])], @@ -16500,12 +16505,6 @@ mod tests { true, MissionMode::SingleMission, ), - ( - vec![None], - vec![&b"DATA/Research/missing.trf"[..]], - true, - MissionMode::SingleMission, - ), ] { let vfs = research_startup_test_vfs(&script_bases, &research_paths, None); let mut engine = @@ -16529,10 +16528,36 @@ mod tests { } #[test] - fn research_trf_read_and_decode_errors_keep_the_loaded_world_and_rng() { - let initial_vfs = research_startup_test_vfs( + fn static_preview_skips_research_trf_reads() { + let vfs = research_startup_test_vfs( &[Some(b"MISSIONS/SCRIPTS/shared")], - &[b"DATA/Research/shared.trf"], + &[b"DATA/Research/missing.trf"], + None, + ); + let mut engine = create(EngineServices::new(vfs)).expect("research startup test engine"); + + load_mission_static_preview( + &mut engine, + MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + ) + .expect("preview must not read research TRF paths"); + + assert!(loaded_mission_research_trf_tables(&engine) + .expect("TRF state") + .is_empty()); + assert!(loaded_mission_ai_startup_states(&engine) + .expect("AI state") + .is_empty()); + } + + #[test] + fn no_script_ai_research_trf_read_and_decode_errors_keep_loaded_world_and_rng() { + let initial_vfs = research_startup_test_vfs( + &[Some(b"MISSIONS/SCRIPTS/shared"), None], + &[b"", b"DATA/Research/shared.trf"], Some(&research_test_trf()), ); let mut engine = create(EngineServices::new(initial_vfs)).expect("research startup engine"); @@ -16564,8 +16589,8 @@ mod tests { for trf in [None, Some(&b"malformed"[..])] { let vfs = research_startup_test_vfs( - &[Some(b"MISSIONS/SCRIPTS/shared")], - &[b"DATA/Research/shared.trf"], + &[Some(b"MISSIONS/SCRIPTS/shared"), None], + &[b"", b"DATA/Research/shared.trf"], trf, ); engine.services.vfs = Some(vfs); @@ -16609,7 +16634,7 @@ mod tests { clan_index, path, .. }, ) => { - assert_eq!(clan_index, 0); + assert_eq!(clan_index, 1); assert_eq!(path, "DATA/Research/shared.trf"); } (_, error) => panic!("unexpected TRF failure: {error:?}"), @@ -16620,8 +16645,8 @@ mod tests { #[test] fn research_trf_c_string_paths_reject_non_ascii_bytes() { let vfs = research_startup_test_vfs( - &[Some(b"MISSIONS/SCRIPTS/shared")], - &[b"DATA/Research/\xe9.trf"], + &[Some(b"MISSIONS/SCRIPTS/shared"), None], + &[b"", b"DATA/Research/\xe9.trf"], None, ); let mut engine = create(EngineServices::new(vfs)).expect("research startup test engine"); @@ -16639,14 +16664,14 @@ mod tests { .expect_err("non-ASCII TRF path must fail explicitly"); assert!(matches!( error, - EngineError::ResearchTrfPath { clan_index: 0, path } + EngineError::ResearchTrfPath { clan_index: 1, path } if path == b"DATA/Research/\xe9.trf" )); } #[test] #[ignore = "requires licensed corpus"] - fn licensed_campaign_full_load_creates_trf_tables_for_script_clan_owners() { + fn licensed_campaign_full_load_creates_trf_tables_for_ai_clan_owners() { let root = licensed_root("IS"); let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); let mut engine = create(EngineServices::new(vfs)).expect("engine"); diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index 9f77c86..ab6c1f8 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -905,22 +905,34 @@ arg1 следующего вызова. `AddNewObjectToGame` принимает `FUN_10002a30` (RVA `0x2a30`, `RET 8`) записывает его в `inner+8`. TMA tag `row+0x20` ограничивает число вызовов AI slot `+0x38`, но не задаёт владельца. +Создание AI не фильтруется по наличию сценарного пути. В `iron3d.dll` SHA-256 +`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa` +`FUN_1005ff40` (RVA `0x5ff40`) проходит записи кланов и вызывает для каждой +`FUN_100392e0` (RVA `0x392e0`); в `ai.dll` SHA-256 +`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` +`CreateSuperAI` (RVA `0xf710`) выделяет AI и вызывает конструктор по RVA +`0x1000` без проверки пути сценария. Создание исследовательского контекста +следует за успешным созданием AI и требует включённого флага создания +исследований и непустого `second_resource.path` до первого NUL. Переданный в +`descriptor[0]` индекс клана, а не `second_resource.tag`, задаёт владельца; +тег остаётся счётчиком начальных вызовов AI slot `+0x38`. + В Rust эта граница представлена только загрузкой таблиц. Полный `load_mission` требует явный `MissionStartupInput { queue_time_ms, -research_creation_gate }`; gate не выводится из `MissionMode` или имени пути. -При `true` загрузчик проходит по успешно разобранным пакетам сценариев, берёт -реальный `second_resource.path` соответствующего TMA-клана до первого NUL, -отклоняет не-ASCII путь, затем сохраняет отдельный `ResearchTrfDocument` с -`clan_index` и нормализованным путём. Пустая строка, выключенный gate или -отсутствие пакета сценариев пропускают чтение. Повтор одного пути разными -владельцами приводит к отдельным буферам строк TRF0. Обычные тесты на -искусственных данных проверяют владельцев, повторное декодирование, пропуски, -ошибки и откат; игнорируемая проверка оригинальных ресурсов связывает реальные -`Campaign.00/Mission.01` пары путей с индексами владельцев. Это не загружает -полный исследовательский контекст оригинала. -Успешно разобранный пакет сценариев здесь обозначает только границу текущей -настройки Rust, а не совпадение с полным нативным жизненным циклом -`CreateSuperAI`. +research_creation_gate }`; этот флаг не выводится из `MissionMode` или имени +пути. Если флаг равен `true`, загрузчик проходит по существующим +`MissionAiStartupState` всех TMA-кланов, в том числе без сценарного пакета, +читает их `second_resource.path` до первого NUL и отклоняет не-ASCII путь. +Пустая строка пропускается; выключенный флаг и статический preview не читают +TRF. На каждого +владельца сохраняется отдельный `ResearchTrfDocument` с нормализованным путём, +даже когда разные кланы указывают на один файл. Обычные тесты на искусственных +данных проверяют владение у кланов со сценариями и без них, повторное +декодирование, пропуски, ошибки и откат; игнорируемая проверка оригинальных +ресурсов связывает реальные `Campaign.00/Mission.01` пары путей с индексами владельцев. +Rust пока сохраняет только декодированные данные и связь с владельцем: он не +создаёт полный исследовательский контекст, не строит AI-каталог и не исполняет +`Handler(43)`. Нативный metadata loader нельзя считать простым копированием таблицы. В GOG `Behavior.dll` (SHA-256 diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index 710553d..1a2cedf 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -579,8 +579,14 @@ let cost = calc_full_research_cost(&candidate, &table)?; штатный Handler(43) и запуск миссии здесь не проверялись. Владелец начальной записи исследования — индекс текущего прохода по кланам. -Движок сохраняет его в `descriptor[0]`, а затем переносит в контекст, чтобы -связать запрос с AI-данными того же клана. +В GOG-сборке `iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) проходит записи кланов +и вызывает для каждой `FUN_100392e0` (RVA `0x392e0`), которая создаёт AI; +`ai.dll!CreateSuperAI` (RVA `0xf710`) выделяет объект и вызывает конструктор +по RVA `0x1000`, не проверяя наличие сценарного пути. Для исследовательского +пути нужны отдельный флаг включения и непустой `second_resource.path`; наличие +сценарного пакета условием не является. Индекс клана сохраняется в +`descriptor[0]` и переносится в контекст, чтобы связать запрос с AI-данными +того же клана. Это значение отдельно от аргумента 1 `AddNewObjectToGame`, берущегося из `GetNetPlayerNum`, и от TMA-тега `second_resource`, задающего число вызовов @@ -590,12 +596,14 @@ Handler(43) и штатный запуск миссии остаются непÑ Полный Rust-загрузчик принимает явный `MissionStartupInput`: время очереди и отдельный `research_creation_gate`. Режим миссии и имя файла этот -флаг не заменяют. При включённом флаге таблица загружается только для клана, -чей сценарный пакет уже прочитан и успешно разобран; путь берётся из второго -ресурса этого клана как C-строка. Пустая C-строка пропускается, а не-ASCII -байты дают явную ошибку. Клан индексируется по порядку в TMA, тег ресурса на -владение не влияет. Если несколько кланов указывают на один файл, каждый -получает отдельно декодированную таблицу с собственными строками TRF0. +флаг не заменяют. При включённом флаге загрузчик использует уже имеющееся +`MissionAiStartupState` каждого TMA-клана, даже если у него нет сценарного +пакета; путь берётся из второго ресурса как C-строка до первого NUL. Пустой +путь и выключенный флаг пропускают чтение, а не-ASCII байты дают явную ошибку. +Предпросмотр тоже не читает TRF. Клан индексируется по порядку в TMA, а тег +`second_resource` задаёт число слотов AI, не владельца. Если несколько кланов +указывают на один файл, каждый получает отдельно декодированную таблицу со +своими строками TRF0. ```rust MissionStartupInput { @@ -604,9 +612,10 @@ MissionStartupInput { } ``` -Это сохраняет исходные таблицы и их связь с владельцами для следующих этапов. -Оно ещё не создаёт полный исследовательский контекст оригинала и не запускает -`Handler(43)`; эта обработка пока остаётся явно неподдержанной. +Это сохраняет декодированные таблицы и связь с владельцами для следующих +этапов. Оно ещё не создаёт полный исследовательский контекст оригинала, не +строит AI-каталог и не запускает `Handler(43)`; эти части пока не +восстановлены. Отдельная frozen-матрица непосредственно вызвала chooser GOG `ai.dll` SHA-256 `c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`: функция |
