From 6412c12c09e9f9b9e1fa85eba0a2170afa86f405 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 31 Oct 2026 07:47:32 +0400 Subject: feat(runtime): restore Control selector 15 --- crates/fparkan-runtime/src/lib.rs | 322 +++++++++++++++++++++++++++++++++++++- docs/appendices/script-vm.md | 65 ++++++++ docs/tomes/06-behavior.md | 44 ++++-- 3 files changed, 416 insertions(+), 15 deletions(-) diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 1bf4841..17d942f 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -2132,9 +2132,10 @@ pub fn loaded_mission_control_device_tables( .map(|state| state.control_device_tables.as_slice()) } -/// Queries native Control global-table selector 1 (weighted ratio) or 2 (sum). +/// 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. Other unsupported native paths are reported as errors. +/// zero-denominator table or selector 15 without both cached factory rows. Other +/// unsupported native paths are reported as errors. /// Inputs and intermediates must be finite and normal, or exact zero. /// /// # Errors @@ -4201,7 +4202,7 @@ fn query_control_device_table_resource( table: &MissionControlDeviceTableState, selector: u32, ) -> Result, ControlResourceAccessError> { - if !matches!(selector, 1 | 2) { + if !matches!(selector, 1 | 2 | 15) { return Err(ControlResourceAccessError::UnsupportedSelector(selector)); } let entries = match &table.status { @@ -4211,6 +4212,32 @@ fn query_control_device_table_resource( } }; + if selector == 15 { + let Some(class9) = entries.iter().rev().find(|entry| entry.factory_tag == 9) else { + return Ok(None); + }; + let Some(class21) = entries.iter().rev().find(|entry| entry.factory_tag == 21) else { + return Ok(None); + }; + + let mut sum = control_device_budget_coefficient(class21, 5)?; + for index in (0..5).rev() { + sum = checked_control_sum( + 21, + "sum", + sum, + control_device_budget_coefficient(class21, index)?, + )?; + } + return checked_control_product( + 9, + "selector-15 value", + control_device_selector_zero(class9)?, + sum, + ) + .map(Some); + } + let mut numerator = 0.0_f32; let mut denominator = 0.0_f32; for entry in entries.iter().filter(|entry| entry.descriptor.tag == 19) { @@ -8148,6 +8175,251 @@ mod tests { ); } + #[test] + fn control_resource_selector_15_uses_last_cached_factories_and_current_fields() { + let mut first_class9 = synthetic_effective_control_device( + 9, + 19, + 90.0_f32.to_bits(), + Some(f32::INFINITY.to_bits()), + Some(9.0_f32.to_bits()), + ); + let mut first_class21 = synthetic_effective_control_device( + 21, + 19, + 90.0_f32.to_bits(), + Some(f32::INFINITY.to_bits()), + None, + ); + let mut class9 = synthetic_effective_control_device( + 9, + 5, + 99.0_f32.to_bits(), + Some(f32::NAN.to_bits()), + Some(99.0_f32.to_bits()), + ); + let mut class21 = synthetic_effective_control_device( + 21, + 26, + 90.0_f32.to_bits(), + Some(f32::INFINITY.to_bits()), + None, + ); + first_class9.native_word_50_bits = Some(0); + first_class21.native_word_50_bits = Some(0); + class9.native_word_50_bits = Some(0); + class21.native_word_50_bits = Some(0); + class9.key = u32::MAX; + class21.key = u32::MAX; + class9.coefficient_bits.as_mut().unwrap()[0] = 3.0_f32.to_bits(); + class9.class9_selector0_scale_bits = Some(4.0_f32.to_bits()); + class21.coefficient_bits.as_mut().unwrap()[..6].copy_from_slice(&[ + 1.0_f32.to_bits(), + 2.0_f32.to_bits(), + 3.0_f32.to_bits(), + 4.0_f32.to_bits(), + 5.0_f32.to_bits(), + 6.0_f32.to_bits(), + ]); + + let table = synthetic_control_device_table_with_providers( + MissionControlDeviceTableStatus::Available(vec![ + first_class9, + first_class21, + class9, + class21, + ]), + MissionControlNodeProviderTableStatus::Unavailable( + MissionControlNodeProviderTableUnavailableReason::MissingNodeData { + unit_component_index: 0, + source: None, + }, + ), + ); + + assert_eq!( + query_control_device_table_resource(&table, 15) + .expect("selector 15 uses current cached getter values") + .map(f32::to_bits), + Some(252.0_f32.to_bits()) + ); + } + + #[test] + fn control_resource_selector_15_preserves_descending_binary32_accumulation() { + let mut class9 = synthetic_effective_control_device( + 9, + 19, + 1.0_f32.to_bits(), + None, + Some(1.0_f32.to_bits()), + ); + let mut class21 = synthetic_effective_control_device(21, 19, 0, None, None); + class9.coefficient_bits.as_mut().unwrap()[0] = 1.0_f32.to_bits(); + class21.coefficient_bits.as_mut().unwrap()[..6].copy_from_slice(&[ + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + (-16_777_216.0_f32).to_bits(), + 16_777_216.0_f32.to_bits(), + 1.0_f32.to_bits(), + ]); + let forward = class21.coefficient_bits.unwrap()[..6] + .iter() + .map(|bits| f32::from_bits(*bits)) + .fold(0.0_f32, |sum, value| sum + value); + assert_eq!(forward.to_bits(), 1.0_f32.to_bits()); + + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9.clone(), + class21.clone(), + ])); + assert_eq!( + query_control_device_table_resource(&table, 15) + .expect("descending native order") + .map(f32::to_bits), + Some(0.0_f32.to_bits()) + ); + + class21.coefficient_bits.as_mut().unwrap()[..6].fill((-0.0_f32).to_bits()); + let negative_zero = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9, class21, + ])); + assert_eq!( + query_control_device_table_resource(&negative_zero, 15) + .expect("negative zero is retained") + .map(f32::to_bits), + Some((-0.0_f32).to_bits()) + ); + } + + #[test] + fn control_resource_selector_15_returns_no_result_without_both_cached_factories() { + let empty = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(Vec::new())); + assert_eq!(query_control_device_table_resource(&empty, 15), Ok(None)); + + let class9_only = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + synthetic_effective_control_device(9, 35, 1, None, None), + ])); + assert_eq!( + query_control_device_table_resource(&class9_only, 15), + Ok(None) + ); + + let mut class21 = synthetic_effective_control_device(21, 35, 1, None, None); + class21.coefficient_bits = None; + let class21_only = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class21, + ])); + assert_eq!( + query_control_device_table_resource(&class21_only, 15), + Ok(None) + ); + } + + #[test] + fn control_resource_selector_15_reports_missing_and_unsupported_inputs() { + let mut class9 = synthetic_effective_control_device( + 9, + 19, + 1.0_f32.to_bits(), + None, + Some(1.0_f32.to_bits()), + ); + let mut class21 = synthetic_effective_control_device(21, 19, 1.0_f32.to_bits(), None, None); + class21.coefficient_bits = None; + let missing_coefficients = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9.clone(), + class21.clone(), + ])); + assert_eq!( + query_control_device_table_resource(&missing_coefficients, 15), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 21, + field: "coefficient table", + }) + ); + + class21.coefficient_bits = Some([1.0_f32.to_bits(); 16]); + class9.coefficient_bits = None; + let missing_class9_coefficient = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9.clone(), + class21.clone(), + ])); + assert_eq!( + query_control_device_table_resource(&missing_class9_coefficient, 15), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 9, + field: "coefficient", + }) + ); + + class9.coefficient_bits = Some([1.0_f32.to_bits(); 16]); + class9.class9_selector0_scale_bits = None; + let missing_scale = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9.clone(), + class21.clone(), + ])); + assert_eq!( + query_control_device_table_resource(&missing_scale, 15), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 9, + field: "class-9 selector-0 scale", + }) + ); + + class9.class9_selector0_scale_bits = Some(1.0_f32.to_bits()); + class21.coefficient_bits.as_mut().unwrap()[5] = f32::INFINITY.to_bits(); + let nonfinite = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9.clone(), + class21.clone(), + ])); + assert_eq!( + query_control_device_table_resource(&nonfinite, 15), + Err(ControlResourceAccessError::NonFiniteValue { + factory_tag: 21, + field: "coefficient", + }) + ); + + class21.coefficient_bits.as_mut().unwrap()[5] = 1; + let subnormal = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9.clone(), + class21.clone(), + ])); + assert_eq!( + query_control_device_table_resource(&subnormal, 15), + Err(ControlResourceAccessError::SubnormalValue { + factory_tag: 21, + field: "coefficient", + }) + ); + + class21.coefficient_bits.as_mut().unwrap()[5] = f32::MAX.to_bits(); + class21.coefficient_bits.as_mut().unwrap()[4] = f32::MAX.to_bits(); + let overflowing_sum = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9, class21, + ])); + assert_eq!( + query_control_device_table_resource(&overflowing_sum, 15), + Err(ControlResourceAccessError::UnsupportedFloatRange { + factory_tag: Some(21), + field: "sum", + }) + ); + } + #[test] fn control_resource_setter_updates_matching_rows_atomically_and_round_trips() { let mut table = @@ -13276,6 +13548,50 @@ mod tests { } } + #[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"), + )))) + .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 tables = loaded_mission_control_device_tables(&engine).expect("Control tables"); + let expected = [ + (0, Some(0x4657_2800)), + (1, Some(0x4636_d000)), + (2, Some(0x467b_4001)), + (3, Some(0x468c_a000)), + (4, Some(0x46ec_4001)), + (5, Some(0x46ec_4001)), + (6, None), + (7, None), + ]; + assert_eq!(tables.len(), expected.len()); + for (original_id, expected_bits) in expected { + let table = tables + .iter() + .find(|table| table.original_id == Some(OriginalObjectId(original_id))) + .expect("AutoDemo Control table for original object"); + let actual = query_loaded_mission_control_resource(&engine, table.handle, 15) + .expect("selector 15 query") + .map(f32::to_bits); + assert_eq!(actual, expected_bits, "original object {original_id}"); + } + } + #[test] #[ignore = "requires licensed corpus"] fn headless_step_runs_resource_schedule_and_rolls_back_both_error_paths() { diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index be8c8b3..26ac234 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -1060,6 +1060,71 @@ factory-класса. В Rust неизвестный текущий тег за CPU-вызов, ни локальные векторы не означают включения бюджетного шага в Control clock. +#### Глобальный Control getter: сырой селектор `0x0f` + +В `Control.dll` SHA-256 +`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e` +`FUN_1002b410` (RVA `0x2b410`) принимает сырой селектор в `EDX` и указатель +результата из `[ESP+4]`; общий выход использует `RET 4`. Сырой селектор `0x0f` +попадает в case RVA `0x2b8f5`. Владелец хранит ссылки на объекты, добавленные +фабриками `9` (`owner+0x5cc`) и `21` (`owner+0x5d0`). + +Фабрика `21` вызывает общий getter `FUN_10021d00` (RVA `0x21d00`): selectors +`0..5` читают шесть текущих `coefficient_bits` по `this+0x54`. Общий загрузчик +RVA `0x21d50` первоначально копирует descriptor words `[11..16]` в это поле. Фабрика `9` +использует getter `FUN_100255c0` (RVA `0x255c0`), который для selector `0` +умножает коэффициент `[this+0x54]` на множитель `[this+0x11c]`. Поэтому Rust +выбирает последнюю строку каждого `factory_tag` `9` и `21` в исходном порядке +таблицы; текущий `descriptor.tag` не меняет выбранную фабрику. Если любой строки нет, +результат отсутствует до проверки полей другой строки. + +Арифметика case `0x0f` сохраняет порядок и отдельное округление каждого шага: + +```text +sum = f32(D5) +for k = 4, 3, 2, 1, 0: + sum = f32(sum + Dk) +out = f32(C0 * sum) +``` + +`Dk` — текущий коэффициент `k` из `coefficient_bits` фабрики `21`; `C0` — +selector `0` фабрики `9`, то есть +`coefficient_bits[0] * class9_selector0_scale_bits`. Общие `multiplier_bits`, `node_provider_status` и +`native_word_50_bits` в этом обратном вызове не читаются. Rust проверяет, что +входы и промежуточные результаты конечны и являются обычными числами либо +точным нулём; переполнение, денормализованные числа и отсутствующие поля +остаются типизированными ошибками. Диапазон +проверяется через `f64`, а затем операция выполняется в `f32` с округлением +после каждого сложения. Это воспроизводит описанные записи `f32`, но не +моделирует полное управляющее состояние x87. Этот getter не реализует весь Control +metadata `0x36`, Handler `43` или каталог свойств. + +Лицензионный acceptance-test загружает `MISSIONS/Autodemo.00/data.tma` с явным +`MissionStartupInput { queue_time_ms: 100_000, research_creation_gate: false }` +и вызывает публичный `query_loaded_mission_control_resource(..., 15)`. Native +вызов исходного `Control.dll` того же SHA выполнен с `executionCW=0x007f` и +получил следующие биты на Rust-подготовленном снимке входных полей: + +| `original_id` | Результат selector `0x0f` | +| --- | --- | +| `0` | `0x46572800` | +| `1` | `0x4636d000` | +| `2` | `0x467b4001` | +| `3` | `0x468ca000` | +| `4` | `0x46ec4001` | +| `5` | `0x46ec4001` | + +Для `original_id` `6` и `7` таблица Rust не содержит обоих требуемых объектов +фабрик (`factory_tag` `9` и `21`), поэтому публичный запрос возвращает `None`. +Исходный обратный вызов для этих двух объектов отдельно не запускался; ветвь +неуспеха native getter-а проверена на отдельном векторе с отсутствующим указателем. + +Сравнение шести значений проверяет формулу getter-а на исходных игровых ресурсах +и снимке входных полей, подготовленном Rust. Оно не снимает состояние фабрик, +созданных оригиналом. Время и флаг `queue_time_ms` и +`research_creation_gate` задаёт тест, поэтому эти значения не подтверждают +начальное состояние запуска AutoDemo в оригинале. + #### Фабричный tag 26: изменяемая добавка В этой сборке `Control.dll` SHA-256 diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index 8000710..aa8fd96 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -992,21 +992,41 @@ descriptor tag `19` и положительным selector-0 ответом; п его vtable slot `+0x10` статически указывает на общий getter RVA `0x21d00`, но отдельный aggregate oracle этот класс не запускал. Rust хранит начальный scale factory `9` и предоставляет `query_loaded_mission_control_resource` для selector -`1`/`2` и `set_loaded_mission_control_multiplier` для setter-а `1`. Вызовы -сначала проверяют живой `ObjectHandle`, затем обращаются к таблице этого объекта -из полной загрузки миссии. Синтетические тесты проверяют границы пустой таблицы, -нулевого знаменателя, неподдержанных factory tags и чисел за диапазоном -нормального `f32`; лицензированный тест сверяет реальные descriptor tags и -получает для `min_l_n1.dat` сумму `16` и отношение `1` до setter-а. - -В этом API пока реализованы только getter selectors `1`/`2` и setter `1`. +`1`, `2` и `15`, а также `set_loaded_mission_control_multiplier` для setter-а +`1`. Вызовы сначала проверяют живой `ObjectHandle`, затем обращаются к таблице +этого объекта из полной загрузки миссии. Синтетические тесты проверяют границы +пустой таблицы, нулевого знаменателя, отсутствующих объектов фабрик и чисел за +диапазоном нормального `f32`; лицензированный тест сверяет реальные descriptor +tags и получает для `min_l_n1.dat` сумму `16` и отношение `1` до setter-а. + +Сырой селектор `0x0f` соединяет два ранее созданных объекта владельца: у строки +с `factory_tag = 9` берётся коэффициент и его текущий множитель, а у строки с +`factory_tag = 21` — шесть текущих `coefficient_bits`. `INTO` может поменять +`descriptor.tag`, не заменяя объект фабрики, поэтому query выбирает последние +строки по `factory_tag`, а не по текущему tag. Если любой объект фабрики +отсутствует, ответ — `None`. Иначе шесть коэффициентов складываются с индекса `5` +к `0` с округлением до `f32` после каждого сложения; результат умножается на +selector `0` фабрики `9` (`coefficient_bits[0] * class9_selector0_scale_bits`). +Общие `multiplier_bits`, `node_provider_status` и `native_word_50_bits` в этой +формуле не участвуют. + +Лицензионная проверка загружает AutoDemo с явно переданными +`MissionStartupInput { queue_time_ms: 100_000, research_creation_gate: false }` +и вызывает `query_loaded_mission_control_resource(..., 15)` для исходных +объектов `0..5`; для `6` и `7` Rust возвращает `None`, потому что не хватает как +минимум одного объекта фабрики. Native-биты для `0..5` приведены в технической +главе. Проверка сверяет результат исходного геттера на снимке входных полей, +подготовленном Rust. Время и флаг заданы самим тестом; они не доказывают +начальный режим запуска AutoDemo в оригинале и не являются захватом его фабрик +или состояния объекта Control. + Отдельный native setter selector `17`, tag-specific методы прочих factory tags, их внешние эффекты и вызов из регулярного шага Behavior здесь не моделируются. Числа должны оставаться конечными и нормальными либо точным нулём; произведения -и отношения за поддерживаемым диапазоном `f32` возвращают явную ошибку, а не -подменяются нулём или `None`. При проверке диапазона Rust оценивает операцию в -`f64`, затем выполняет её в `f32`; это ограниченная проверка диапазона, а не -эмуляция полного x87 accumulator-а. +и суммы за поддерживаемым диапазоном `f32` возвращают явную ошибку, а не +подменяются нулём или `None`. При проверке диапазона Rust сначала оценивает +границы в `f64`, затем выполняет каждую операцию в `f32`; это сохраняет +пошаговое округление этого getter-а, но не эмулирует весь x87 execution state. Отдельная ветка того же GOG `AniMesh.dll` показывает, когда создаётся Behavior для host object. В `FUN_10002ff0` (RVA `0x2ff0`, SHA-256 -- cgit v1.2.3