From caef8f8e6c0ae81b46fcab39cd4f2b74e1b29235 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Tue, 20 Oct 2026 08:44:10 +0400 Subject: feat(runtime): register mission objects with clan distributors --- crates/fparkan-runtime/src/lib.rs | 217 +++++++++++++++++++++++++++++++- docs/appendices/knowledge-boundaries.md | 39 +++++- docs/reference/tma.md | 2 + docs/tomes/04-world.md | 20 +++ docs/tomes/06-behavior.md | 58 +++++++-- 5 files changed, 318 insertions(+), 18 deletions(-) diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 8d32778..8d42958 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -23,8 +23,8 @@ use fparkan_terrain::SurfaceQuery; use fparkan_vfs::{Vfs, VfsError}; use fparkan_world::{ construct_object, handle_by_original_id, new as new_world, register_object, set_transform, - step, transform_state, InputSnapshot, ObjectDraft, OriginalObjectId, TransformState, World, - WorldConfig, WorldSnapshot, + step, transform_state, InputSnapshot, ObjectDraft, ObjectHandle, OriginalObjectId, + TransformState, World, WorldConfig, WorldSnapshot, }; use std::num::NonZeroUsize; use std::sync::Arc; @@ -115,6 +115,34 @@ pub struct MissionObjectDraft { pub unit_components: Vec, } +/// A mission object registered with one clan's native Behavior distributor. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BehaviorRegisteredObject { + /// Stable identity from the source TMA object order. + pub original_id: OriginalObjectId, + /// Corresponding live Rust world object. + pub handle: ObjectHandle, + /// Raw DWORD that selects the `GetSuperAI` table slot. + pub selector: u32, + /// Class word recovered from the root Unit DAT header. + pub class_flags: u32, +} + +/// Resource distributor state associated with one TMA clan's constructed AI. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BehaviorDistributorState { + /// TMA clan index; it is not an index into the filtered script bundle list. + pub clan_index: usize, + /// Whether a successfully loaded script package published this AI to + /// `GetSuperAI` in the standard host path. + pub super_ai_visible: bool, + /// Objects admitted by the currently recovered standard-load registration path. + pub registered_objects: Vec, +} + +const LAST_SUPER_AI_TABLE_INDEX: u32 = 63; +const BEHAVIOR_REGISTRATION_KEY_FLAG: u32 = 0x8000_0000; + /// Native terrain adjustment selected for a mission object's BAS geometry. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum BasementPlacementMode { @@ -429,6 +457,7 @@ struct LoadedMissionState { mission_assets: MissionAssets, asset_plan: MissionAssetPlan, object_drafts: Vec, + behavior_distributors: Vec, script_bundles: Vec, script_varset: Option, script_init_states: Vec, @@ -955,6 +984,58 @@ fn load_mission_with_options( registered_objects += 1; } + let behavior_distributors = if options.asset_scope == MissionAssetScope::Full { + let visible_clan_indices = loaded_scripts + .bundles + .iter() + .map(|bundle| bundle.clan_index) + .filter(|clan_index| { + *clan_index < mission.clans.len() + && *clan_index <= LAST_SUPER_AI_TABLE_INDEX as usize + }) + .collect::>(); + let mut distributors = mission + .clans + .iter() + .enumerate() + .map(|(clan_index, _)| BehaviorDistributorState { + clan_index, + super_ai_visible: visible_clan_indices.contains(&clan_index), + registered_objects: Vec::new(), + }) + .collect::>(); + + for (draft, handle) in object_drafts.iter().zip(&handles) { + let Some(class_flags) = draft.unit_dat_header_flags else { + continue; + }; + if !has_behavior_registration_key(class_flags) { + continue; + } + let Some(clan_index) = behavior_clan_for_selector( + draft.raw_after_resource, + mission.clans.len(), + &visible_clan_indices, + ) else { + continue; + }; + let Some(original_id) = draft.original_id else { + continue; + }; + distributors[clan_index] + .registered_objects + .push(BehaviorRegisteredObject { + original_id, + handle: *handle, + selector: draft.raw_after_resource, + class_flags, + }); + } + distributors + } else { + Vec::new() + }; + 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"); @@ -1029,6 +1110,7 @@ fn load_mission_with_options( mission_assets, asset_plan: mission_asset_plan, object_drafts, + behavior_distributors, script_bundles: loaded_scripts.bundles, script_varset: loaded_scripts.varset, script_init_states: loaded_scripts.init_states, @@ -1300,6 +1382,33 @@ pub fn loaded_mission_object_drafts(engine: &Engine) -> Option<&[MissionObjectDr .map(|state| state.object_drafts.as_slice()) } +/// Returns the clan-indexed Behavior distributors and their registered objects. +#[must_use] +pub fn loaded_mission_behavior_distributors( + engine: &Engine, +) -> Option<&[BehaviorDistributorState]> { + engine + .loaded + .as_ref() + .map(|state| state.behavior_distributors.as_slice()) +} + +fn behavior_clan_for_selector( + selector: u32, + clan_count: usize, + super_ai_clan_indices: &[usize], +) -> Option { + if selector > LAST_SUPER_AI_TABLE_INDEX { + return None; + } + let clan_index = usize::try_from(selector).ok()?; + (clan_index < clan_count && super_ai_clan_indices.contains(&clan_index)).then_some(clan_index) +} + +fn has_behavior_registration_key(class_flags: u32) -> bool { + class_flags & BEHAVIOR_REGISTRATION_KEY_FLAG != 0 +} + fn normalize_engine_path(role: &'static str, value: &str) -> Result { normalize_relative(value.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| { EngineError::Path { @@ -1666,6 +1775,26 @@ mod tests { ); } + #[test] + fn behavior_selector_requires_an_exact_visible_clan_slot() { + let sparse_slots = [1, 63]; + assert_eq!(behavior_clan_for_selector(1, 64, &sparse_slots), Some(1)); + assert_eq!(behavior_clan_for_selector(63, 64, &[63]), Some(63)); + assert_eq!(behavior_clan_for_selector(0, 64, &[1, 63]), None); + assert_eq!(behavior_clan_for_selector(64, 65, &[64]), None); + assert_eq!(behavior_clan_for_selector(0xfffe, 65_535, &[0xfffe]), None); + assert_eq!(behavior_clan_for_selector(63, 63, &[63]), None); + } + + #[test] + fn behavior_registration_uses_the_class_high_bit() { + assert!(has_behavior_registration_key(0x8000_0010)); + assert!(has_behavior_registration_key(0x8000_0002)); + assert!(has_behavior_registration_key(0x8000_0123)); + assert!(!has_behavior_registration_key(0x0000_0123)); + assert!(!has_behavior_registration_key(0x7fff_ffff)); + } + #[test] fn behavior_warm_start_uses_native_clock_and_shared_rng_vectors() { let mut rng_state = 1; @@ -2260,6 +2389,80 @@ mod tests { assert_eq!(draft.identity_or_clan_raw, object.identity_or_clan_raw); } + let distributors = + loaded_mission_behavior_distributors(&engine).expect("Behavior distributors"); + assert_eq!( + distributors + .iter() + .map(|distributor| (distributor.clan_index, distributor.super_ai_visible)) + .collect::>(), + vec![(0, true), (1, true)] + ); + let registered = distributors + .iter() + .flat_map(|distributor| { + distributor + .registered_objects + .iter() + .map(move |object| (distributor.clan_index, object)) + }) + .collect::>(); + assert_eq!( + registered + .iter() + .map(|(clan_index, object)| { + let draft = drafts + .iter() + .find(|draft| draft.original_id == Some(object.original_id)) + .expect("registered object draft"); + ( + *clan_index, + object.original_id, + draft.resource_name_raw.as_slice(), + object.selector, + object.class_flags, + ) + }) + .collect::>(), + vec![ + ( + 0, + OriginalObjectId(4), + &b"UNITS\\BUILDS\\PLANT\\wor_l_n1.dat"[..], + 0, + 0x8000_0010, + ), + ( + 0, + OriginalObjectId(6), + &b"UNITS\\BUILDS\\GENER\\gener01.dat"[..], + 0, + 0x8000_0002, + ), + ( + 1, + OriginalObjectId(5), + &b"UNITS\\BUILDS\\PLANT\\wor_l_n1.dat"[..], + 1, + 0x8000_0010, + ), + ( + 1, + OriginalObjectId(7), + &b"UNITS\\BUILDS\\GENER\\gener01.dat"[..], + 1, + 0x8000_0002, + ), + ] + ); + assert_eq!(registered.len(), 4); + for (_, object) in registered { + assert_eq!( + handle_by_original_id(&engine.world, object.original_id), + Some(object.handle) + ); + } + assert_eq!( mission .clans @@ -2333,6 +2536,9 @@ mod tests { .expect("load licensed campaign preview"); let bundles = loaded_mission_script_bundles(&engine).expect("script bundles"); + assert!(loaded_mission_behavior_distributors(&engine) + .expect("preview Behavior distributors") + .is_empty()); assert!(bundles .iter() .all(|bundle| bundle.behavior_warm_start.is_none())); @@ -2639,6 +2845,9 @@ mod tests { let previous_bundles = loaded_mission_script_bundles(&engine) .expect("previous bundles") .to_vec(); + let previous_distributors = loaded_mission_behavior_distributors(&engine) + .expect("previous Behavior distributors") + .to_vec(); let previous_rng = engine.behavior_rng_state; let before = step_headless(&mut engine, InputSnapshot) .expect("step previous world") @@ -2662,6 +2871,10 @@ mod tests { loaded_mission_script_bundles(&engine), Some(previous_bundles.as_slice()) ); + assert_eq!( + loaded_mission_behavior_distributors(&engine), + Some(previous_distributors.as_slice()) + ); assert_eq!(engine.behavior_rng_state, previous_rng); let after = step_headless(&mut engine, InputSnapshot) .expect("step retained world") diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index a3e4743..b10a002 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -129,10 +129,41 @@ Rust `load_mission` принимает явный `queue_time_ms`, а не чи host construction phases. Не проверена другая лицензированная миссия с непустым `Init`. Вызов `Behavior.dll!FUN_10019e80` (RVA `0x19e80`, SHA-256 `ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) исполнялся -на синтетических интерфейсах, но runtime пока не вызывает его в игровом цикле: -не доказаны создание реальных записей distributor и интерфейсов ArealMap, -которые ему нужны. Остальной Behavior tick flow и общий dispatcher событий -также не восстановлены. +на синтетических интерфейсах. Путь членства в реальных distributor-списках +теперь связан с полями TMA и Unit DAT: `World3D!CreateObject` передаёт selector +и class word через дескриптор в Behavior (RVA `0x7e55`, `0x7e62`), а +`Behavior.dll!FUN_10005e60` (RVA `0x5e60`) проверяет высокий бит registration +key, AI и distributor. Selector из первого DWORD после имени ресурса выбирает +слот `GetSuperAI`; таблица этой функции находится в `ai.dll` RVA `0x55398`. +Хеши `MisLoad.dll`, `iron3d.dll`, `ArealMap.dll`, `World3D.dll` и +`Behavior.dll`, использованных для цепочки, перечислены в +[справочнике TMA](../reference/tma.md). + +Отдельный режимный бит Behavior нельзя выводить из `MissionMode`. Конструктор +Behavior (`Behavior.dll`, RVA `0x3600`) вызывает `World3D!stdGetGameMode` (RVA +`0x14750`), который читает host-global по RVA `0x795130` в `World3D.dll` (SHA-256 +`17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e`). В +обычном пути `stdInitGame` (RVA `0x13e90`, запись режима около `0x14040`) +формируется отдельное host-значение; в наблюдавшемся запуске AutoDemo getter +вернул `4`, и бит `0x2` был снят. Это объясняет соответствующий gate при +создании Behavior, но не устанавливает отображение между host-режимом и Rust +`MissionMode`. + +Наблюдение обычного запуска лицензированной GOG AutoDemo подтвердило четыре +добавления с ключами `0x80000003..0x80000006` в два клановых списка. Полная +загрузка Rust сохраняет соответствующие `ObjectHandle` в точном доступном слоте +`GetSuperAI` до стартового `Init`; preview не создаёт членств. Лицензированный +тест связывает эти записи с TMA ID 4–7, selector и Unit DAT class. Это +подтверждает только состав списков, не точную эквивалентность всей загрузки или +регулярного шага Behavior. + +Нативный `Behavior.dll!Add` (RVA `0x1aac0`) создаёт для каждого участника запись +размером `0x1c` с начальной долей `1 / count`, а `normalize` (RVA `0x1ac90`) +перенормирует прежние доли после добавления. Rust пока хранит только членство: +поля записи, доли и их изменение ещё не исполняются. Не восстановлено и +соединение с реальными интерфейсами ArealMap, необходимыми для регулярного +вызова `FUN_10019e80`; остальной Behavior tick flow и общий dispatcher событий +также остаются открытыми. Для `Handler(30)` отдельно подтверждены обратный вызов `iron3d.dll` RVA `0x611d0` (SHA-256 `d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`), diff --git a/docs/reference/tma.md b/docs/reference/tma.md index dff0796..2a84d6d 100644 --- a/docs/reference/tma.md +++ b/docs/reference/tma.md @@ -87,6 +87,8 @@ slot `+0x40` в путь выбора AI в Behavior. Поэтому Rust сох `17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e` и `Behavior.dll` `ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`. +Таблица `GetSuperAI` в `ai.dll` находится по RVA `0x55398`; SHA-256 файла — +`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`. `Property` состоит из четырёх raw `u32` и имени. Typed views разрешены только для доказанных property names и consumers. diff --git a/docs/tomes/04-world.md b/docs/tomes/04-world.md index dcd53b2..1123bc7 100644 --- a/docs/tomes/04-world.md +++ b/docs/tomes/04-world.md @@ -215,6 +215,26 @@ MissionObjectDraft { `fparkan-world` не подтверждено. Смещения, условия версий и контрольные суммы исследованных сборок приведены в [справочнике TMA](../reference/tma.md). +Это слово влияет не только на сценарий. Успешно загруженный пакет клана +публикует его `SuperAI` в таблице `GetSuperAI`; World3D передаёт первый DWORD +дескриптора создания через Behavior, где он выбирает тот же номер таблицы. +Поэтому при создании полного мира можно отнести объект к конкретному клановому +диспетчеру, если его номер лежит в диапазоне `0..63` и сценарий этого клана +загружен. Rust хранит такую связь отдельно от владельца объекта: + +```text +TMA raw_after_resource = 1 + -> GetSuperAI[1] доступен после загрузки script bundle клана 1 + -> запись объекта относится к distributor клана 1 +``` + +Для объектов с Unit DAT используется старший бит DWORD по смещению `+4` от +начала DAT как общий признак, по которому оригинальный World3D создаёт +registration key для Behavior. Это второе 32-битное слово заголовка. Из него +нельзя вывести сторону или тип владельца. В AutoDemo это связывает поля TMA, +class word из Unit DAT и выбранный клановый AI, не меняя `OwnerId` и хеш +игрового состояния. + Свойства хранятся как ordered property bag: ```text diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index cd08969..b30bc7d 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -204,11 +204,47 @@ SuperAI для записей кланов, затем обновляет Behavi не воспроизводит аргумент обратного вызова в `AI+0x3fc`, защиту от повторного входа `AI+0x78`, одноразовую метку `AI+0x8a8` или обновления Behavior на игровых тиках. При этом один конкретный шаг Behavior — распределение ресурсов — уже -исполнен на исходной DLL с искусственными объектами; FParkan пока не вызывает -его, потому что игровой мир Rust ещё не предоставляет нужные ресурсные -интерфейсы. Runtime также не подменяет очередь оригинала: вызывающая сторона -сама передаёт время, а локальный генератор Rust пока учитывает только случайные -числа восстановленного стартового шага. +исполнен на исходной DLL с искусственными объектами. Rust пока не вызывает +регулярный шаг: игровой мир ещё не предоставляет нужные ресурсные интерфейсы. +При полной загрузке Rust уже восстанавливает членство объектов в клановых +списках Behavior, но сами записи распределителя и их доли пока не моделирует. +Runtime также не подменяет очередь оригинала: вызывающая сторона сама передаёт +время, а локальный генератор Rust пока учитывает только случайные числа +восстановленного стартового шага. + +### Откуда берутся объекты распределителя + +Для каждого клана Iron3D создаёт AI независимо от того, найден ли его `.scr`. +Таблица `GetSuperAI` получает указатель только для клана, чей пакет сценария +успешно загружен. У размещённого объекта первое DWORD после имени ресурса +выбирает индекс этой таблицы; второй соседний DWORD для этой цели не используется. +World3D получает индекс через дескриптор создания объекта и передаёт его в +Behavior. Отдельно DWORD по смещению `+4` от начала Unit DAT задаёт class word +объекта. Это второе 32-битное слово файла; его старший бит приводит к созданию +registration key, который проходит защиту Behavior вместе с проверками AI и +distributor. + +Для обычной полной загрузки Rust сохраняет по одной записи состояния на клан. +Каждый размещённый объект с известным Unit DAT и установленным старшим битом +получает существующий `ObjectHandle` и попадает в список только при точном +совпадении индекса `raw_after_resource` с доступной записью `GetSuperAI`. Индекс +не перенумеровывается: например, пакет клана 1 остаётся слотом 1 даже если +пакет клана 0 отсутствует. Preview сохраняет свои статические данные, но не +создаёт эти связи. + +В лицензированной AutoDemo это даёт четыре членства: TMA объекты 4 и 6 идут +в слот 0, а объекты 5 и 7 — в слот 1. У парных записей первого клана raw +селектор равен 0, у второго — 1; классы из Unit DAT равны `0x80000010` для +`wor_l_n1.dat` и `0x80000002` для `gener01.dat`. Тот же обычный путь в исходном +Behavior добавил четыре ключа `0x80000003..0x80000006` в два distributor-а. +Rust-тест повторно читает лицензированную миссию, связывает результаты с её +TMA-записями и проверяет реальные `ObjectHandle`, но помечен `#[ignore]`, чтобы +не требовать оригинальных ресурсов от остальных разработчиков. + +Это пока только перечень участников. Оригинальный `Behavior.dll!Add` создаёт для +каждого участника запись размером `0x1c` с долей `1 / count`, после чего +нормирует прежние доли. Rust ещё не хранит эти поля и не исполняет их регулярный +обмен ресурсами. Поля не выводятся из TMA-соседнего слова или `OwnerId`. ### Регулярная передача ресурсов @@ -227,13 +263,11 @@ Resource Distributor работает со списком зарегистрир исходной `Behavior.dll`, но объекты и ответы интерфейсов заданы тестом; это ещё не воспроизведение реальной миссии. -Пока мы знаем точные границы интерфейсных запросов и некоторые ветви классов -объектов, но не все доменные значения полей записи и не происхождение реальных -строк списка. Загрузчик FParkan не должен создавать такие строки из похожих -полей миссии: сначала нужно доказать путь регистрации и связать его с типами -ресурсных объектов. Технические идентификаторы и численные векторы приведены -в [приложении о сценарной машине](../appendices/script-vm.md), а оставшиеся -вопросы перечислены в [границах знания](../appendices/knowledge-boundaries.md). +Членство списка связано с реальным путём регистрации, но выполнение этой +миссии не сравнивалось с тем, как исходная DLL заполняет поля записи `0x1c` и +нормирует доли. Rust пока не хранит эти поля, а игровому миру ещё не хватает +ресурсных интерфейсов ArealMap для регулярного шага. Открытые вопросы и границы +проверки приведены в [границах знания](../appendices/knowledge-boundaries.md). Первый часто встречающийся table entry с side effect — `Handler(2)` (176 records в corpus). Он берёт active instruction, разрешает семь slots через -- cgit v1.2.3