diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index a90808c..d0a5ff9 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -41,6 +41,11 @@ pub use fparkan_assets::MissionAssets; const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256; 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; +const CONTROL_CLASS_SOURCE_BTLU: u32 = u32::from_le_bytes(*b"BTLU"); +const CONTROL_CLASS_SOURCE_BULL: u32 = u32::from_le_bytes(*b"BULL"); +const CONTROL_CLASS_SOURCE_WPNS: u32 = u32::from_le_bytes(*b"WPNS"); +const CONTROL_CLASS_SOURCE_STAT: u32 = u32::from_le_bytes(*b"STAT"); /// Injectable engine services used by composition roots. #[derive(Clone, Default)] @@ -454,6 +459,8 @@ pub struct MissionControlDeviceTableState { pub handle: ObjectHandle, /// Complete base projection, or an explicit unsupported boundary. pub status: MissionControlDeviceTableStatus, + /// Native Control object class chosen by the full Unit DAT load route. + pub native_control_class: Option, /// Raw CTLD profile retained by native component ID zero and its per-owner copy. pub owner_profile: Option, /// Global NDP provider rows used by indexed Control selectors. @@ -472,6 +479,24 @@ pub struct MissionControlDeviceTableState { pub current_output_rate_5e0_bits: u32, } +/// Provenance for the class passed to Control's constructor. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MissionControlClassSource { + /// The Unit DAT class word's high bit selected the World -> Terrain route. + UnitDatHighBit { header_flags: u32 }, + /// The root prototype NRes registry entry selected the direct AniMesh route. + RootPrototypeFourCc { source_type: u32 }, +} + +/// Native Control object class selected during full Unit DAT loading. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MissionControlClassProjection { + /// The class argument received by `Control.dll!LoadControlSystem`. + pub class_id: u32, + /// Source branch and raw value that selected the class. + pub source: MissionControlClassSource, +} + /// CTLD owner profile selected by native component ID zero. #[derive(Clone, Debug, Eq, PartialEq)] pub struct MissionControlOwnerProfile { @@ -1518,13 +1543,19 @@ fn load_mission_with_options( .zip(&handles) .enumerate() .map(|(object_index, (draft, handle))| { + let prototype_span = prototype_graph + .root_prototype_request_spans + .get(object_index); + let native_control_class = native_control_class_for_root( + draft.unit_dat_header_flags, + prototype_span, + &resolved_prototypes, + ); let projection = build_control_device_projection( object_index, draft, &mission_assets, - prototype_graph - .root_prototype_request_spans - .get(object_index), + prototype_span, &resolved_prototypes, ); match projection { @@ -1532,6 +1563,7 @@ fn load_mission_with_options( original_id: draft.original_id, handle: *handle, status: MissionControlDeviceTableStatus::Available(projection.devices), + native_control_class, owner_profile: projection.owner_profile, node_provider_maximum_amount_bits: initial_node_provider_maximum_amount( &projection.node_provider_status, @@ -1547,6 +1579,7 @@ fn load_mission_with_options( original_id: draft.original_id, handle: *handle, status: MissionControlDeviceTableStatus::Unavailable(reason.clone()), + native_control_class, owner_profile: None, node_provider_maximum_amount_bits: None, node_provider_amount_bits: None, @@ -4996,6 +5029,37 @@ fn native_ex_to_append_key( Some(raw_key.wrapping_add(first_global_node).wrapping_sub(1)) } +fn native_control_class_for_root( + unit_dat_header_flags: Option, + prototype_span: Option<&std::ops::Range>, + prototypes: &[fparkan_prototype::EffectivePrototype], +) -> Option { + let header_flags = unit_dat_header_flags?; + if header_flags & UNIT_DAT_CONTROL_CLASS_HIGH_BIT != 0 { + return Some(MissionControlClassProjection { + class_id: 3, + source: MissionControlClassSource::UnitDatHighBit { header_flags }, + }); + } + + let span = prototype_span?; + if span.start >= span.end { + return None; + } + let source_type = prototypes.get(span.start)?.source_type?; + let class_id = match source_type { + CONTROL_CLASS_SOURCE_BTLU => 4, + CONTROL_CLASS_SOURCE_BULL => 9, + CONTROL_CLASS_SOURCE_WPNS => 2, + CONTROL_CLASS_SOURCE_STAT => 10, + _ => 0, + }; + Some(MissionControlClassProjection { + class_id, + source: MissionControlClassSource::RootPrototypeFourCc { source_type }, + }) +} + fn active_unit_component_order( records: &[UnitComponentRecord], tree: &[UnitComponentTreeNode], @@ -6293,6 +6357,29 @@ mod tests { (draft, assets, prototypes) } + fn synthetic_control_class_fixture( + source_types: &[Option], + ) -> ( + MissionObjectDraft, + Vec, + ) { + let component_count = source_types.len(); + let mut child_counts = vec![0; component_count]; + if let Some(root_child_count) = child_counts.first_mut() { + *root_child_count = + u32::try_from(component_count.saturating_sub(1)).expect("child count fits u32"); + } + let (draft, _, prototypes) = synthetic_control_fixture( + source_types, + &vec![1; component_count], + &child_counts, + &vec![0; component_count], + &vec![1; component_count], + vec![None; component_count], + ); + (draft, prototypes) + } + fn synthetic_control_table( source_types: &[Option], kinds: &[u32], @@ -6419,6 +6506,7 @@ mod tests { slot: 7, }, status, + native_control_class: None, owner_profile: None, node_provider_maximum_amount_bits: initial_node_provider_maximum_amount( &node_provider_status, @@ -9021,6 +9109,82 @@ mod tests { assert_eq!(native_into_append_key(0x1234), 0x1234); } + #[test] + fn native_control_class_highbit_selects_class_three_before_root_fourcc() { + let btlu = u32::from_le_bytes(*b"BTLU"); + let unknown = u32::from_le_bytes(*b"ZZZZ"); + for root_source_type in [Some(btlu), Some(unknown), None] { + let (mut draft, prototypes) = + synthetic_control_class_fixture(&[root_source_type, Some(btlu)]); + draft.unit_dat_header_flags = Some(0x8000_0004); + let span = 0..prototypes.len(); + assert_eq!( + native_control_class_for_root( + draft.unit_dat_header_flags, + Some(&span), + &prototypes + ), + Some(MissionControlClassProjection { + class_id: 3, + source: MissionControlClassSource::UnitDatHighBit { + header_flags: 0x8000_0004, + }, + }), + "Unit DAT high-bit route overrides root source type {root_source_type:?}" + ); + } + } + + #[test] + fn native_control_class_clear_route_uses_root_registry_fourcc() { + let source_types = [ + u32::from_le_bytes(*b"BTLU"), + u32::from_le_bytes(*b"BULL"), + u32::from_le_bytes(*b"WPNS"), + u32::from_le_bytes(*b"STAT"), + u32::from_le_bytes(*b"ZZZZ"), + ]; + let expected_classes = [4, 9, 2, 10, 0]; + let (draft, prototypes) = synthetic_control_class_fixture(&source_types.map(Some)); + + for (index, (source_type, class_id)) in + source_types.into_iter().zip(expected_classes).enumerate() + { + let span = index..index + 1; + assert_eq!( + native_control_class_for_root( + draft.unit_dat_header_flags, + Some(&span), + &prototypes + ), + Some(MissionControlClassProjection { + class_id, + source: MissionControlClassSource::RootPrototypeFourCc { source_type }, + }), + "source type {source_type:08x} comes from the span's root entry" + ); + } + } + + #[test] + fn native_control_class_requires_root_metadata_and_never_uses_child_fourcc() { + let btlu = u32::from_le_bytes(*b"BTLU"); + let (mut draft, prototypes) = synthetic_control_class_fixture(&[None, Some(btlu)]); + let span = 0..prototypes.len(); + + assert_eq!( + native_control_class_for_root(draft.unit_dat_header_flags, Some(&span), &prototypes), + None, + "a child entry cannot substitute for missing root registry metadata" + ); + draft.unit_dat_header_flags = None; + assert_eq!( + native_control_class_for_root(draft.unit_dat_header_flags, Some(&span), &prototypes), + None, + "missing Unit DAT class word must remain explicit" + ); + } + #[test] fn exto_key_mapping_obeys_native_component_and_mount_sentinels() { assert_eq!(native_ex_to_append_key(2, 0, None, Some(20)), Some(2)); @@ -9896,6 +10060,15 @@ mod tests { let mine_control_before = loaded_mission_control_device_tables(&mine_engine) .expect("licensed mine Control table") .to_vec(); + assert_eq!( + mine_control_before[0].native_control_class, + Some(MissionControlClassProjection { + class_id: 3, + source: MissionControlClassSource::UnitDatHighBit { + header_flags: 0x8000_0004, + }, + }) + ); let mine_prepared_controls = &mine_engine .loaded .as_ref() @@ -11820,6 +11993,7 @@ mod tests { original_id: Some(OriginalObjectId(original_id)), handle, status: MissionControlDeviceTableStatus::Available(vec![device]), + native_control_class: None, owner_profile: None, node_provider_status: MissionControlNodeProviderTableStatus::Available(Vec::new()), node_provider_maximum_amount_bits: Some(0), @@ -11929,6 +12103,7 @@ mod tests { ), ore_device, ]), + native_control_class: None, owner_profile: None, node_provider_status: MissionControlNodeProviderTableStatus::Available(vec![ synthetic_node_provider_row(0, 0, 1.0_f32.to_bits()), @@ -11984,6 +12159,7 @@ mod tests { original_id: Some(object.original_id), handle: object.handle, status: MissionControlDeviceTableStatus::Available(Vec::new()), + native_control_class: None, owner_profile: None, node_provider_status: MissionControlNodeProviderTableStatus::Available(Vec::new()), node_provider_maximum_amount_bits: Some(0), diff --git a/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs b/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs index d92c46a..d72fd57 100644 --- a/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs +++ b/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs @@ -10,6 +10,7 @@ using System.Text; // slots, and bounded vector backing are substituted; tested module code remains installed. internal static class BehaviorDistributorTickOracle { private const uint LoadWithAlteredSearchPath = 0x00000008; + private const uint DontResolveDllReferences = 0x00000001; private const uint MemCommitReserve = 0x00003000; private const uint PageExecuteReadWrite = 0x40; private const uint MemRelease = 0x00008000; @@ -18,7 +19,9 @@ internal static class BehaviorDistributorTickOracle { private const string ExpectedControlSha256 = "bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e"; private const string ExpectedArealMapSha256 = "a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea"; private const string ExpectedAniMeshSha256 = "65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e"; + private const string ExpectedTerrainSha256 = "af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead"; private const int TickRva = 0x19e80; + private const int TerrainClassGetterRva = 0x33ae0; private const int DistributorScheduleRva = 0x1adb0; private const int AddObjectRva = 0x1aac0; private const int NormalizeRva = 0x1ac90; @@ -96,6 +99,7 @@ internal static class BehaviorDistributorTickOracle { [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void X87ControlWordReader(); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void X87ControlWordWriter(); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void TickDelegate(IntPtr self, float deltaSeconds); + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate uint TerrainClassGetter(IntPtr self); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void NativeBehaviorSchedule(IntPtr self); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate IntPtr QueueGetter(); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate uint QueueClockGetter(IntPtr self); @@ -579,8 +583,31 @@ internal static class BehaviorDistributorTickOracle { return sequence[Math.Min(index, sequence.Length - 1)]; } + private static int ProbeTerrainClass() { + string terrainPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_TERRAIN_DLL"); + if (String.IsNullOrEmpty(terrainPath)) terrainPath = @"C:\GOG Games\Parkan - Iron Strategy\Terrain.dll"; + if (!File.Exists(terrainPath)) { Console.Error.WriteLine("Set FPARKAN_ORIGINAL_TERRAIN_DLL to the installed Terrain.dll."); return 3; } + string hash = Hash(terrainPath); + if (hash != ExpectedTerrainSha256) { Console.Error.WriteLine("Unexpected Terrain.dll SHA-256: " + hash); return 4; } + IntPtr module = LoadLibraryEx(terrainPath, IntPtr.Zero, DontResolveDllReferences); + if (module == IntPtr.Zero) { Console.Error.WriteLine("LoadLibraryEx(Terrain.dll) failed: " + Marshal.GetLastWin32Error()); return 5; } + try { + IntPtr getterAddress = IntPtr.Add(module, TerrainClassGetterRva); + byte[] getterBytes = new byte[6]; Marshal.Copy(getterAddress, getterBytes, 0, getterBytes.Length); + string code = BitConverter.ToString(getterBytes).Replace("-", ""); + if (code != "B803000000C3") { Console.Error.WriteLine("Unexpected Terrain getter bytes: " + code); return 6; } + TerrainClassGetter getter = (TerrainClassGetter)Marshal.GetDelegateForFunctionPointer(getterAddress, typeof(TerrainClassGetter)); + uint classId = getter(IntPtr.Zero); + if (classId != 3) { Console.Error.WriteLine("Unexpected Terrain class ID: " + classId); return 7; } + Console.WriteLine("PASS sha256=" + hash + " rva=0x33AE0 bytes=" + code + " this=null class=3"); + return 0; + } finally { FreeLibrary(module); } + } + private static int Main() { if (IntPtr.Size != 4) { Console.Error.WriteLine("Build with /platform:x86."); return 2; } + int terrainClass = ProbeTerrainClass(); + if (terrainClass != 0) return terrainClass; return RunWithIsolatedWorkingDirectory(); } diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index e73ba68..be2277a 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -202,8 +202,8 @@ Unit DAT создаёт тот же Behavior-компонент во всех ho prototype roots без Unit DAT и отложенные spawn после исходной загрузки вне этой модели. Путь в `ArealMap.dll` (SHA-256 `a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`) к -`CreateObject` по RVA `0x14fd0`/`0x15290` показывает выбор World3D class 3 или 4 -по старшему биту. Для ветки GOG `AniMesh.dll` (SHA-256 +`CreateObject` по RVA `0x14fd0`/`0x15290` показывает выбор World3D factory +selector `3` или `4` по старшему биту. Для ветки GOG `AniMesh.dll` (SHA-256 `65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`) RVA `0x2ff0` показывает одну ветку создания: после QueryInterface для IID `0x204` выполняется отдельный импорт `GetQueue()`, а на возвращённом объекте вызывается @@ -247,6 +247,24 @@ CTLD и связанные resources структурно читаются, coun известны. Не названы все секции, shape types, coefficients и точный contact solver. То же относится к редким MSH auxiliary streams и части CTPT/NDPR flags. +### Класс Control при загрузке Unit DAT + +Для двух подтверждённых полных Unit-DAT путей Rust теперь сохраняет класс, +который передаётся конструктору Control, вместе с ветвью-источником. Лицензированный +`min_l_n1.dat` проходит high-bit route и даёт класс `3`; synthetic-тесты покрывают +оба маршрута, известные FourCC и отсутствующие root metadata. Отдельное +исполнение проверило только чистый getter `Terrain.dll+0x33ae0` (SHA-256 и точный +RVA приведены в технической главе). Остальная цепочка через ArealMap, World3D, +Terrain/AniMesh и создание controller восстановлена по статическим инструкциям, +таблицам и конфигурации. Она не означает исполнения оригинального полного +loader-а, Control activation, движения или периодического tick. + +Не моделируются произвольный ненулевой host с неудачным QueryInterface, preview +без полного Unit DAT, отложенный spawn и другие host/factory routes. В частности, +generic failed-query путь native оставляет class `0` и не читает FourCC; это не +эквивалентно canonical no-host lookup корневой registry entry. Остаточные вопросы +об активации и игровом поведении описаны в технической главе. + Одна цепочка Unit DAT → AniMesh descriptor → Control теперь восстановлена статически в [главе о Behavior и Control](../tomes/06-behavior.md): raw `parent_or_link` копируется в descriptor. В non-`EXTO` registry path parent ID diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index a1e867d..d47a64d 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -977,6 +977,61 @@ Ignored Rust-тест также проходит полный неотфиль профилю. Это подтверждает декодирование и CPU-проекцию, но не исполнение native полного загрузчика. +#### Входной класс Control при загрузке Unit DAT + +Этот класс задаётся до разбора CTLD и не выводится из device tag или MSH- +геометрии. В GOG `ArealMap.dll` (SHA-256 +`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`) +`FUN_10014fd0` (RVA `0x14fd0`) вызывает полный путь `FUN_10015290` (RVA +`0x15290`), который читает class word разобранного корня Unit DAT по `+0x78`; +это смещение поля в структуре, а не raw file offset. Установленный старший бит +выбирает `World3D.CreateObject` selector `3`, снятый — selector `4`. В GOG +`World3D.dll` (SHA-256 +`17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e`) RVA +`0x7cb0` ведёт к внутренней реализации `0x7a50`, где selectors `3` и `4` +направляются соответственно в registered CID `1` и `3`. +`Comp.ini` (SHA-256 +`3ce88fc077fcc9659d7b87ed0caaa6885ba6d2e2307250cbc0eb75661c295df8`) +связывает CID `1` с `Terrain.LoadBuilding`, а CID `3` — с `AniMesh.LoadAgent`. +Selector, CID и класс Control — разные значения, хотя первый route приводит к +совпадающему числу `3`. + +У маршрута selector `3` внешний host-аргумент `Terrain.LoadBuilding` пуст. +`Terrain.dll` (SHA-256 +`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead`) +создаёт `CBuilding` через RVA `0x32490`/`0x324d0`, записывает пустой внешний +родитель в `+0x14`, затем передаёт сам `CBuilding` в `AniMesh.LoadAgent`. +`AniMesh+0x2ff0` вызывает `QueryInterface(6)` и получает обратно primary +указатель `CBuilding`. Его vtable slot `+0x2c` находится по Terrain RVA +`0x65548` и указывает на `Terrain.dll+0x33ae0`. Вызванные отдельно машинные инструкции +этого getter-а — `B8 03 00 00 00 C3`, то есть он возвращает класс `3`. +Постоянный x86 harness проверяет SHA-256, эти байты и прямой вызов getter-а с +`this = null`; это исполненная проверка getter-а. Связь от Unit DAT через +wrapper и vtable восстановлена статически, а не прогнана целиком в оригинальной +игре. + +При selector `4` `AniMesh.LoadAgent` получает пустой host. Конструктор начинает +Agent class field `+0x6d8` с нуля; путь `AniMesh.dll` (SHA-256 +`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`) RVA +`0x2ff0` берёт FourCC из корневой записи NRes registry. Дальше вызов +`LoadControlSystem` по AniMesh RVA `0x32e7` передаёт значение `+0x6d8` в качестве +последнего аргумента; `Control.dll` (SHA-256 +`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`) RVA +`0x32280` выбирает для класса `9` конструктор `0x7330`, а для остальных — +`0x31490`. + +Правило прямого no-host пути: `BTLU → 4`, `BULL → 9`, `WPNS → 2`, `STAT → 10`; +другой root FourCC даёт `0`. Источник — NRes registry metadata именно корневой +записи (`EffectivePrototype.source_type`), не первый DWORD CPT payload и не +запись ребёнка. Если заголовок отсутствует, класс Rust оставляет неопределённым +(`None`); при установленном старшем бите выбирается `3`, даже если FourCC нет. +Только в прямом no-host пути отсутствие root FourCC даёт `None`. У произвольного +ненулевого host, который не возвращает +нужный интерфейс, native field также остаётся `0`, но FourCC в этой ветви не +читается; этот generic route не считается одним из двух полных Unit-DAT путей. +Runtime сохраняет класс и источник выбора, не запуская Control clock или +конструкторы gameplay state. + #### Активация и начальные часы Для Control класса `4` oracle вызывает исходный конструктор RVA `0x31490` diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index 45a6303..68a35e6 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -662,6 +662,20 @@ parent ID через Control IID `0x202`, virtual slot `+0x44`, только е исходный ключ `1` становится `22`. Этот ключ выбирает модельный узел, а не строку Control. Записи корня остаются в исходном виде. +#### Входной класс контроллера + +Таблица устройств и класс самого Control-контроллера выбираются разными +частями загрузки. У полного объекта из Unit DAT старший бит флагов корня решает, +каким путём создать host. В ветви с этим битом объект проходит через Terrain; +эта оболочка передаёт AniMesh native-класс Control `3`. Если бит не установлен, +класс выбирает FourCC записи корневого ресурса: `BTLU` даёт `4`, `BULL` — `9`, +`WPNS` — `2`, `STAT` — `10`, а неизвестный FourCC оставляет класс `0`. + +Например, `min_l_n1.dat` хранит флаги `0x80000004`, поэтому его проекция +получает класс `3`. Это аргумент конструктора Control, а не номер строки +устройства и не само по себе включение движения или обновлений. Техническая +цепочка и границы проверки описаны в [приложении](../appendices/script-vm.md#входной-класс-control-при-загрузке-unit-dat). + ### Как Control добавляет и переопределяет устройства В `Control.dll` (SHA-256