diff options
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 246 | ||||
| -rw-r--r-- | docs/appendices/knowledge-boundaries.md | 20 | ||||
| -rw-r--r-- | docs/appendices/script-vm.md | 43 | ||||
| -rw-r--r-- | docs/tomes/06-behavior.md | 19 |
4 files changed, 328 insertions, 0 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 17d942f..440c623 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -495,6 +495,38 @@ pub struct MissionControlDeviceTableState { pub current_output_rate_5e0_bits: u32, } +/// One initial outer row prepared from the current Control descriptor table. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionBehaviorControlGroup { + /// Table ordinal of the descriptor whose current tag starts this group. + pub header_device_ordinal: usize, + /// Table ordinals of current-tag-2 descriptors following this header. + pub candidate_device_ordinals: Vec<usize>, + /// Table ordinals of current-tag-30 descriptors following this header. + pub tag30_device_ordinals: Vec<usize>, +} + +/// Initial grouping of a prepared Control table, before live Behavior refreshes. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct MissionBehaviorControlGrouping { + /// Groups in the order their current-tag-1 headers appeared. + pub groups: Vec<MissionBehaviorControlGroup>, + /// Current-tag-2 entries before the first header. + pub ungrouped_candidate_device_ordinals: Vec<usize>, +} + +/// Initial Behavior grouping projection for one full-load mission object. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionBehaviorControlGroupingState { + /// Stable identity from the source TMA object order. + pub original_id: Option<OriginalObjectId>, + /// Corresponding live Rust world object. + pub handle: ObjectHandle, + /// Initial grouping, or the reason its prepared Control table is unavailable. + pub grouping: + Result<MissionBehaviorControlGrouping, MissionControlDeviceTableUnavailableReason>, +} + /// Provenance for the class passed to Control's constructor. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MissionControlClassSource { @@ -974,6 +1006,7 @@ struct LoadedMissionState { behavior_ore_states: Vec<BehaviorOreState>, behavior_profiles: Vec<BehaviorProfileState>, control_device_tables: Vec<MissionControlDeviceTableState>, + behavior_control_groupings: Vec<MissionBehaviorControlGroupingState>, research_trf_tables: Vec<MissionResearchTrfTable>, script_bundles: Vec<MissionScriptBundle>, script_varset: Option<MissionScriptVarSet>, @@ -1720,6 +1753,14 @@ fn load_mission_with_options( } else { Vec::new() }; + let behavior_control_groupings = if options.asset_scope == MissionAssetScope::Full { + control_device_tables + .iter() + .map(project_mission_behavior_control_grouping) + .collect() + } else { + Vec::new() + }; let mut next_behavior_rng_state = engine.behavior_rng_state; if options.asset_scope == MissionAssetScope::Full { @@ -1801,6 +1842,7 @@ fn load_mission_with_options( behavior_ore_states, behavior_profiles, control_device_tables, + behavior_control_groupings, research_trf_tables, script_bundles: loaded_scripts.bundles, script_varset: loaded_scripts.varset, @@ -2132,6 +2174,61 @@ pub fn loaded_mission_control_device_tables( .map(|state| state.control_device_tables.as_slice()) } +/// Returns the initial prepared Control grouping for full-load mission objects. +#[must_use] +pub fn loaded_mission_behavior_control_groupings( + engine: &Engine, +) -> Option<&[MissionBehaviorControlGroupingState]> { + engine + .loaded + .as_ref() + .map(|state| state.behavior_control_groupings.as_slice()) +} + +fn project_mission_behavior_control_grouping( + table: &MissionControlDeviceTableState, +) -> MissionBehaviorControlGroupingState { + let grouping = match &table.status { + MissionControlDeviceTableStatus::Available(devices) => { + Ok(build_mission_behavior_control_grouping(devices)) + } + MissionControlDeviceTableStatus::Unavailable(reason) => Err(reason.clone()), + }; + MissionBehaviorControlGroupingState { + original_id: table.original_id, + handle: table.handle, + grouping, + } +} + +fn build_mission_behavior_control_grouping( + devices: &[EffectiveControlDevice], +) -> MissionBehaviorControlGrouping { + let mut grouping = MissionBehaviorControlGrouping::default(); + for (ordinal, device) in devices.iter().enumerate() { + match device.descriptor.tag { + 1 => { + grouping.groups.push(MissionBehaviorControlGroup { + header_device_ordinal: ordinal, + candidate_device_ordinals: Vec::new(), + tag30_device_ordinals: Vec::new(), + }); + } + 2 => match grouping.groups.last_mut() { + Some(group) => group.candidate_device_ordinals.push(ordinal), + None => grouping.ungrouped_candidate_device_ordinals.push(ordinal), + }, + 30 => { + if let Some(group) = grouping.groups.last_mut() { + group.tag30_device_ordinals.push(ordinal); + } + } + _ => {} + } + } + grouping +} + /// Queries native Control global-table selector 1 (weighted ratio), 2 (sum), or 15. /// `Ok(None)` preserves the original false/no-result behavior for an empty or /// zero-denominator table or selector 15 without both cached factory rows. Other @@ -7276,6 +7373,95 @@ mod tests { } } + #[test] + fn behavior_control_grouping_uses_current_tags_and_table_ordinals() { + let tags = [2, 30, 1, 30, 2, 99, 1, 2, 30, 2, 1]; + let devices = tags + .into_iter() + .enumerate() + .map(|(ordinal, current_tag)| { + let mut device = synthetic_effective_control_device( + 5, + current_tag, + 1.0_f32.to_bits(), + None, + None, + ); + device.key = 100 + ordinal as u32; + device.descriptor.fixed_words[1] = 200 + ordinal as u32; + device + }) + .collect(); + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(devices)); + let state = project_mission_behavior_control_grouping(&table); + assert_eq!(state.original_id, Some(OriginalObjectId(7))); + assert_eq!( + state.handle, + ObjectHandle { + generation: 5, + slot: 7 + } + ); + assert_eq!( + state.grouping, + Ok(MissionBehaviorControlGrouping { + ungrouped_candidate_device_ordinals: vec![0], + groups: vec![ + MissionBehaviorControlGroup { + header_device_ordinal: 2, + candidate_device_ordinals: vec![4], + tag30_device_ordinals: vec![3], + }, + MissionBehaviorControlGroup { + header_device_ordinal: 6, + candidate_device_ordinals: vec![7, 9], + tag30_device_ordinals: vec![8], + }, + MissionBehaviorControlGroup { + header_device_ordinal: 10, + candidate_device_ordinals: Vec::new(), + tag30_device_ordinals: Vec::new(), + }, + ], + }), + "group membership uses current descriptor tags and table ordinals, not factory tags or keys" + ); + + let empty = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(Vec::new())); + assert_eq!( + project_mission_behavior_control_grouping(&empty).grouping, + Ok(MissionBehaviorControlGrouping::default()) + ); + + let headerless_devices = [2, 30, 2] + .into_iter() + .map(|current_tag| { + synthetic_effective_control_device(5, current_tag, 1.0_f32.to_bits(), None, None) + }) + .collect(); + let headerless = synthetic_control_device_table( + MissionControlDeviceTableStatus::Available(headerless_devices), + ); + assert_eq!( + project_mission_behavior_control_grouping(&headerless).grouping, + Ok(MissionBehaviorControlGrouping { + groups: Vec::new(), + ungrouped_candidate_device_ordinals: vec![0, 2], + }) + ); + + let reason = MissionControlDeviceTableUnavailableReason::NoControlResource; + let unavailable = synthetic_control_device_table( + MissionControlDeviceTableStatus::Unavailable(reason.clone()), + ); + assert_eq!( + project_mission_behavior_control_grouping(&unavailable).grouping, + Err(reason) + ); + } + fn control_budget_test_table(generator_coefficient: f32) -> MissionControlDeviceTableState { let mut generator = synthetic_effective_control_device( 19, @@ -13550,6 +13736,62 @@ mod tests { #[test] #[ignore = "requires licensed corpus"] + fn full_load_builds_initial_behavior_control_groupings_for_autodemo() { + let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new( + licensed_root("IS"), + )))) + .expect("engine"); + load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".into(), + mode: MissionMode::AutoDemo, + }, + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .expect("load licensed AutoDemo with explicit startup inputs"); + + let groupings = + loaded_mission_behavior_control_groupings(&engine).expect("initial Behavior groupings"); + let group = |header_device_ordinal, candidate_device_ordinals: &[usize]| { + MissionBehaviorControlGroup { + header_device_ordinal, + candidate_device_ordinals: candidate_device_ordinals.to_vec(), + tag30_device_ordinals: Vec::new(), + } + }; + let expected = [ + (0, vec![group(6, &[10, 11, 12, 13])]), + (1, vec![group(6, &[10, 11])]), + (2, vec![group(8, &[12, 13, 14])]), + (3, vec![group(6, &[10, 11, 12])]), + (4, Vec::new()), + (5, Vec::new()), + (6, Vec::new()), + (7, Vec::new()), + ]; + assert_eq!(groupings.len(), expected.len()); + for (original_id, expected_groups) in expected { + let state = groupings + .iter() + .find(|state| state.original_id == Some(OriginalObjectId(original_id))) + .expect("grouping for AutoDemo Control object"); + assert_eq!( + state.grouping, + Ok(MissionBehaviorControlGrouping { + groups: expected_groups, + ungrouped_candidate_device_ordinals: Vec::new(), + }), + "original object {original_id}" + ); + } + } + + #[test] + #[ignore = "requires licensed corpus"] fn full_load_queries_control_selector_15_against_frozen_native_inputs() { let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new( licensed_root("IS"), @@ -14654,6 +14896,10 @@ mod tests { ) .expect("load licensed Single.01 preview"); + assert!(loaded_mission_behavior_control_groupings(&engine) + .expect("preview grouping projection") + .is_empty()); + let bundles = loaded_mission_script_bundles(&engine).expect("script bundles"); assert_eq!(bundles.len(), 2); let first = bundles diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index 07ba5d7..59b2ffd 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -262,6 +262,26 @@ Behavior (`Behavior.dll`, RVA `0x3600`) вызывает `World3D!stdGetGameMode с реальными интерфейсами ArealMap, нужными `FUN_10019e80`; полный Behavior tick flow и общий dispatcher событий также остаются открытыми. +`loaded_mission_behavior_control_groupings` публикует только начальную +группировку подготовленной Control-таблицы. Ветка с меткой `2` исходного +`Behavior.dll!FUN_1001be10` (RVA `0x1be10`) дополнительно вызывает +`FUN_1001b4b0` и обращается к свойствам через QI `0x204`, включая запросы с +номерами `0x700`, `6` и `0x400`. Результат `0x700` попадает в строку кандидата +по смещению `+0x3c`, результат `6` — в `+0x40`, а `0x400` может обнулить второе +поле. Их доменный смысл, множитель `0x700`, агрегаты Behavior QI `0x204` и +прочие числовые поля кандидата не восстановлены. Rust не подставляет множитель +`0x700`, нулевые агрегаты, выбранный индекс или эффект. Список для метки `30` не +встречается в ресурсах AutoDemo и проверяется только на искусственном наборе. + +Игнорируемая проверка AutoDemo сравнивает порядковые номера записей начальной +группировки, отдельно выведенные по снимку текущих меток дескрипторов, с +состоянием Rust после полной загрузки. Это подтверждает согласованность разбора +подготовленной таблицы и правила группировки на этих ресурсах, но не является +захватом состояния Behavior оригинала и не устанавливает, когда список +прикрепляется или обновляется в живом объекте. Полный штатный путь загрузки и +запуска, агрегаты Behavior QI `0x204`, обновление после изменений Control, +`Handler(43)` и полный игровой цикл остаются отдельными границами. + Начальные свойства `CurrentOre`/`MaximumOre` восстановлены отдельно от регулярного обмена. В проверенных `Behavior.dll` (SHA-256 `ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) RVA diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index 26ac234..790729b 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -763,6 +763,49 @@ tie-векторов подтверждает реакцию `FISTP`, но не одного numeric literal; остальные формы инструкции и содержимое `.scr` сохраняются reader-ом, но остаются вне этого исполнения. +### Начальная группировка подготовленных Control-дескрипторов + +В проверенной GOG `Behavior.dll` (SHA-256 +`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) +`FUN_1001be10` (RVA `0x1be10`) получает список через QI с кодом `0x202` от +`owner+0x5c`. Слоты `+0x50` и `+0x1c` этого интерфейса возвращают число строк и +код-различитель для строки; цикл обрабатывает порядковые номера строк +`0..count-1` по возрастанию. Метки `1`, `2` и `30` начинают группу, добавляют +кандидата и добавляют отдельный элемент к последней группе соответственно. +Запись с меткой `2` до первого заголовка попадает в отдельный вектор; метка `30` +без группы игнорируется, как и все остальные метки. + +Для источника списка проверена пара GOG DLL. В `Control.dll` SHA-256 +`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e` запрос с +кодом `0x202` отдаёт интерфейс `owner+8`; его методы-обёртки переходят к методам +чтения канонического QI `0x204`, receiver которого равен `owner+0x0c`. Метод +`Control.dll` RVA `0x8830` читает число из `[receiver+0x5b4]`, то есть +`[owner+0x5c0]`; таблица указателей находится в `[receiver+0x5b0]`, то есть +`[owner+0x5bc]`. Поле `[owner+0x5b4]` — отдельный указатель кэша, а не число +строк. Метод чтения `Control.dll` RVA `0x2c390` выбирает указатель `table[index]`, +разыменовывает указатель на дескриптор из записи `+0x48` и возвращает DWORD по +адресу этого дескриптора. Загрузчик `FUN_10021d50` (RVA `0x21d50`) сохраняет там +указатель текущего дескриптора, поэтому код-различитель — его текущая метка, а +не неизменная метка фабрики. + +В оригинальном Behavior внешняя строка имеет шаг `0x34`; её `+4` получает +порядковый номер заголовка. Строка-кандидат имеет шаг `0x58`, а её `+8` хранит +порядковый номер кандидата. Номера строк с меткой `30` добавляются в отдельный +вектор последней внешней строки. Rust публикует только эти начальные номера: +`MissionBehaviorControlGroup` и +`MissionBehaviorControlGrouping` группируют строки из уже подготовленной +Control-таблицы в её исходном порядке. `MissionBehaviorControlGroupingState` +сохраняет `original_id`, `ObjectHandle` и либо результат, либо исходную причину +недоступности Control-таблицы. Полная загрузка создаёт проекцию перед публикацией +`LoadedMissionState`; предварительный просмотр возвращает пустой список. Проверки +`behavior_control_grouping_uses_current_tags_and_table_ordinals` и игнорируемый +`full_load_builds_initial_behavior_control_groupings_for_autodemo` покрывают +искусственные граничные случаи и сохранённые номера строк AutoDemo соответственно. + +Это начальное разложение подготовленного списка, а не реконструкция полной +инициализации Behavior: отдельные свойства строки-кандидата, её числовые поля, +эффекты и обновление списка после изменений Control здесь не моделируются. + ### Изменение provider-состояния Control Для `Control.dll` (SHA-256 diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index aa8fd96..3af2c21 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -260,6 +260,25 @@ Rust-тест повторно читает лицензированную миÑ TMA-записями и проверяет реальные `ObjectHandle`, но помечен `#[ignore]`, чтобы не требовать оригинальных ресурсов от остальных разработчиков. +После подготовки Control-таблицы можно разобрать и её начальную группировку для +Behavior. Важны текущая метка записи и её порядковый номер в общей таблице: +метка `1` начинает пустую группу, метка `2` становится кандидатом последней +группы, а метка `30` записывает строку в отдельный список той же группы. +Кандидат, встретившийся до первого заголовка, остаётся в списке без группы; +ранняя метка `30` игнорируется. Например, +для текущих меток `[2, 30, 1, 30, 2]` получаются незагруппированный кандидат +`[0]` и группа с заголовком `2`, кандидатом `[4]` и строкой с меткой `30` под +порядковым номером `[3]`. Все номера начинаются с нуля и отсчитываются по общей +Control-таблице, а не по ключам устройств или записям CTLD. + +Полная загрузка Rust сохраняет это начальное состояние рядом с подготовленными +Control-таблицами по исходному TMA ID и `ObjectHandle`. Если таблица недоступна, +проекция сохраняет причину ошибки; доступная пустая таблица означает пустую +группировку. При предварительном просмотре список группировок не создаётся. Здесь +разбирается только подготовленный порядок текущих Control-дескрипторов. Проекция +не устанавливает, когда список присоединяется к живому Behavior, как он затем +обновляется и как работает полный игровой цикл. + Список распределителя хранит не только участников, но и долю каждого. При добавлении двух объектов оба получают по половине общего веса. Если добавить третий, движок оставляет долю новой записи около одной трети, а оставшийся вес |
