diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 8ad25e8..633b6fe 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -7410,6 +7410,7 @@ mod tests { type_id: None, }, model_node_count: 1, + native_profile_words: [0; 27], bindings: vec![], timeline_rows: vec![fparkan_assets::PreparedControlTimelineRow { row_index: 0, diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index 94018d1..2ee829f 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/src/lib.rs @@ -1081,6 +1081,8 @@ pub struct PreparedVisual { pub struct PreparedComponentControl { /// CTLD resource that supplied these bindings. pub source: ResourceKey, + /// The 27 raw native owner-profile words at CTLD bytes 20..128. + pub native_profile_words: [u32; 27], /// Number of nodes in the component model this control targets. pub model_node_count: usize, /// Enabled source rows in their original order, including duplicate nodes. @@ -4191,6 +4193,19 @@ fn decode_component_control_payload( return Err(invalid("complete binding table extends past the payload")); } + let profile_bytes = payload + .get(HEADER_BYTES..HEADER_BYTES + FIXED_SECTION_BYTES) + .ok_or_else(|| invalid("fixed owner profile extends past the payload"))?; + let mut native_profile_words = [0_u32; FIXED_SECTION_BYTES / 4]; + for (index, word) in native_profile_words.iter_mut().enumerate() { + let offset = index * 4; + *word = u32::from_le_bytes( + profile_bytes[offset..offset + 4] + .try_into() + .expect("profile word is exactly four bytes"), + ); + } + let mut bindings = Vec::with_capacity(row_count); let mut timeline_rows = Vec::with_capacity(row_count); for row_index in 0..row_count { @@ -4253,6 +4268,7 @@ fn decode_component_control_payload( Ok(PreparedComponentControl { source, + native_profile_words, model_node_count, bindings, timeline_rows, @@ -4840,6 +4856,66 @@ mod tests { assert_eq!(control.bindings[1].flags, 1); } + #[test] + fn component_control_preserves_raw_profile_between_header_and_sections() { + const PROFILE_START: usize = 20; + const PROFILE_WORDS: usize = 27; + const SECTION_BYTES: usize = 156 + 16; + const MATRIX_BYTES: usize = 4; + const ROW_START: usize = PROFILE_START + PROFILE_WORDS * 4 + SECTION_BYTES + MATRIX_BYTES; + const ROW_BYTES: usize = 36; + + let source = ResourceKey { + archive: parse_path("controls.rlb").expect("archive"), + name: resource_name(b"profile.ctl"), + type_id: None, + }; + let mut payload = vec![0; ROW_START + ROW_BYTES]; + payload[0..4].copy_from_slice(&1_u32.to_le_bytes()); // one section + payload[4..8].copy_from_slice(&1_u32.to_le_bytes()); // one node per section + payload[8..12].copy_from_slice(&1_u32.to_le_bytes()); // one binding row + let profile = std::array::from_fn::<_, PROFILE_WORDS, _>(|index| match index { + 0 => 0xDEAD_BEEF, + 1 => 0x7FC0_1234, + index => 0xA500_0000 | u32::try_from(index).expect("index fits u32"), + }); + for (index, word) in profile.iter().enumerate() { + let start = PROFILE_START + index * 4; + payload[start..start + 4].copy_from_slice(&word.to_le_bytes()); + } + payload[ROW_START..ROW_START + 4].copy_from_slice(&1_u32.to_le_bytes()); + payload[ROW_START + 4..ROW_START + 8].copy_from_slice(&1.25_f32.to_bits().to_le_bytes()); + payload[ROW_START + 8..ROW_START + 12].copy_from_slice(&2.5_f32.to_bits().to_le_bytes()); + payload[ROW_START + 12..ROW_START + 16].copy_from_slice(&0.75_f32.to_bits().to_le_bytes()); + payload[ROW_START + 0x20..ROW_START + 0x24].copy_from_slice(&1_u32.to_le_bytes()); + + let control = + decode_component_control_payload(&payload, source, 2, 0).expect("profile CTLD"); + + assert_eq!(control.native_profile_words, profile); + assert_eq!(control.timeline_rows.len(), 1); + assert_eq!(control.timeline_rows[0].node_index, 1); + assert_eq!( + control.timeline_rows[0].frame_a.to_bits(), + 1.25_f32.to_bits() + ); + assert_eq!( + control.timeline_rows[0].frame_b.to_bits(), + 2.5_f32.to_bits() + ); + } + + #[test] + fn component_control_rejects_truncated_native_profile() { + let source = ResourceKey { + archive: parse_path("controls.rlb").expect("archive"), + name: resource_name(b"truncated-profile.ctl"), + type_id: None, + }; + + assert!(decode_component_control_payload(&vec![0; 127], source, 0, 0).is_err()); + } + #[test] fn component_control_rejects_bad_bounds_but_keeps_sampler_inputs_raw() { let source = ResourceKey { diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index d3ab573..a90808c 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -454,6 +454,8 @@ pub struct MissionControlDeviceTableState { pub handle: ObjectHandle, /// Complete base projection, or an explicit unsupported boundary. pub status: MissionControlDeviceTableStatus, + /// 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. pub node_provider_status: MissionControlNodeProviderTableStatus, /// Native `+0x58c`: total provider capacity in the supported unit-scale domain. @@ -470,8 +472,24 @@ pub struct MissionControlDeviceTableState { pub current_output_rate_5e0_bits: u32, } +/// CTLD owner profile selected by native component ID zero. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionControlOwnerProfile { + /// CTLD resource that supplied the profile words. + pub source: ResourceKey, + /// Unit DAT component that supplied this CTLD resource. + pub unit_component_index: usize, + /// Native component ID used by Control's profile-selection condition. + pub native_component_id: u32, + /// Original profile words held by the Control resource cache. + pub original_words: [u32; 27], + /// Initial per-owner copy; this is mutated independently from `original_words`. + pub current_words: [u32; 27], +} + struct ControlDeviceProjection { devices: Vec, + owner_profile: Option, node_provider_status: MissionControlNodeProviderTableStatus, } @@ -1514,6 +1532,7 @@ fn load_mission_with_options( original_id: draft.original_id, handle: *handle, status: MissionControlDeviceTableStatus::Available(projection.devices), + owner_profile: projection.owner_profile, node_provider_maximum_amount_bits: initial_node_provider_maximum_amount( &projection.node_provider_status, ), @@ -1528,6 +1547,7 @@ fn load_mission_with_options( original_id: draft.original_id, handle: *handle, status: MissionControlDeviceTableStatus::Unavailable(reason.clone()), + owner_profile: None, node_provider_maximum_amount_bits: None, node_provider_amount_bits: None, node_provider_dirty: false, @@ -5318,6 +5338,7 @@ fn build_control_device_projection( } let mut devices = Vec::new(); + let mut owner_profile = None; let mut node_provider_status = MissionControlNodeProviderTableStatus::Available(Vec::new()); let mut has_control_resource = false; for component_index in active_components { @@ -5353,6 +5374,15 @@ fn build_control_device_projection( let component_id = component_ids[component_index].ok_or(Unavailable::ComponentAlignmentMismatch)?; + if component_id == 0 { + owner_profile = Some(MissionControlOwnerProfile { + source: control.source.clone(), + unit_component_index: component_index, + native_component_id: component_id, + original_words: control.native_profile_words, + current_words: control.native_profile_words, + }); + } let is_root = component_index == 0; let is_ex_to = prototype.source_type == Some(PROTOTYPE_TYPE_EXTO); let raw_local_index = draft.unit_components[component_index].parent_or_link; @@ -5478,6 +5508,7 @@ fn build_control_device_projection( } Ok(ControlDeviceProjection { devices, + owner_profile, node_provider_status, }) } @@ -6026,6 +6057,7 @@ mod tests { ) -> PreparedComponentControl { PreparedComponentControl { source: synthetic_resource_key(100 + index), + native_profile_words: [0; 27], model_node_count: 0, bindings: Vec::new(), timeline_rows: Vec::new(), @@ -6387,6 +6419,7 @@ mod tests { slot: 7, }, status, + owner_profile: None, node_provider_maximum_amount_bits: initial_node_provider_maximum_amount( &node_provider_status, ), @@ -9088,6 +9121,89 @@ mod tests { ); } + #[test] + fn control_projection_keeps_native_component_zero_profile_and_mutable_copy() { + let root_profile = std::array::from_fn(|index| 0xA500_0000 | index as u32); + let child_profile = [0xC11D_0000; 27]; + let mut root = synthetic_component_control( + 0, + vec![synthetic_control_record(19, 7, 0x10, None)], + PreparedControlEffectSection::Parsed(Vec::new()), + ); + root.native_profile_words = root_profile; + let mut child = synthetic_component_control( + 1, + vec![synthetic_control_record(19, 7, 0x20, None)], + PreparedControlEffectSection::Parsed(Vec::new()), + ); + child.native_profile_words = child_profile; + let root_source = root.source.clone(); + let (draft, assets, prototypes) = synthetic_control_fixture( + &[None, Some(PROTOTYPE_TYPE_INTO)], + &[1, 1], + &[1, 0], + &[0, 0], + &[1, 1], + vec![Some(root), Some(child)], + ); + + let mut projection = build_control_device_projection( + 0, + &draft, + &assets, + Some(&(0..prototypes.len())), + &prototypes, + ) + .expect("root plus INTO projection"); + assert_eq!(projection.devices.len(), 1); + assert_eq!( + projection.devices[0] + .descriptor_provenance + .unit_component_index, + 1, + "INTO with local index zero overrides the parent's first device" + ); + + let owner_profile = projection.owner_profile.as_mut().expect("root profile"); + assert_eq!(owner_profile.unit_component_index, 0); + assert_eq!(owner_profile.native_component_id, 0); + assert_eq!(owner_profile.source, root_source); + assert_eq!(owner_profile.original_words, root_profile); + assert_eq!(owner_profile.current_words, root_profile); + owner_profile.current_words[3] ^= 0xFFFF; + assert_eq!(owner_profile.original_words, root_profile); + assert_ne!(owner_profile.current_words, owner_profile.original_words); + } + + #[test] + fn control_projection_does_not_fall_back_to_child_profile_without_id_zero() { + let mut child = synthetic_component_control( + 1, + vec![synthetic_control_record(19, 7, 0x20, None)], + PreparedControlEffectSection::Parsed(Vec::new()), + ); + child.native_profile_words = [0xC11D_0000; 27]; + let (draft, assets, prototypes) = synthetic_control_fixture( + &[None, Some(PROTOTYPE_TYPE_INTO)], + &[1, 1], + &[1, 0], + &[0, 0], + &[1, 1], + vec![None, Some(child)], + ); + + let projection = build_control_device_projection( + 0, + &draft, + &assets, + Some(&(0..prototypes.len())), + &prototypes, + ) + .expect("child-only Control projection"); + + assert_eq!(projection.owner_profile, None); + } + #[test] fn synthetic_node_provider_projection_keeps_native_rows_separate_from_mesh_keys() { let controls = vec![ @@ -9780,6 +9896,38 @@ mod tests { let mine_control_before = loaded_mission_control_device_tables(&mine_engine) .expect("licensed mine Control table") .to_vec(); + let mine_prepared_controls = &mine_engine + .loaded + .as_ref() + .expect("loaded licensed mine") + .mission_assets + .object_component_controls[0]; + let root_control = mine_prepared_controls[0] + .as_ref() + .expect("licensed root CTLD"); + let child_control = mine_prepared_controls[5] + .as_ref() + .expect("licensed EXTO CTLD"); + let owner_profile = mine_control_before[0] + .owner_profile + .as_ref() + .expect("loaded root owner profile"); + assert_eq!(owner_profile.unit_component_index, 0); + assert_eq!(owner_profile.native_component_id, 0); + assert_eq!(owner_profile.source, root_control.source); + assert_eq!(owner_profile.source.name.0.as_slice(), b"fr_l_mine.ctl"); + assert_eq!( + owner_profile.original_words, + root_control.native_profile_words + ); + assert_eq!( + owner_profile.current_words, + root_control.native_profile_words + ); + assert_ne!( + root_control.native_profile_words, child_control.native_profile_words, + "the licensed child CTLD has its own profile words" + ); let mine_control_handle = mine_control_before[0].handle; let MissionControlDeviceTableStatus::Available(mine_control_devices) = &mine_control_before[0].status @@ -11672,6 +11820,7 @@ mod tests { original_id: Some(OriginalObjectId(original_id)), handle, status: MissionControlDeviceTableStatus::Available(vec![device]), + owner_profile: None, node_provider_status: MissionControlNodeProviderTableStatus::Available(Vec::new()), node_provider_maximum_amount_bits: Some(0), node_provider_amount_bits: Some(0), @@ -11780,6 +11929,7 @@ mod tests { ), ore_device, ]), + owner_profile: None, node_provider_status: MissionControlNodeProviderTableStatus::Available(vec![ synthetic_node_provider_row(0, 0, 1.0_f32.to_bits()), ]), @@ -11834,6 +11984,7 @@ mod tests { original_id: Some(object.original_id), handle: object.handle, status: MissionControlDeviceTableStatus::Available(Vec::new()), + owner_profile: None, node_provider_status: MissionControlNodeProviderTableStatus::Available(Vec::new()), node_provider_maximum_amount_bits: Some(0), node_provider_amount_bits: Some(0), diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index 2f93502..e73ba68 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -520,6 +520,13 @@ provider rows после пропуска первого `EXTO` row. Он выз getter оригинальной DLL здесь не исполняется. Это не запись живого resource-device значения во время игры. +### Профиль владельца Control из CTLD + +Смысл отдельных слов, последующие изменения mutable-копии и чтения в Control +`fca0/fac0` остаются открытыми. Загрузочный тест проверяет CPU-проекцию, но не +исполнение native полного загрузчика или игрового tick. Точные writer, RVA и +указатели описаны в [техническом разделе о загрузке CTLD](script-vm.md#профиль-владельца-control-при-загрузке-ctld). + Закрывающий эксперимент: трассировать `LoadControlSystem`, `LoadPhysicalModel`, `CreateCollManager` и создание collision objects; связать каждый изменяемый field с созданным shape, contact или реакцией на движение. diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index 84bb4b9..a1e867d 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -953,6 +953,30 @@ Ignored Rust-тест также проходит полный неотфиль Это проверка CPU-проекции реальной таблицы, а не сравнение целой миссии с оригиналом. +#### Профиль владельца Control при загрузке CTLD + +Для `Control.dll` с SHA-256 +`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e` +`FUN_10008b10` (RVA `0x8b10`) проверяет native component ID из параметра +`+0xc0`. Только при значении `0` запись около RVA `0x8ee9` указывает на блок +`payload + 0x14` в `owner + 0x46c` и копирует `0x1b` DWORD в `owner + 0x470`. +Затем `FUN_100143e0` (RVA `0x143e0`) связывает указатель исходного блока и +указатель изменяемой копии; при `this = owner + 0x1b4` это указатели +`owner + 0x360` и `owner + 0x364`. Итого CTLD bytes `20..128` — 27 сырых слов: +они первоначально одинаковы в двух областях, но изменение копии владельца не +меняет оригинал. + +Текущая Control-проекция назначает корню native ID `0`, а следующим активным +компонентам — ID от `1`. `INTO` с локальным индексом `0` может переопределять +первую строку таблицы, не меняя native ID самого компонента. В Rust +`PreparedComponentControl` сохраняет блок как `[u32; 27]`, а полная проекция +прикрепляет к ID `0` источник CTLD, исходный массив и отдельную начальную копию +владельца. Загрузочный тест `UNITS/BUILDS/MINE/min_l_n1.dat` подтверждает +источник `fr_l_mine.ctl` и сырые слова корня; отдельный synthetic-тест проверяет +`INTO` slot `0`, независимость массивов и отсутствие fallback к дочернему +профилю. Это подтверждает декодирование и CPU-проекцию, но не исполнение native +полного загрузчика. + #### Активация и начальные часы Для Control класса `4` oracle вызывает исходный конструктор RVA `0x31490` diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index d3af8f7..45a6303 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -691,6 +691,18 @@ Rust full-loader теста на оригинальных ресурсах. Ве `EXTO` подтверждены статически по native ASM; отдельный native full-load oracle для всей таблицы не запускался. +Профиль владельца читается из отдельного блока каждого CTLD сразу после пяти +слов заголовка: это 27 исходных DWORD, начиная с байта `20`. Control выбирает +для владельца только компонент с native ID `0`; его блок остаётся исходной +копией, а те же 27 слов отдельно копируются в изменяемую память владельца. +Поэтому профиль дочернего компонента, даже если его `INTO` обновляет строку +таблицы `0`, не подменяет профиль корня. В Rust `PreparedComponentControl` +сохраняет эти слова без предположений об их типах, а полная Control-проекция +хранит источник, native ID, исходный массив и отдельную начальную копию. Эти +массивы сначала равны; отсутствие CTLD у компонента с ID `0` остаётся явным +`None`, без дочернего fallback или синтетических нулей. Точные native writes и +открытые вопросы о смысле полей приведены в [техническом разборе загрузки CTLD](../appendices/script-vm.md#профиль-владельца-control-при-загрузке-ctld). + Для tag `19` фабрика создаёт объект с его исходными классом и vtable. Базовый конструктор `FUN_100218b0` задаёт multiplier `+0x4c = 1.0`. Override через `FUN_1002d890` вызывает у выбранного объекта vtable slot `+0x1c`, то есть