From aa6d4b8537405628d77f0928eb6fb3e3ba9a826b Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Thu, 29 Oct 2026 22:51:25 +0400 Subject: [PATCH] feat(runtime): project load-time Control profile values --- crates/fparkan-runtime/src/lib.rs | 961 +++++++++++++++++- ...behavior_distributor_tick_native_oracle.cs | 274 ++++- docs/appendices/knowledge-boundaries.md | 52 +- docs/appendices/script-vm.md | 137 +++ docs/tomes/06-behavior.md | 28 + 5 files changed, 1435 insertions(+), 17 deletions(-) diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index d0a5ff9..82bd07c 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -46,6 +46,8 @@ 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"); +// Canonical Unit DAT LoadAgent reaches FAC0 before a scale setter; defaults are unit scale. +const CONTROL_LOAD_FAC0_SCALE_BITS: [u32; 3] = [0x3f80_0000; 3]; /// Injectable engine services used by composition roots. #[derive(Clone, Default)] @@ -510,6 +512,39 @@ pub struct MissionControlOwnerProfile { pub original_words: [u32; 27], /// Initial per-owner copy; this is mutated independently from `original_words`. pub current_words: [u32; 27], + /// Initial FAC0 projection, separate from base device-table availability. + pub load_projection: Result< + MissionControlOwnerProfileLoadProjection, + MissionControlOwnerProfileLoadProjectionUnavailableReason, + >, +} + +/// Initial Control FAC0 load-time values for one owner profile. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionControlOwnerProfileLoadProjection { + /// Native owner aggregates at `+0x530`, `+0x534`, and `+0x538`, as raw bits. + pub aggregate_530_bits: u32, + pub aggregate_534_bits: u32, + pub aggregate_538_bits: u32, + /// Native provider-row `+0x20` values in provider-row order. + /// + /// When available, this length equals the loaded provider-row count. + pub row_20_bits: Vec, +} + +/// Why the initial Control FAC0 profile projection is unavailable. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MissionControlOwnerProfileLoadProjectionUnavailableReason { + /// AniMesh could not provide a known mapping for the requested global node. + UnknownModelMapping { global_node_ordinal: Option }, + /// FAC0 requires provider rows, but the NDP table could not be projected. + ProviderTableUnavailable(MissionControlNodeProviderTableUnavailableReason), + /// A source, intermediate, or resulting float is outside the checked domain. + UnsupportedFloat { field: &'static str }, + /// A device's native provider key exceeds the loaded provider-row table. + ProviderKeyOutOfRange { key: u32, row_count: usize }, + /// An INTO attempted to change a profile tag before the cache was created. + MissingProfileCacheOnInto, } struct ControlDeviceProjection { @@ -518,6 +553,12 @@ struct ControlDeviceProjection { node_provider_status: MissionControlNodeProviderTableStatus, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ControlLoadProfileSlot { + Absent, + Present([u32; 2]), +} + #[derive(Clone, Copy)] struct PreparedBehaviorProfileState { original_id: OriginalObjectId, @@ -4770,6 +4811,224 @@ fn check_control_range_probe( Ok(()) } +fn control_load_profile_float( + value: f32, + field: &'static str, +) -> Result { + checked_control_float(0, field, value).map_err(|_| { + MissionControlOwnerProfileLoadProjectionUnavailableReason::UnsupportedFloat { field } + }) +} + +fn control_load_profile_product( + lhs: f32, + rhs: f32, + field: &'static str, +) -> Result { + let lhs = control_load_profile_float(lhs, field)?; + let rhs = control_load_profile_float(rhs, field)?; + checked_control_product(0, field, lhs, rhs).map_err(|_| { + MissionControlOwnerProfileLoadProjectionUnavailableReason::UnsupportedFloat { field } + }) +} + +fn control_load_profile_sum( + lhs: f32, + rhs: f32, + field: &'static str, +) -> Result { + let lhs = control_load_profile_float(lhs, field)?; + let rhs = control_load_profile_float(rhs, field)?; + checked_control_sum(0, field, lhs, rhs).map_err(|_| { + MissionControlOwnerProfileLoadProjectionUnavailableReason::UnsupportedFloat { field } + }) +} + +fn control_load_profile_selector10( + slot: ControlLoadProfileSlot, + scale_bits: [u32; 3], +) -> Result { + let ControlLoadProfileSlot::Present(slot_words) = slot else { + return Ok(0.0_f32.to_bits()); + }; + let scale158 = f32::from_bits(scale_bits[0]); + let scale15c = f32::from_bits(scale_bits[1]); + let scale160 = f32::from_bits(scale_bits[2]); + let selector10 = control_load_profile_product( + control_load_profile_product( + control_load_profile_product(scale160, scale15c, "selector 0x10 scale")?, + scale158, + "selector 0x10 scale", + )?, + f32::from_bits(slot_words[1]), + "selector 0x10 slot value", + )?; + Ok(selector10.to_bits()) +} + +fn control_load_profile_selector_f( + slot: ControlLoadProfileSlot, + scale_bits: [u32; 3], +) -> Result { + let ControlLoadProfileSlot::Present(slot_words) = slot else { + return Ok(0.0_f32.to_bits()); + }; + let scale158 = f32::from_bits(scale_bits[0]); + let scale15c = f32::from_bits(scale_bits[1]); + let selector_f = control_load_profile_product( + control_load_profile_product(scale15c, scale158, "selector 0xf scale")?, + f32::from_bits(slot_words[0]), + "selector 0xf slot value", + )?; + Ok(selector_f.to_bits()) +} + +fn project_control_load_profile( + original_words: &[u32; 27], + current_words: &mut [u32; 27], + provider_rows: &[MissionControlNodeProviderRow], + devices: &[EffectiveControlDevice], + mesh_slots: Option<&[Option]>, + mesh_component_ids: &[u32], + scale_bits: [u32; 3], + cache27_bits: Result, MissionControlOwnerProfileLoadProjectionUnavailableReason>, +) -> Result< + MissionControlOwnerProfileLoadProjection, + MissionControlOwnerProfileLoadProjectionUnavailableReason, +> { + use MissionControlOwnerProfileLoadProjectionUnavailableReason as Unavailable; + + let Some(mesh_slots) = mesh_slots else { + return Err(Unavailable::UnknownModelMapping { + global_node_ordinal: None, + }); + }; + let cache27_bits = cache27_bits?; + let first_component_zero = mesh_component_ids + .iter() + .position(|component_id| *component_id == 0) + .and_then(|ordinal| u32::try_from(ordinal).ok()); + let component_zero_count = u32::try_from( + mesh_component_ids + .iter() + .filter(|component_id| **component_id == 0) + .count(), + ) + .map_err(|_| Unavailable::UnknownModelMapping { + global_node_ordinal: None, + })?; + let component_zero_end = first_component_zero + .map(|first| { + first + .checked_add(component_zero_count) + .ok_or(Unavailable::UnknownModelMapping { + global_node_ordinal: None, + }) + }) + .transpose()?; + + let mut current_word26 = + control_load_profile_float(f32::from_bits(original_words[26]), "profile word 26")?; + let mut aggregate_530 = 0.0_f32; + let mut aggregate_534 = 0.0_f32; + let mut aggregate_538 = 0.0_f32; + let mut row_20_bits = Vec::with_capacity(provider_rows.len()); + + for (row_index, row) in provider_rows.iter().enumerate() { + let row_ordinal = + u32::try_from(row_index).map_err(|_| Unavailable::UnknownModelMapping { + global_node_ordinal: None, + })?; + let Some(slot) = mesh_slots.get(row_index).copied().flatten() else { + return Err(Unavailable::UnknownModelMapping { + global_node_ordinal: Some(row_ordinal), + }); + }; + let selector10_bits = control_load_profile_selector10(slot, scale_bits)?; + let selector10 = + control_load_profile_float(f32::from_bits(selector10_bits), "selector 0x10")?; + aggregate_534 = control_load_profile_sum(aggregate_534, selector10, "aggregate +0x534")?; + let base_amount = control_load_profile_product( + row.source_row.mobility_per_volume, + selector10, + "provider row +0x20 base", + )?; + let mut row_amount = base_amount; + if first_component_zero.is_some_and(|first| { + row_ordinal >= first && component_zero_end.is_some_and(|end| row_ordinal < end) + }) { + current_word26 = control_load_profile_sum( + current_word26, + base_amount, + "profile word 26 base addition", + )?; + } + + let selector_f = control_load_profile_float( + f32::from_bits(control_load_profile_selector_f(slot, scale_bits)?), + "selector 0xf", + )?; + aggregate_530 = control_load_profile_sum(aggregate_530, selector_f, "aggregate +0x530")?; + if let Some(cache27_bits) = cache27_bits { + let cache_contribution = control_load_profile_product( + f32::from_bits(cache27_bits), + selector_f, + "profile cache contribution", + )?; + row_amount = control_load_profile_sum( + row_amount, + cache_contribution, + "provider row +0x20 cache addition", + )?; + } + aggregate_538 = + control_load_profile_sum(aggregate_538, row_amount, "aggregate +0x538 provider rows")?; + row_20_bits.push(row_amount.to_bits()); + } + + for device in devices.iter().rev() { + let row_index = usize::try_from(device.key).ok(); + let Some(row_index) = row_index.filter(|index| *index < row_20_bits.len()) else { + return Err(Unavailable::ProviderKeyOutOfRange { + key: device.key, + row_count: row_20_bits.len(), + }); + }; + let contribution = control_load_profile_float( + f32::from_bits(device.descriptor.fixed_words[7]), + "device descriptor word 7", + )?; + let row_amount = control_load_profile_sum( + f32::from_bits(row_20_bits[row_index]), + contribution, + "provider row +0x20 device contribution", + )?; + row_20_bits[row_index] = row_amount.to_bits(); + aggregate_538 = control_load_profile_sum( + aggregate_538, + contribution, + "aggregate +0x538 device contributions", + )?; + } + + let final_word26 = control_load_profile_sum( + current_word26, + -aggregate_538, + "profile word 26 final subtraction", + )?; + current_words[26] = if final_word26 >= 0.0 { + final_word26.to_bits() + } else { + 0.0_f32.to_bits() + }; + Ok(MissionControlOwnerProfileLoadProjection { + aggregate_530_bits: aggregate_530.to_bits(), + aggregate_534_bits: aggregate_534.to_bits(), + aggregate_538_bits: aggregate_538.to_bits(), + row_20_bits, + }) +} + fn behavior_clan_for_selector( selector: u32, clan_count: usize, @@ -5180,6 +5439,31 @@ fn native_control_factory_byte_94(factory_tag: u32) -> Option { (factory_tag == 15).then_some(0) } +fn initialize_control_profile_cache27( + cache: &mut Result, MissionControlOwnerProfileLoadProjectionUnavailableReason>, + descriptor: &PreparedControlTypedRecord, +) { + if descriptor.tag == 27 && cache.is_ok() { + *cache = Ok(Some(descriptor.fixed_words[11])); + } +} + +fn update_control_profile_cache27_on_into( + cache: &mut Result, MissionControlOwnerProfileLoadProjectionUnavailableReason>, + descriptor: &PreparedControlTypedRecord, +) { + if descriptor.tag != 27 { + return; + } + match cache { + Ok(Some(value)) => *value = descriptor.fixed_words[11], + Ok(None) => *cache = Err( + MissionControlOwnerProfileLoadProjectionUnavailableReason::MissingProfileCacheOnInto, + ), + Err(_) => {} + } +} + #[cfg(test)] fn build_control_device_table( object_index: usize, @@ -5351,6 +5635,9 @@ fn build_control_device_projection( } let mut global_mesh_states = Vec::new(); + let mut global_control_profile_slots = Vec::new(); + let mut global_mesh_component_ids = Vec::new(); + let mut root_control_profile_model_known = false; let mut global_node_cursor = 0_usize; for component_index in active_components.iter().copied() { let prototype = @@ -5368,6 +5655,9 @@ fn build_control_device_projection( let model = visual .and_then(|visual| visual.model_id) .and_then(|model_id| assets.model_by_id(model_id)); + if is_root { + root_control_profile_model_known = model.is_some(); + } let parent_base = if is_ex_to { i32::try_from(global_node_cursor) .ok() @@ -5389,11 +5679,17 @@ fn build_control_device_projection( draft.unit_components[component_index].parent_or_link, ) }; + let component_id = + component_ids[component_index].ok_or(Unavailable::ComponentAlignmentMismatch)?; let first_source_node = usize::from(is_ex_to); for source_node_index in first_source_node..node_count { global_mesh_states.push(model.and_then(|model| { initial_control_mesh_state(model, source_node_index, parent_base, mount_node) })); + global_control_profile_slots.push( + model.and_then(|model| initial_control_profile_slot(model, source_node_index)), + ); + global_mesh_component_ids.push(component_id); } let contribution = node_count.saturating_sub(first_source_node); global_node_cursor = global_node_cursor @@ -5403,6 +5699,7 @@ fn build_control_device_projection( let mut devices = Vec::new(); let mut owner_profile = None; + let mut profile_cache27_bits = Ok(None); let mut node_provider_status = MissionControlNodeProviderTableStatus::Available(Vec::new()); let mut has_control_resource = false; for component_index in active_components { @@ -5439,13 +5736,12 @@ 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, - }); + owner_profile = Some(( + control.source.clone(), + component_index, + component_id, + control.native_profile_words, + )); } let is_root = component_index == 0; let is_ex_to = prototype.source_type == Some(PROTOTYPE_TYPE_EXTO); @@ -5494,6 +5790,7 @@ fn build_control_device_projection( control, descriptor, ); + initialize_control_profile_cache27(&mut profile_cache27_bits, descriptor); } continue; } @@ -5533,6 +5830,7 @@ fn build_control_device_projection( control, descriptor, ); + initialize_control_profile_cache27(&mut profile_cache27_bits, descriptor); } } ControlTableTarget::Override(target_index) => { @@ -5561,6 +5859,7 @@ fn build_control_device_projection( }); } apply_effective_control_override(device, descriptor, component_index, 0, control); + update_control_profile_cache27_on_into(&mut profile_cache27_bits, descriptor); } } } @@ -5570,6 +5869,39 @@ fn build_control_device_projection( if let MissionControlNodeProviderTableStatus::Available(rows) = &mut node_provider_status { attach_initial_mesh_states_by_global_ordinal(rows, &global_mesh_states); } + let owner_profile = owner_profile.map( + |(source, unit_component_index, native_component_id, original_words)| { + let mut current_words = original_words; + let load_projection = match &node_provider_status { + MissionControlNodeProviderTableStatus::Available(rows) => { + project_control_load_profile( + &original_words, + &mut current_words, + rows, + &devices, + root_control_profile_model_known + .then_some(global_control_profile_slots.as_slice()), + &global_mesh_component_ids, + CONTROL_LOAD_FAC0_SCALE_BITS, + profile_cache27_bits, + ) + } + MissionControlNodeProviderTableStatus::Unavailable(reason) => Err( + MissionControlOwnerProfileLoadProjectionUnavailableReason::ProviderTableUnavailable( + reason.clone(), + ), + ), + }; + MissionControlOwnerProfile { + source, + unit_component_index, + native_component_id, + original_words, + current_words, + load_projection, + } + }, + ); Ok(ControlDeviceProjection { devices, owner_profile, @@ -5679,6 +6011,35 @@ fn initial_control_mesh_state( }) } +fn initial_control_profile_slot( + model: &fparkan_assets::PreparedModel, + source_node_index: usize, +) -> Option { + let node_index = u32::try_from(source_node_index).ok()?; + let node_id = NodeId(node_index); + node38_metadata(&model.validated, node_id)?; + let slot_offset = source_node_index + .checked_mul(model.validated.node_stride)? + .checked_add(8)?; + let slot_bytes = model + .validated + .nodes_raw + .get(slot_offset..slot_offset.checked_add(2)?)?; + let raw_slot = u16::from_le_bytes([slot_bytes[0], slot_bytes[1]]); + if raw_slot == u16::MAX { + return Some(ControlLoadProfileSlot::Absent); + } + let Some(slot_id) = selected_slot(&model.validated, node_id, Lod(0), Group(0)) else { + return None; + }; + let slot_index = usize::try_from(slot_id.0).ok()?; + let slot = model.validated.slots.get(slot_index)?; + Some(ControlLoadProfileSlot::Present([ + slot.opaque[0], + slot.opaque[1], + ])) +} + fn attach_initial_mesh_states_by_global_ordinal( provider_rows: &mut [MissionControlNodeProviderRow], global_mesh_states: &[Option], @@ -9334,11 +9695,97 @@ mod tests { assert_eq!(owner_profile.source, root_source); assert_eq!(owner_profile.original_words, root_profile); assert_eq!(owner_profile.current_words, root_profile); + assert!(matches!( + owner_profile.load_projection.as_ref(), + Err( + MissionControlOwnerProfileLoadProjectionUnavailableReason::ProviderTableUnavailable( + _ + ) + ) + )); 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 full_control_projection_uses_constructor_scales_and_writes_derived_profile_copy() { + let mut cache_record = synthetic_control_record(27, 0, 2.0_f32.to_bits(), None); + cache_record.fixed_words[7] = 0.0_f32.to_bits(); + let mut device_record = synthetic_control_record(19, 0, 0, None); + device_record.fixed_words[7] = 5.25_f32.to_bits(); + let mut root = control_with_synthetic_node_data(0, vec![cache_record, device_record], 1); + root.native_profile_words[26] = 1000.0_f32.to_bits(); + root.node_data.as_mut().unwrap().rows[0].mobility_per_volume = 2.0; + let (mut draft, mut assets, prototypes) = + synthetic_control_fixture(&[None], &[1], &[0], &[0], &[1], vec![Some(root)]); + attach_synthetic_model(&mut assets, 0, &[synthetic_node38(0, 0, [true; 3])]); + assets.models[0].validated.slots[0].opaque = + [7.0_f32.to_bits(), 11.0_f32.to_bits(), 0, 0, 0]; + draft.scale = [2.0, 3.0, 5.0]; + + let projection = build_control_device_projection( + 0, + &draft, + &assets, + Some(&(0..prototypes.len())), + &prototypes, + ) + .expect("complete synthetic owner profile"); + let profile = projection + .owner_profile + .expect("native component-zero profile"); + let load = profile.load_projection.expect("FAC0 load projection"); + assert_eq!( + [ + load.aggregate_530_bits, + load.aggregate_534_bits, + load.aggregate_538_bits, + ], + [7.0_f32.to_bits(), 11.0_f32.to_bits(), 41.25_f32.to_bits()] + ); + assert_eq!(load.row_20_bits, [41.25_f32.to_bits()]); + assert_eq!(profile.original_words[26], 1000.0_f32.to_bits()); + assert_eq!(profile.current_words[26], 980.75_f32.to_bits()); + assert_eq!( + profile.current_words[..26], + profile.original_words[..26], + "FAC0 changes only mutable profile word 26" + ); + } + + #[test] + fn full_control_profile_marks_unknown_root_model_for_empty_table() { + let root = control_with_synthetic_node_data(0, Vec::new(), 0); + let (draft, assets, prototypes) = + synthetic_control_fixture(&[None], &[1], &[0], &[0], &[1], vec![Some(root)]); + + let projection = build_control_device_projection( + 0, + &draft, + &assets, + Some(&(0..prototypes.len())), + &prototypes, + ) + .expect("empty Control table still has an owner profile"); + assert!(projection.devices.is_empty()); + assert!(matches!( + &projection.node_provider_status, + MissionControlNodeProviderTableStatus::Available(rows) if rows.is_empty() + )); + assert_eq!( + projection + .owner_profile + .expect("root owner profile") + .load_projection, + Err( + MissionControlOwnerProfileLoadProjectionUnavailableReason::UnknownModelMapping { + global_node_ordinal: None, + } + ) + ); + } + #[test] fn control_projection_does_not_fall_back_to_child_profile_without_id_zero() { let mut child = synthetic_component_control( @@ -9368,6 +9815,456 @@ mod tests { assert_eq!(projection.owner_profile, None); } + #[test] + fn control_profile_cache27_tracks_initial_records_and_into_order() { + let build = |root_records: Vec, + into_record: Option| { + let mut root = control_with_synthetic_node_data(0, root_records, 1); + root.node_data.as_mut().expect("root NDP").rows[0].mobility_per_volume = 1.0; + let (source_types, child_counts, parent_or_links, model_nodes, controls) = + if let Some(into_record) = into_record { + ( + vec![None, Some(PROTOTYPE_TYPE_INTO)], + vec![1, 0], + vec![0, 0], + vec![1, 0], + vec![ + Some(root), + Some(synthetic_component_control( + 1, + vec![into_record], + PreparedControlEffectSection::Parsed(Vec::new()), + )), + ], + ) + } else { + (vec![None], vec![0], vec![0], vec![1], vec![Some(root)]) + }; + let (draft, mut assets, prototypes) = synthetic_control_fixture( + &source_types, + &vec![1; source_types.len()], + &child_counts, + &parent_or_links, + &model_nodes, + controls, + ); + attach_synthetic_model(&mut assets, 0, &[synthetic_node38(0, 0, [true; 3])]); + assets.models[0].validated.slots[0].opaque = + [2.0_f32.to_bits(), 3.0_f32.to_bits(), 0, 0, 0]; + build_control_device_projection( + 0, + &draft, + &assets, + Some(&(0..prototypes.len())), + &prototypes, + ) + .expect("synthetic profile-cache projection") + .owner_profile + .expect("synthetic owner profile") + }; + + assert_eq!( + build( + vec![ + synthetic_control_record(27, 0, 2.0_f32.to_bits(), None), + synthetic_control_record(27, 0, 5.0_f32.to_bits(), None), + ], + None, + ) + .load_projection + .expect("last initial tag-27 projection") + .row_20_bits, + [13.0_f32.to_bits()], + "the last initial tag-27 constructor owns cache +0x0c" + ); + assert_eq!( + build( + vec![synthetic_control_record(27, 0, 2.0_f32.to_bits(), None)], + Some(synthetic_control_record(19, 0, 7.0_f32.to_bits(), None)), + ) + .load_projection + .expect("non-tag-27 INTO projection") + .row_20_bits, + [7.0_f32.to_bits()], + "INTO with a different new tag keeps the previous tag-27 cache" + ); + assert_eq!( + build( + vec![synthetic_control_record(27, 0, 2.0_f32.to_bits(), None)], + Some(synthetic_control_record(27, 0, 7.0_f32.to_bits(), None)), + ) + .load_projection + .expect("tag-27 INTO projection") + .row_20_bits, + [17.0_f32.to_bits()], + "INTO tag 27 updates an allocated cache" + ); + assert_eq!( + build( + vec![synthetic_control_record(19, 0, 2.0_f32.to_bits(), None)], + Some(synthetic_control_record(27, 0, 7.0_f32.to_bits(), None)), + ) + .load_projection, + Err(MissionControlOwnerProfileLoadProjectionUnavailableReason::MissingProfileCacheOnInto), + "INTO tag 27 cannot create a missing cache" + ); + assert_eq!( + build( + vec![synthetic_control_record(19, 0, 2.0_f32.to_bits(), None)], + None, + ) + .load_projection + .expect("profile without tag 27 projection") + .row_20_bits, + [3.0_f32.to_bits()], + "without tag 27 native FAC0 has no cache" + ); + } + + #[test] + fn control_profile_cache27_missing_into_remains_unavailable_after_later_append() { + let mut cache = Ok(None); + update_control_profile_cache27_on_into( + &mut cache, + &synthetic_control_record(27, 0, 3.0_f32.to_bits(), None), + ); + initialize_control_profile_cache27( + &mut cache, + &synthetic_control_record(27, 0, 5.0_f32.to_bits(), None), + ); + assert_eq!( + cache, + Err(MissionControlOwnerProfileLoadProjectionUnavailableReason::MissingProfileCacheOnInto) + ); + } + + #[test] + fn control_load_profile_projection_matches_native_fac0_vector() { + let mut original_words = [0; 27]; + original_words[26] = 2000.0_f32.to_bits(); + let mut current_words = [0; 27]; + current_words[26] = (-999.0_f32).to_bits(); + let mut rows = vec![ + synthetic_node_provider_row(0, 0, 1.0_f32.to_bits()), + synthetic_node_provider_row(0, 1, 1.0_f32.to_bits()), + ]; + rows[0].source_row.mobility_per_volume = 2.0; + rows[1].source_row.mobility_per_volume = 3.0; + rows[0].current_amount_bits = 123.0_f32.to_bits(); + rows[1].current_amount_bits = 456.0_f32.to_bits(); + let mut key_one = + synthetic_effective_control_device(19, 19, 99.0_f32.to_bits(), None, None); + key_one.key = 1; + key_one.descriptor.fixed_words[7] = 5.25_f32.to_bits(); + let mut key_zero = + synthetic_effective_control_device(19, 19, 101.0_f32.to_bits(), None, None); + key_zero.key = 0; + key_zero.descriptor.fixed_words[7] = 0.5_f32.to_bits(); + + let projection = project_control_load_profile( + &original_words, + &mut current_words, + &rows, + &[key_one, key_zero], + Some(&[ + Some(ControlLoadProfileSlot::Present([ + 7.0_f32.to_bits(), + 11.0_f32.to_bits(), + ])), + Some(ControlLoadProfileSlot::Present([ + 7.0_f32.to_bits(), + 11.0_f32.to_bits(), + ])), + ]), + &[1, 0], + [2.0_f32.to_bits(), 3.0_f32.to_bits(), 5.0_f32.to_bits()], + Ok(Some(2.0_f32.to_bits())), + ) + .expect("native FAC0 load vector"); + + assert_eq!(projection.aggregate_530_bits, 84.0_f32.to_bits()); + assert_eq!(projection.aggregate_534_bits, 660.0_f32.to_bits()); + assert_eq!(projection.aggregate_538_bits, 1823.75_f32.to_bits()); + assert_eq!( + projection.row_20_bits, + [744.5_f32.to_bits(), 1079.25_f32.to_bits()] + ); + assert_eq!(current_words[26], 1166.25_f32.to_bits()); + assert_eq!(original_words[26], 2000.0_f32.to_bits()); + assert_eq!(rows[0].current_amount_bits, 123.0_f32.to_bits()); + assert_eq!(rows[1].current_amount_bits, 456.0_f32.to_bits()); + } + + #[test] + fn control_load_profile_projection_matches_native_cache_and_clamp_vectors() { + let mut row = synthetic_node_provider_row(0, 0, 1.0_f32.to_bits()); + row.source_row.mobility_per_volume = 1.0; + let rows = [row]; + let slots = [Some(ControlLoadProfileSlot::Present([ + 7.0_f32.to_bits(), + 11.0_f32.to_bits(), + ]))]; + let component_ids = [0]; + let scales = [2.0_f32.to_bits(), 3.0_f32.to_bits(), 5.0_f32.to_bits()]; + for (cache, expected_row, expected_final) in [ + (Ok(None), 330.0_f32.to_bits(), 1000.0_f32.to_bits()), + ( + Ok(Some(0.0_f32.to_bits())), + 330.0_f32.to_bits(), + 1000.0_f32.to_bits(), + ), + ( + Ok(Some(2.0_f32.to_bits())), + 414.0_f32.to_bits(), + 916.0_f32.to_bits(), + ), + ] { + let mut original_words = [0; 27]; + original_words[26] = 1000.0_f32.to_bits(); + let mut current_words = original_words; + let projection = project_control_load_profile( + &original_words, + &mut current_words, + &rows, + &[], + Some(&slots), + &component_ids, + scales, + cache, + ) + .expect("cache load vector"); + assert_eq!(projection.row_20_bits, [expected_row]); + assert_eq!(projection.aggregate_530_bits, 42.0_f32.to_bits()); + assert_eq!(projection.aggregate_534_bits, 330.0_f32.to_bits()); + assert_eq!(projection.aggregate_538_bits, expected_row); + assert_eq!(current_words[26], expected_final); + } + + let original_words = [0; 27]; + let mut current_words = original_words; + let projection = project_control_load_profile( + &original_words, + &mut current_words, + &rows, + &[], + Some(&slots), + &[1], + scales, + Ok(None), + ) + .expect("negative-clamp vector"); + assert_eq!(projection.aggregate_538_bits, 330.0_f32.to_bits()); + assert_eq!(current_words[26], 0.0_f32.to_bits()); + } + + #[test] + fn control_load_profile_projection_preserves_signed_zero_and_reports_unknown_mapping() { + let mut original_words = [0; 27]; + original_words[26] = 0x8000_0000; + let mut current_words = original_words; + let projection = project_control_load_profile( + &original_words, + &mut current_words, + &[], + &[], + Some(&[]), + &[], + [1.0_f32.to_bits(); 3], + Ok(None), + ) + .expect("known empty model without provider rows"); + assert_eq!(projection.aggregate_530_bits, 0.0_f32.to_bits()); + assert_eq!(projection.aggregate_534_bits, 0.0_f32.to_bits()); + assert_eq!(projection.aggregate_538_bits, 0.0_f32.to_bits()); + assert_eq!(current_words[26], 0x8000_0000); + + let mut original_words = [0; 27]; + original_words[26] = (-5.0_f32).to_bits(); + let mut current_words = [0; 27]; + current_words[26] = (-999.0_f32).to_bits(); + assert_eq!( + project_control_load_profile( + &original_words, + &mut current_words, + &[], + &[], + None, + &[], + [1.0_f32.to_bits(); 3], + Ok(None), + ), + Err( + MissionControlOwnerProfileLoadProjectionUnavailableReason::UnknownModelMapping { + global_node_ordinal: None, + } + ) + ); + assert_eq!(current_words[26], (-999.0_f32).to_bits()); + + let rows = [ + synthetic_node_provider_row(0, 0, 1.0_f32.to_bits()), + synthetic_node_provider_row(0, 1, 1.0_f32.to_bits()), + ]; + let mut original_words = [0; 27]; + original_words[26] = 100.0_f32.to_bits(); + let mut current_words = [0; 27]; + current_words[26] = (-999.0_f32).to_bits(); + assert_eq!( + project_control_load_profile( + &original_words, + &mut current_words, + &rows, + &[], + Some(&[Some(ControlLoadProfileSlot::Present([0; 2])), None,]), + &[0, 0], + [1.0_f32.to_bits(); 3], + Ok(None), + ), + Err( + MissionControlOwnerProfileLoadProjectionUnavailableReason::UnknownModelMapping { + global_node_ordinal: Some(1), + } + ) + ); + assert_eq!(current_words[26], (-999.0_f32).to_bits()); + + let negative_scales = [ + 1.0_f32.to_bits(), + (-1.0_f32).to_bits(), + (-1.0_f32).to_bits(), + ]; + assert_eq!( + control_load_profile_selector10(ControlLoadProfileSlot::Absent, negative_scales), + Ok(0.0_f32.to_bits()) + ); + assert_eq!( + control_load_profile_selector_f(ControlLoadProfileSlot::Absent, negative_scales), + Ok(0.0_f32.to_bits()) + ); + } + + #[test] + fn control_load_profile_projection_preserves_native_order_and_rejects_unsupported_inputs() { + let mut rows = (0..3) + .map(|index| synthetic_node_provider_row(0, index, 1.0_f32.to_bits())) + .collect::>(); + for (row, mobility) in rows.iter_mut().zip([16_777_216.0_f32, 1.0, -16_777_216.0]) { + row.source_row.mobility_per_volume = mobility; + } + let mut original_words = [0; 27]; + original_words[26] = 10.0_f32.to_bits(); + let mut current_words = original_words; + let projection = project_control_load_profile( + &original_words, + &mut current_words, + &rows, + &[], + Some(&[Some(ControlLoadProfileSlot::Present([0, 1.0_f32.to_bits()])); 3]), + &[1, 1, 1], + [1.0_f32.to_bits(); 3], + Ok(None), + ) + .expect("ordered rounding vector"); + assert_eq!( + projection.row_20_bits, + [ + 16_777_216.0_f32.to_bits(), + 1.0_f32.to_bits(), + (-16_777_216.0_f32).to_bits() + ] + ); + assert_eq!(projection.aggregate_534_bits, 3.0_f32.to_bits()); + assert_eq!(projection.aggregate_538_bits, 0.0_f32.to_bits()); + assert_eq!(current_words[26], 10.0_f32.to_bits()); + + let one_row = [synthetic_node_provider_row(0, 0, 1.0_f32.to_bits())]; + let mut out_of_range = synthetic_effective_control_device(19, 19, 0, None, None); + out_of_range.key = 1; + let mut original_words = [0; 27]; + original_words[26] = 10.0_f32.to_bits(); + let mut current_words = original_words; + assert_eq!( + project_control_load_profile( + &original_words, + &mut current_words, + &one_row, + &[out_of_range], + Some(&[Some(ControlLoadProfileSlot::Present([0; 2]))]), + &[1], + [1.0_f32.to_bits(); 3], + Ok(None), + ), + Err( + MissionControlOwnerProfileLoadProjectionUnavailableReason::ProviderKeyOutOfRange { + key: 1, + row_count: 1, + } + ) + ); + assert_eq!(current_words[26], 10.0_f32.to_bits()); + + let mut unsupported_row = one_row; + unsupported_row[0].source_row.mobility_per_volume = f32::INFINITY; + assert_eq!( + project_control_load_profile( + &original_words, + &mut current_words, + &unsupported_row, + &[], + Some(&[Some(ControlLoadProfileSlot::Present([0; 2]))]), + &[1], + [1.0_f32.to_bits(); 3], + Ok(None), + ), + Err( + MissionControlOwnerProfileLoadProjectionUnavailableReason::UnsupportedFloat { + field: "provider row +0x20 base", + } + ) + ); + assert_eq!(current_words[26], 10.0_f32.to_bits()); + + assert_eq!( + project_control_load_profile( + &original_words, + &mut current_words, + &[], + &[], + Some(&[]), + &[], + [1.0_f32.to_bits(); 3], + Err(MissionControlOwnerProfileLoadProjectionUnavailableReason::MissingProfileCacheOnInto), + ), + Err(MissionControlOwnerProfileLoadProjectionUnavailableReason::MissingProfileCacheOnInto) + ); + assert_eq!(current_words[26], 10.0_f32.to_bits()); + } + + #[test] + fn initial_control_profile_slot_uses_native_slot68_fields_and_tracks_absence() { + let (_, mut assets, _) = + synthetic_control_fixture(&[None], &[1], &[0], &[0], &[1], vec![None]); + attach_synthetic_model(&mut assets, 0, &[synthetic_node38(0, 0, [true; 3])]); + assets.models[0].validated.slots[0].opaque = + [7.0_f32.to_bits(), 11.0_f32.to_bits(), 0, 0, 0]; + let model = &assets.models[0]; + assert_eq!( + initial_control_profile_slot(model, 0), + Some(ControlLoadProfileSlot::Present([ + 7.0_f32.to_bits(), + 11.0_f32.to_bits(), + ])) + ); + assets.models[0].validated.nodes_raw[8..10].copy_from_slice(&1_u16.to_le_bytes()); + assert_eq!(initial_control_profile_slot(&assets.models[0], 0), None); + assets.models[0].validated.nodes_raw[8..10].copy_from_slice(&u16::MAX.to_le_bytes()); + assert_eq!( + initial_control_profile_slot(&assets.models[0], 0), + Some(ControlLoadProfileSlot::Absent) + ); + assert_eq!(initial_control_profile_slot(&assets.models[0], 1), None); + } + #[test] fn synthetic_node_provider_projection_keeps_native_rows_separate_from_mesh_keys() { let controls = vec![ @@ -10093,9 +10990,54 @@ mod tests { owner_profile.original_words, root_control.native_profile_words ); + let mine_fac0 = owner_profile + .load_projection + .as_ref() + .expect("licensed mine FAC0 projection"); assert_eq!( - owner_profile.current_words, - root_control.native_profile_words + [ + mine_fac0.aggregate_530_bits, + mine_fac0.aggregate_534_bits, + mine_fac0.aggregate_538_bits, + ], + [0x4708_33fc, 0x4897_e712, 0x4d94_52cb] + ); + assert_eq!( + mine_fac0.row_20_bits, + [ + 0x0000_0000, + 0x4c5b_f1ca, + 0x4d20_1bf8, + 0x4a1c_9e36, + 0x4b61_82dd, + 0x4bbc_015f, + 0x4a77_2242, + 0x4914_0fe7, + 0x4914_0fe7, + 0x0000_0000, + 0x4817_baa4, + 0x4a4e_9ec8, + 0x4ac1_1a66, + 0x4a2e_79a9, + 0x4b0d_6df0, + 0x48d9_d1f6, + 0x478d_7e31, + 0x4ac1_1c86, + 0x478f_dbf5, + 0x4817_b6da, + 0x4b0a_1f75, + 0x478f_dbf9, + 0x469f_85b4, + 0x46b5_5e97, + 0x46b5_5e94, + ] + ); + assert_eq!(mine_fac0.row_20_bits.len(), 25); + assert_eq!(owner_profile.original_words[26], 0x7f7f_ffff); + assert_eq!(owner_profile.current_words[26], 0x7f7f_ffff); + assert_eq!( + &owner_profile.current_words[..26], + &root_control.native_profile_words[..26] ); assert_ne!( root_control.native_profile_words, child_control.native_profile_words, @@ -10107,6 +11049,7 @@ mod tests { else { panic!("licensed mine device table should be available"); }; + assert_eq!(mine_control_devices.len(), 11); assert_eq!( mine_control_devices .iter() 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 d72fd57..4fcee01 100644 --- a/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs +++ b/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs @@ -81,6 +81,13 @@ internal static class BehaviorDistributorTickOracle { private const int ControlResourceNoOpStringPairRva = 0x0bcd0; private const int ControlResourceTag19VtableRva = 0x3c608; private const int ControlResourceTag19StringPairNoOpRva = 0x0bcd0; + private const int ControlProfileFac0Rva = 0x0fac0; + private const int ControlProfileOwnerConstructorRva = 0x31490; + private const int ControlProfileDeviceVtableRva = 0x3d1b4; + private const int ControlProfileDeviceCountRva = 0x08830; + private const int ControlProfileDeviceQueryRva = 0x2bb40; + private const int AniMeshComponentFirstRva = 0x05780; + private const int AniMeshComponentCountRva = 0x057c0; private const int BehaviorVarTableVtableRva = 0x591b0; private const int BehaviorVarArrayLoadRva = 0x49fa0; private const int BehaviorVarFieldBindRva = 0x49090; @@ -129,6 +136,7 @@ internal static class BehaviorDistributorTickOracle { [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void NativeFastcallComponentBuilder(IntPtr owner, IntPtr node, uint parentId); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int NativeArealMapEvent(IntPtr owner, uint message, uint subcode, IntPtr descriptor); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int NativeAniMeshNodeMetadataQuery(IntPtr owner, uint nodeIndex, uint selector, IntPtr result); + [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate uint NativeAniMeshRangeLookup(IntPtr owner, uint componentId); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate uint NativeAniMeshNodeStageCount(IntPtr owner, uint nodeIndex); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate IntPtr NativeControlResourceDeviceConstructor(IntPtr self); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeControlResourceBudgetUpdate(IntPtr self, float deltaSeconds); @@ -151,6 +159,7 @@ internal static class BehaviorDistributorTickOracle { [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeControlTimedUpdate(IntPtr self, float inputTime); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeControlProviderRowConstructor(IntPtr self, IntPtr sourceNdpRow); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate IntPtr NativeControlOwnerConstructor(IntPtr self, IntPtr host, uint classCode); + [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void NativeControlProfileFac0(IntPtr owner); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeControlProviderInitialState(IntPtr self); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void NativeControlNodeUpdateCallback(IntPtr self, uint nodeIndex, uint selector, uint valueBits); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeControlProviderStageUpdate(IntPtr self, int nodeIndex, uint updateEffects); @@ -691,7 +700,7 @@ internal static class BehaviorDistributorTickOracle { Marshal.WriteIntPtr(import, Marshal.GetFunctionPointerForDelegate(AreaMapGetterCallback)); Marshal.WriteIntPtr(queueImport, Marshal.GetFunctionPointerForDelegate(QueueGetterCallback)); RunSmokeCases(module, controlModule, arealMapModule, aniMeshModule); - Console.WriteLine("original native Behavior/ArealMap/Control oracle passed (Behavior Add 0x1aac0, normalize 0x1ac90, scheduler 0x1adb0, tick 0x19e80; ArealMap UnitDAT projection 0x14b90; Control INTO override 0x2d890, provider distribution 0x10ba0, class-4 update 0x12a40 and bounded class-10 active clock 0xbcf0; synthetic host callbacks, GetSystemArealMap/GetQueue IAT slots, and bounded vector insertion are substituted; Control provider selector vtable is original; no full Control update or Rust parity is claimed)."); + Console.WriteLine("original native Behavior/ArealMap/Control oracle passed (Behavior Add 0x1aac0, normalize 0x1ac90, scheduler 0x1adb0, tick 0x19e80; ArealMap UnitDAT projection 0x14b90; Control INTO override 0x2d890, FAC0 load profile 0xfac0, provider distribution 0x10ba0, class-4 update 0x12a40 and bounded class-10 active clock 0xbcf0; FAC0 uses synthetic backing with original AniMesh and Control query methods; other host callbacks, GetSystemArealMap/GetQueue IAT slots, and bounded vector insertion are substituted; no full UnitDAT loading or gameplay parity is claimed)."); return 0; } catch (Exception error) { @@ -734,6 +743,7 @@ internal static class BehaviorDistributorTickOracle { RunNativeArealMapComponentBuilderCase(arealMapModule); RunNativeAniMeshScaleLoadSnapshot(aniMeshModule); RunNativeAniMeshNodeMetadataCases(aniMeshModule); + RunNativeControlFac0LoadCases(controlModule, aniMeshModule); RunNativeControlProviderInitialStateCase(controlModule, aniMeshModule); RunNativeControlIntoOverrideCase(controlModule); RunNativeControlResourceDeviceCase(controlModule); @@ -2765,6 +2775,268 @@ internal static class BehaviorDistributorTickOracle { GC.KeepAlive(stageCount); } + private static void RunNativeControlFac0LoadCases(IntPtr controlModule, IntPtr aniMeshModule) { + NativeControlOwnerConstructor constructOwner = (NativeControlOwnerConstructor) + Marshal.GetDelegateForFunctionPointer(IntPtr.Add(controlModule, ControlProfileOwnerConstructorRva), + typeof(NativeControlOwnerConstructor)); + NativeControlProfileFac0 fac0 = (NativeControlProfileFac0)Marshal.GetDelegateForFunctionPointer( + WriteStdCallToFastcallOneArgThunk(0x440, IntPtr.Add(controlModule, ControlProfileFac0Rva)), + typeof(NativeControlProfileFac0)); + NativeAniMeshNodeMetadataQuery queryNode = (NativeAniMeshNodeMetadataQuery) + Marshal.GetDelegateForFunctionPointer(IntPtr.Add(aniMeshModule, AniMeshNodeMetadataQueryRva), + typeof(NativeAniMeshNodeMetadataQuery)); + NativeAniMeshRangeLookup findFirst = (NativeAniMeshRangeLookup) + Marshal.GetDelegateForFunctionPointer(IntPtr.Add(aniMeshModule, AniMeshComponentFirstRva), + typeof(NativeAniMeshRangeLookup)); + NativeAniMeshRangeLookup countMatches = (NativeAniMeshRangeLookup) + Marshal.GetDelegateForFunctionPointer(IntPtr.Add(aniMeshModule, AniMeshComponentCountRva), + typeof(NativeAniMeshRangeLookup)); + + RunFac0CombinedCase(controlModule, aniMeshModule, constructOwner, fac0, queryNode, findFirst, countMatches); + uint noCache = RunFac0CacheCase(controlModule, aniMeshModule, constructOwner, fac0, false, 0.0f); + uint zeroCache = RunFac0CacheCase(controlModule, aniMeshModule, constructOwner, fac0, true, 0.0f); + uint nonzeroCache = RunFac0CacheCase(controlModule, aniMeshModule, constructOwner, fac0, true, 2.0f); + Expect(noCache == zeroCache && noCache != nonzeroCache, + "FAC0 distinguishes a missing cache from its value while a present-zero cache contributes zero"); + RunFac0NegativeClamp(controlModule, aniMeshModule, constructOwner, fac0, findFirst, countMatches); + RunFac0NullAndSignedZeroCases(controlModule, aniMeshModule, constructOwner, fac0); + RunFac0OrderedCase(controlModule, aniMeshModule, constructOwner, fac0); + Expect(CurrentX87ControlWord() == 0x007f, "FAC0 vectors use x87 control word 0x007f"); + Console.WriteLine("native-control-fac0-load: controlHash=" + ExpectedControlSha256 + + " aniMeshHash=" + ExpectedAniMeshSha256 + " rva=0x0fac0 cw=0x" + + CurrentX87ControlWord().ToString("x4") + " original owner/node/device methods"); + GC.KeepAlive(fac0); + GC.KeepAlive(constructOwner); + GC.KeepAlive(queryNode); + GC.KeepAlive(findFirst); + GC.KeepAlive(countMatches); + } + + private static IntPtr CreateFac0AniMesh(Fixture fixture, IntPtr aniMeshModule, uint[] componentIds, + float scale158, float scale15c, float scale160, float slot30, float slot34) { + IntPtr ani = fixture.Allocate(0x1b0); + IntPtr vtable = fixture.Allocate(0x50); + IntPtr nodes = fixture.Allocate(Math.Max(1, componentIds.Length) * 0x130); + IntPtr type1NodeData = fixture.Allocate(0x26); + IntPtr slotData = fixture.Allocate(0x44); + IntPtr modelContext = fixture.Allocate(3 * IntPtr.Size); + Marshal.WriteInt16(type1NodeData, 0x08, 0); + Marshal.WriteIntPtr(modelContext, 0, type1NodeData); + Marshal.WriteIntPtr(modelContext, 2 * IntPtr.Size, slotData); + Marshal.WriteInt32(slotData, 0x30, FloatBits(slot30)); + Marshal.WriteInt32(slotData, 0x34, FloatBits(slot34)); + Marshal.WriteIntPtr(ani, 0, vtable); + Marshal.WriteIntPtr(ani, 0x1a8, nodes); + Marshal.WriteInt32(ani, 0x1ac, componentIds.Length); + Marshal.WriteInt32(ani, 0x158, FloatBits(scale158)); + Marshal.WriteInt32(ani, 0x15c, FloatBits(scale15c)); + Marshal.WriteInt32(ani, 0x160, FloatBits(scale160)); + for (int i = 0; i < componentIds.Length; i++) { + IntPtr node = IntPtr.Add(nodes, i * 0x130); + Marshal.WriteInt32(node, 0, unchecked((int)componentIds[i])); + Marshal.WriteInt32(node, 0x08, 0); + Marshal.WriteInt32(node, 0x1c, 0); + Marshal.WriteIntPtr(node, 0x12c, modelContext); + } + Marshal.WriteIntPtr(vtable, 0x0c, IntPtr.Add(aniMeshModule, AniMeshNodeMetadataQueryRva)); + Marshal.WriteIntPtr(vtable, 0x38, IntPtr.Add(aniMeshModule, AniMeshComponentFirstRva)); + Marshal.WriteIntPtr(vtable, 0x3c, IntPtr.Add(aniMeshModule, AniMeshComponentCountRva)); + return ani; + } + + private static IntPtr CreateFac0Owner(Fixture fixture, IntPtr controlModule, + NativeControlOwnerConstructor constructOwner, IntPtr ani, uint originalWord26, + float[] descriptorMobility, float[] mutableAmounts, bool hasCache, float cacheC, + uint[] deviceKeys, float[] deviceValues) { + Expect(descriptorMobility.Length == mutableAmounts.Length, "FAC0 provider fixture lengths"); + Expect(deviceKeys.Length == deviceValues.Length, "FAC0 device fixture lengths"); + IntPtr owner = fixture.Allocate(0x700); + Expect(constructOwner(owner, IntPtr.Zero, 4) == owner, "original Control owner constructor 0x31490"); + IntPtr deviceInterface = IntPtr.Add(owner, 0x0c); + IntPtr deviceVtable = Marshal.ReadIntPtr(deviceInterface); + Expect(deviceVtable == IntPtr.Add(controlModule, ControlProfileDeviceVtableRva) && + Marshal.ReadIntPtr(deviceVtable, 0x0c) == IntPtr.Add(controlModule, ControlProfileDeviceCountRva) && + Marshal.ReadIntPtr(deviceVtable, 0x18) == IntPtr.Add(controlModule, ControlProfileDeviceQueryRva), + "constructor installs original Control device count/query methods"); + Marshal.WriteIntPtr(owner, 0x20, ani); + Marshal.WriteIntPtr(owner, 0x38, deviceInterface); + IntPtr profile = fixture.Allocate(27 * 4); + Marshal.WriteIntPtr(owner, 0x46c, profile); + Marshal.WriteInt32(profile, 26 * 4, unchecked((int)originalWord26)); + Marshal.WriteInt32(owner, 0x4d8, FloatBits(-999.0f)); + if (descriptorMobility.Length != 0) { + IntPtr rows = fixture.Allocate(descriptorMobility.Length * 0x2c); + for (int i = 0; i < descriptorMobility.Length; i++) { + IntPtr descriptor = fixture.Allocate(0x20); + IntPtr row = IntPtr.Add(rows, i * 0x2c); + Marshal.WriteInt32(descriptor, 0x08, FloatBits(descriptorMobility[i])); + Marshal.WriteIntPtr(row, 0x00, descriptor); + Marshal.WriteInt32(row, 0x08, FloatBits(mutableAmounts[i])); + Marshal.WriteInt32(row, 0x20, unchecked((int)0xdeadbeef)); + } + Marshal.WriteIntPtr(owner, 0x55c, rows); + Marshal.WriteInt32(owner, 0x560, descriptorMobility.Length); + } + if (hasCache) { + IntPtr cache = fixture.Allocate(0x10); + Marshal.WriteIntPtr(owner, 0x5b4, cache); + Marshal.WriteInt32(cache, 0x0c, FloatBits(cacheC)); + } + if (deviceKeys.Length != 0) { + IntPtr devices = fixture.Allocate(deviceKeys.Length * IntPtr.Size); + for (int i = 0; i < deviceKeys.Length; i++) { + IntPtr device = fixture.Allocate(0x50); + IntPtr descriptor = fixture.Allocate(0x20); + Marshal.WriteInt32(device, 0x04, unchecked((int)deviceKeys[i])); + Marshal.WriteIntPtr(device, 0x48, descriptor); + Marshal.WriteInt32(descriptor, 0x1c, FloatBits(deviceValues[i])); + Marshal.WriteIntPtr(devices, i * IntPtr.Size, device); + } + Marshal.WriteIntPtr(owner, 0x5bc, devices); + Marshal.WriteInt32(owner, 0x5c0, deviceKeys.Length); + } + return owner; + } + + private static uint ReadFac0Word(IntPtr address, int offset) { + return unchecked((uint)Marshal.ReadInt32(address, offset)); + } + + private static void ExpectFac0Word(IntPtr address, int offset, float expected, string name) { + uint expectedBits = FloatBitsUnsigned(expected); + Expect(ReadFac0Word(address, offset) == expectedBits, + name + " expected " + Hex(expectedBits) + " got " + Hex(ReadFac0Word(address, offset))); + } + + private static void RunFac0CombinedCase(IntPtr controlModule, IntPtr aniMeshModule, + NativeControlOwnerConstructor constructOwner, NativeControlProfileFac0 fac0, + NativeAniMeshNodeMetadataQuery queryNode, NativeAniMeshRangeLookup findFirst, + NativeAniMeshRangeLookup countMatches) { + using (Fixture fixture = NewFixture()) { + IntPtr ani = CreateFac0AniMesh(fixture, aniMeshModule, new uint[] { 1, 0 }, 2.0f, 3.0f, 5.0f, 7.0f, 11.0f); + IntPtr owner = CreateFac0Owner(fixture, controlModule, constructOwner, ani, + FloatBitsUnsigned(2000.0f), new float[] { 2.0f, 3.0f }, new float[] { 123.0f, 456.0f }, + true, 2.0f, new uint[] { 1, 0 }, new float[] { 5.25f, 0.5f }); + Expect(findFirst(ani, 0) == 1 && countMatches(ani, 0) == 1, + "AniMesh component-zero range is first=1,count=1"); + IntPtr outCell = fixture.Allocate(IntPtr.Size); + Expect(NativeBoolean(queryNode(ani, 0, 0x0f, outCell)) == 1 && + ReadFloat(Marshal.ReadIntPtr(outCell), 0) == 42.0f, "original AniMesh selector F result"); + Expect(NativeBoolean(queryNode(ani, 1, 0x10, outCell)) == 1 && + ReadFloat(Marshal.ReadIntPtr(outCell), 0) == 330.0f, "original AniMesh selector 10 result"); + fac0(owner); + Expect(CurrentX87ControlWord() == 0x007f, "FAC0 combined vector retains CW007f"); + ExpectFac0Word(owner, 0x530, 84.0f, "aggregate +0x530"); + ExpectFac0Word(owner, 0x534, 660.0f, "aggregate +0x534"); + ExpectFac0Word(owner, 0x538, 1823.75f, "aggregate +0x538"); + ExpectFac0Word(owner, 0x4d8, 1166.25f, "range-gated profile word 26"); + IntPtr rows = Marshal.ReadIntPtr(owner, 0x55c); + ExpectFac0Word(rows, 0x20, 744.5f, "provider row 0 after device key 0"); + ExpectFac0Word(IntPtr.Add(rows, 0x2c), 0x20, 1079.25f, "provider row 1 after device key 1"); + Console.WriteLine("native-control-fac0-combined: scales=[2,3,5] slots=[7,11] selector-F/10=[42,330]" + + " range=[1,1] descriptor+8=[2,3] mutable-row+8=[123,456] device-word7=[5.25,0.5]" + + " totals=[530:" + Hex(ReadFac0Word(owner, 0x530)) + ",534:" + Hex(ReadFac0Word(owner, 0x534)) + + ",538:" + Hex(ReadFac0Word(owner, 0x538)) + ",4d8:" + Hex(ReadFac0Word(owner, 0x4d8)) + + "] rows20=[" + Hex(ReadFac0Word(rows, 0x20)) + "," + + Hex(ReadFac0Word(IntPtr.Add(rows, 0x2c), 0x20)) + "]"); + } + } + + private static uint RunFac0CacheCase(IntPtr controlModule, IntPtr aniMeshModule, + NativeControlOwnerConstructor constructOwner, NativeControlProfileFac0 fac0, + bool hasCache, float cacheC) { + using (Fixture fixture = NewFixture()) { + IntPtr ani = CreateFac0AniMesh(fixture, aniMeshModule, new uint[] { 0 }, 2.0f, 3.0f, 5.0f, 7.0f, 11.0f); + IntPtr owner = CreateFac0Owner(fixture, controlModule, constructOwner, ani, + FloatBitsUnsigned(1000.0f), new float[] { 1.0f }, new float[] { 777.0f }, hasCache, + cacheC, new uint[0], new float[0]); + fac0(owner); + uint row20 = ReadFac0Word(Marshal.ReadIntPtr(owner, 0x55c), 0x20); + uint expectedRow20 = FloatBitsUnsigned(330.0f + cacheC * 42.0f); + uint expectedFinal = FloatBitsUnsigned(hasCache && cacheC != 0.0f ? 916.0f : 1000.0f); + Expect(row20 == expectedRow20, "FAC0 row20 reflects cache presence/value"); + ExpectFac0Word(owner, 0x530, 42.0f, "cache selector F aggregate"); + ExpectFac0Word(owner, 0x534, 330.0f, "cache selector 10 aggregate"); + Expect(ReadFac0Word(owner, 0x4d8) == expectedFinal, "FAC0 cache final profile word"); + Console.WriteLine("native-control-fac0-cache: ptr=" + (hasCache ? "present" : "null") + + " C=" + Hex(FloatBitsUnsigned(cacheC)) + " row20=" + Hex(row20) + + " total538=" + Hex(ReadFac0Word(owner, 0x538)) + " final4d8=" + Hex(ReadFac0Word(owner, 0x4d8))); + return ReadFac0Word(owner, 0x4d8); + } + } + + private static void RunFac0NegativeClamp(IntPtr controlModule, IntPtr aniMeshModule, + NativeControlOwnerConstructor constructOwner, NativeControlProfileFac0 fac0, + NativeAniMeshRangeLookup findFirst, NativeAniMeshRangeLookup countMatches) { + using (Fixture fixture = NewFixture()) { + IntPtr ani = CreateFac0AniMesh(fixture, aniMeshModule, new uint[] { 1 }, 2.0f, 3.0f, 5.0f, 7.0f, 11.0f); + IntPtr owner = CreateFac0Owner(fixture, controlModule, constructOwner, ani, 0, + new float[] { 1.0f }, new float[] { 44.0f }, false, 0.0f, new uint[0], new float[0]); + Expect(findFirst(ani, 0) == 0xffffffff && countMatches(ani, 0) == 0, + "AniMesh reports no component-zero range"); + fac0(owner); + ExpectFac0Word(owner, 0x538, 330.0f, "unranged provider aggregate"); + Expect(ReadFac0Word(owner, 0x4d8) == 0, "negative result clamps to positive zero"); + Console.WriteLine("native-control-fac0-negative-clamp: total538=" + + Hex(ReadFac0Word(owner, 0x538)) + " final4d8=" + Hex(ReadFac0Word(owner, 0x4d8))); + } + } + + private static void RunFac0NullAndSignedZeroCases(IntPtr controlModule, IntPtr aniMeshModule, + NativeControlOwnerConstructor constructOwner, NativeControlProfileFac0 fac0) { + using (Fixture fixture = NewFixture()) { + IntPtr owner = CreateFac0Owner(fixture, controlModule, constructOwner, IntPtr.Zero, + 0xc0a00000, new float[0], new float[0], false, 0.0f, new uint[0], new float[0]); + fac0(owner); + Expect(ReadFac0Word(owner, 0x4d8) == 0xc0a00000 && ReadFac0Word(owner, 0x530) == 0 && + ReadFac0Word(owner, 0x534) == 0 && ReadFac0Word(owner, 0x538) == 0, + "null AniMesh returns after copying word 26 and before clamp"); + Console.WriteLine("native-control-fac0-null-model: original26=0xc0a00000 final4d8=" + + Hex(ReadFac0Word(owner, 0x4d8))); + } + using (Fixture fixture = NewFixture()) { + IntPtr ani = CreateFac0AniMesh(fixture, aniMeshModule, new uint[0], 2.0f, 3.0f, 5.0f, 7.0f, 11.0f); + IntPtr owner = CreateFac0Owner(fixture, controlModule, constructOwner, ani, 0x80000000, + new float[0], new float[0], false, 0.0f, new uint[0], new float[0]); + fac0(owner); + Expect(ReadFac0Word(owner, 0x4d8) == 0x80000000, "FAC0 preserves negative zero"); + Console.WriteLine("native-control-fac0-signed-zero: total538=" + + Hex(ReadFac0Word(owner, 0x538)) + " final4d8=" + Hex(ReadFac0Word(owner, 0x4d8))); + } + } + + private static void RunFac0OrderedCase(IntPtr controlModule, IntPtr aniMeshModule, + NativeControlOwnerConstructor constructOwner, NativeControlProfileFac0 fac0) { + using (Fixture fixture = NewFixture()) { + IntPtr ani = CreateFac0AniMesh(fixture, aniMeshModule, new uint[] { 1, 1, 1 }, + 1.0f, 1.0f, 1.0f, 0.0f, 1.0f); + IntPtr owner = CreateFac0Owner(fixture, controlModule, constructOwner, ani, + FloatBitsUnsigned(10.0f), new float[] { 16777216.0f, 1.0f, -16777216.0f }, + new float[] { 13.0f, 17.0f, 19.0f }, false, 0.0f, new uint[0], new float[0]); + fac0(owner); + IntPtr rows = Marshal.ReadIntPtr(owner, 0x55c); + Expect(ReadFac0Word(owner, 0x538) == 0 && + ReadFac0Word(owner, 0x534) == FloatBitsUnsigned(3.0f) && + ReadFac0Word(owner, 0x4d8) == FloatBitsUnsigned(10.0f), + "FAC0 keeps forward single-precision aggregate rounding"); + Expect(ReadFac0Word(rows, 0x20) == 0x4b800000 && + ReadFac0Word(IntPtr.Add(rows, 0x2c), 0x20) == 0x3f800000 && + ReadFac0Word(IntPtr.Add(rows, 0x58), 0x20) == 0xcb800000, + "FAC0 preserves provider-row products in source order"); + Console.WriteLine("native-control-fac0-ordered: row-products=[0x4b800000,0x3f800000,0xcb800000]" + + " total538=0x00000000 regrouped=0x3f800000"); + } + } + + private static IntPtr WriteStdCallToFastcallOneArgThunk(int offset, IntPtr fastcallTarget) { + // Adapt stdcall(owner) to the native fastcall owner register in ECX. + byte[] code = { 0x8b, 0x4c, 0x24, 0x04, 0xb8, 0, 0, 0, 0, 0xff, 0xd0, 0xc2, 0x04, 0x00 }; + WriteImm32(code, 5, fastcallTarget.ToInt32()); + IntPtr thunk = IntPtr.Add(fastcallThunks, offset); + Marshal.Copy(code, 0, thunk, code.Length); + return thunk; + } + private static void RunNativeControlProviderInitialStateCase(IntPtr controlModule, IntPtr aniMeshModule) { const int rowCount = 4; NativeControlOwnerConstructor constructOwner = (NativeControlOwnerConstructor)Marshal.GetDelegateForFunctionPointer( diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index be2277a..3d97927 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -540,14 +540,52 @@ resource-device значения во время игры. ### Профиль владельца Control из CTLD -Смысл отдельных слов, последующие изменения mutable-копии и чтения в Control -`fca0/fac0` остаются открытыми. Загрузочный тест проверяет CPU-проекцию, но не -исполнение native полного загрузчика или игрового tick. Точные writer, RVA и -указатели описаны в [техническом разделе о загрузке CTLD](script-vm.md#профиль-владельца-control-при-загрузке-ctld). +Общий проход оригинального `Control.dll!FUN_1000fac0` (RVA `0x0fac0`, SHA-256 +`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`) проверен +прямым x86-вызовом под control word `0x007f` на synthetic backing. Оригинальный +`AniMesh.dll` query `0x51f0` и методы диапазона component ID `0` +`0x5780/0x57c0` участвуют в векторе; Rust воспроизводит его raw output bits и +отдельные cache, order, clamp и signed-zero случаи. -Закрывающий эксперимент: трассировать `LoadControlSystem`, -`LoadPhysicalModel`, `CreateCollManager` и создание collision objects; связать -каждый изменяемый field с созданным shape, contact или реакцией на движение. +Для обычной начальной загрузки scale-input тоже закрыт. AniMesh constructor +оставляет `+0x158/+0x15c/+0x160` равными `1.0`; перед Control event `4` World3D +проходит Agent event `0`, AniMesh RVA `0x1a80` вызывает базовый return `0x17df0` +и передаёт event `4/null` модели, FX, CollManager и CollObject. Их проверенные +ветви `0x75b0/0x6fc0`, Effect RVA `0x3d30`, Control RVA `0x1ce00` и `0x1fe00` +не вызывают AniMesh setter `0x14770` и не меняют outer scale. Поэтому FAC0 в +стандартном `LoadAgent` получает `[1, 1, 1]`. Другой маршрут +`AddNewObjectToGame`/mirror к setter и последующие placement/tick остаются +отдельными границами. В synthetic x86-векторе по-прежнему явно заданы `[2,3,5]`; +он не является источником scale для полного load. + +Смысл `Slot68 +0x30/+0x34`, tag-27 cache DWORD, owner accumulators `+0x530`, +`+0x534`, `+0x538` и результата mutable word `26` не имеет доказанных единиц +измерения. Неизвестные слова остаются raw; их нельзя подписывать как массу или +площадь. Synthetic x86 vector напрямую записывает scales `[2, 3, 5]` в +`AniMesh +0x158/+0x15c/+0x160`; это не значения обычной миссии. Проверка +стандартных `ArealMap`/`World3D` и AniMesh callback-ветвей до FAC0 подтвердила, +что setter не вызывается, поэтому полный начальный loader использует defaults +конструктора `[1, 1, 1]`. Конкретный отдельный путь World3D `AddNewObjectToGame` +и mirror-варианты передают свой transform-вектор setter-у; это отдельная +граница, как и более поздние placement/tick. + +Полный Rust loader теперь вызывает FAC0 при построении +`build_control_device_projection` и публикует результат в +`MissionControlOwnerProfile::load_projection`. Игнорируемый corpus-тест +`full_load_builds_effective_control_device_table_for_mine_unit_dat` на лицензированном +`min_l_n1.dat` проверяет 11 устройств, все 25 соответствий provider ordinal +модели и упорядоченные значения `row +0x20`. Его агрегаты — +`0x470833fc/0x4897e712/0x4d9452cb` для `+0x530/+0x534/+0x538`; исходный и +начальный mutable word `26` равны `0x7f7fffff`. Это подтверждает Rust full-load +результат на оригинальных ресурсах, но не совпадение с полным нативным load +оригинала. Поведение позже при placement/tick и физический смысл raw-полей +остаётся неизвестным. Точные RVA, owner layout и векторы приведены в +[описании изолированного прохода FAC0](script-vm.md#изолированный-проход-fac0). + +Остаётся сравнить этот полный mine load с original loader при тех же входных +данных и продолжить трассировку `LoadControlSystem`, `LoadPhysicalModel`, +`CreateCollManager` и collision objects, если потребуется связать profile words +с shapes, contacts или реакцией на движение. ## DirectPlay wire diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index d47a64d..c86e562 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -977,6 +977,143 @@ Ignored Rust-тест также проходит полный неотфиль профилю. Это подтверждает декодирование и CPU-проекцию, но не исполнение native полного загрузчика. +#### Изолированный проход FAC0 + +В оригинальном `Control.dll` SHA-256 +`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e` +функция `FUN_1000fac0` (RVA `0x0fac0`) рассчитывает начальное состояние +mutable-копии CTLD профиля. x86 oracle вызывает её с указателем owner в `ECX` +через короткий stdcall-to-fastcall переходник. Вектор создаёт owner исходным +конструктором RVA `0x31490`, но памяти для профиля, provider rows, device list и +AniMesh модели выделяет искусственно. Код FAC0 и выбранные методы DLL при этом +остаются оригинальными. + +В используемой раскладке owner функция читает и записывает следующие поля: + +| Смещение owner | Что находится в тестовом ABI | +| --- | --- | +| `+0x20` | указатель на AniMesh; в отдельном векторе здесь null | +| `+0x46c` | указатель на исходный блок из 27 слов профиля | +| `+0x470` | изменяемая копия из 27 слов; её word `26` находится по `+0x4d8` | +| `+0x530`, `+0x534`, `+0x538` | три накопителя профиля | +| `+0x55c`, `+0x560` | указатель на строки provider и их count; шаг строки — `0x2c` | +| `+0x5b4` | необязательный указатель cache; используемый DWORD расположен по `+0x0c` | +| `+0x5bc`, `+0x5c0` | массив указателей на устройства и его count | + +Это описание ABI-вектора, а не отдельная публичная Rust layout-структура. + +Для чтения MSH функции используется оригинальный `AniMesh.dll` SHA-256 +`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`: +`FUN_100051f0` (RVA `0x51f0`) запрашивает значение узла по selector `0x0f` или +`0x10`, а `FUN_10005780` / `FUN_100057c0` (RVA `0x5780` / `0x57c0`) дают +начало и длину диапазона глобальных узлов component ID `0`. В model data функция +`0x51f0` читает Node38 mapping и использует два выбранных DWORD Slot68 `+0x30` +и `+0x34` при вычислении selectors. Это opaque `opaque[0]` и `opaque[1]`, а +не доказанные физические коэффициенты или значения, которые query возвращает без +преобразования. + +Slot выбирается первым словом `Node38.slot_index[state * 5 + lod]`, то есть для +начального профиля — state `0`, LOD `0` и byte offset `+0x08` в Node38. +`0xffff` обозначает отсутствие slot и даёт нулевые selectors без умножения на +scales. В Rust проекции другой непригодный индекс или нечитаемая запись — +неизвестное сопоставление, а не известный нулевой slot. Для валидной ссылки Rust +использует `selected_slot` и сохраняет эту границу через `Option`. + +Если три входных `f32` из AniMesh `+0x158`, `+0x15c`, `+0x160` обозначить +`s158`, `s15c`, `s160`, а два raw Slot68 слова — `v30` и `v34`, порядок native +умножений задаёт selectors: + +```text +selector F = (s15c * s158) * v30 +selector 10 = ((s160 * s15c) * s158) * v34 +``` + +Затем для каждой provider row по её global-node ordinal вычисляется +`base = row.mobility_per_volume * selector 10`. `+0x534` накапливает selector +`10`, `+0x530` — selector `F`, а `+0x538` — `row +0x20` после начального +расчёта и device contributions. Если ordinal попадает в диапазон component ID +`0`, `base` добавляется к **исходному** profile word `26`; накопление начинается +из original CTLD copy, а не из прежнего mutable word `+0x4d8`. + +Ненулевой cache pointer `owner +0x5b4` указывает на raw cache DWORD `+0x0c`. +Он добавляет в каждый `row +0x20` значение `cache27 * selector F`. В +проекции Rust последняя tag `27` запись начального append задаёт raw cache из +`fixed_words[11]`; последующий tag `27` в `INTO` обновляет существующее значение, +а другой tag его не меняет. Если `INTO` встречается до создания cache, результат +профиля помечается недоступным и остаётся таким после более позднего append. +Cache отсутствует и cache со значением ноль численно дают одинаковую добавку; +это не превращает пропущенную ссылку в нулевые данные. + +После начального прохода устройства читаются в обратном порядке. Каждое +устройство берёт индекс provider row из ключа `+0x04`, а DWORD descriptor +`+0x1c` прибавляет и к выбранному `row +0x20`, и к `+0x538`. Накопители +складываются в наблюдаемом порядке одиночной точности; перестановка или +переассоциация слагаемых меняет биты. + +После вычитания `+0x538` из подготовленного word `26` отрицательный результат +записывается как положительный ноль. Ненегативный результат, включая `-0.0`, +сохраняет свои исходные биты. Если AniMesh pointer отсутствует, оригинальный +word `26` просто копируется в mutable copy, и функция возвращает до этого +вычитания. Ни слова профиля, ни cache, ни Slot68 DWORD пока не получили названий +«масса» или «площадь». + +Объединённый вектор под x87 control word `0x007f` задаёт scales `[2, 3, 5]`, +Slot68 raw words `[7, 11]`, коэффициенты двух provider rows `[2, 3]`, диапазон +component `0` `[first=1, count=1]`, cache `27 = 2`, device keys `[1, 0]` с +descriptor values `[5.25, 0.5]`, original word `26 = 2000` и прежнюю mutable +копию word `26 = -999`. Исходные функции выдают: + +| Поле | Десятичное значение | Raw bits | +| --- | ---: | --- | +| `owner +0x530` | `84` | `0x42a80000` | +| `owner +0x534` | `660` | `0x44250000` | +| `owner +0x538` | `1823.75` | `0x44e3f800` | +| `owner +0x4d8` (mutable word `26`) | `1166.25` | `0x4491c800` | +| provider row `+0x20`, row `0` | `744.5` | `0x443a2000` | +| provider row `+0x20`, row `1` | `1079.25` | `0x4486e800` | + +Другие векторы показывают различие отсутствующего и нулевого cache, положительный +ноль после negative clamp, сохранение signed zero и прямое сложение в source +order: `2^24 + 1 - 2^24` даёт `0`, тогда как группировка `2^24 - 2^24 + 1` +дала бы `1`. Rust helper повторяет эти тестовые биты в поддерживаемом домене +normal-or-zero; он отклоняет непредставимые значения и промежуточные результаты, +а не заявляет parity для каждого x87 режима или всех допустимых битовых payload. + +Этот x86-вектор подтверждает арифметику оригинальной функции на synthetic +backing. Значения `[2, 3, 5]` относятся только к нему; источник scales в +обычной загрузке — другой, отдельно проверенный путь, привязанный к GOG +сборкам: `ArealMap.dll` SHA-256 +`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`, +`World3D.dll` `17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e`, +`AniMesh.dll` `65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`, +`Effect.dll` `386a3e8db1ce23f61957825d633c7dae1ed40c25cab0cf10a534385fd7cee789` +и `Control.dll` `bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`. +Сначала `AniMesh::FUN_10002ff0` (RVA `0x2ff0`) вызывает `LoadAniMesh`, затем — +`LoadControlSystem` на RVA `0x32e7`. После возврата из этой фабрики обычный +`ArealMap` путь `0x14fd0/0x15290` вызывает `World3D.CreateObject` +(`0x7cb0/0x7a50`), который до возврата посылает Agent event `0` через primary +vtable slot `+0x38`. Agent RVA `0x1a80` сначала вызывает базовый обработчик +`0x17df0`, затем рассылает компонентам event `4` с null payload. Конструктор +AniMesh задаёт `+0x158/+0x15c/+0x160` равными `1.0`. +Обработчик модели `0x75b0/0x6fc0` привязывает интерфейсы и матрицу, Effect RVA +`0x3d30` и Control RVA `0x1ce00` сохраняют host/service указатели, а Control +`0x1fe00` меняет только локальный collision-флаг. Эти ветви не вызывают AniMesh +scale setter `0x14770` и не пишут поля масштаба. После них Control проходит +`0x31b00 -> 0x314f0 -> 0x7830 -> 0xf940`, то есть в FAC0 входит с constructor +scales `[1, 1, 1]`. Это подтверждено только для обычной начальной загрузки и +rebind; отдельные AddNewObject/mirror scale setters и более поздние placement/ +tick пути в этот вывод не входят. + +Теперь полный Rust loader вызывает FAC0 в `build_control_device_projection` +после сборки provider rows и устройств. Игнорируемый тест +`full_load_builds_effective_control_device_table_for_mine_unit_dat` прогнан на +лицензированном `min_l_n1.dat`: он проверяет 11 устройств, сопоставление всех +25 provider rows и весь упорядоченный вектор `row +0x20`. Агрегаты mine равны +`+0x530 = 0x470833fc`, `+0x534 = 0x4897e712`, `+0x538 = 0x4d9452cb`; исходное и +начальное mutable word `26` оба равны `0x7f7fffff`. Это измеренный результат +Rust loader-а на оригинальных ресурсах, но не сравнение полного load результата +с оригинальным loader-ом. Имена и физические единицы полей остаются неизвестны. + #### Входной класс Control при загрузке Unit DAT Этот класс задаётся до разбора CTLD и не выводится из device tag или MSH- diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index 68a35e6..cd8a0ba 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -717,6 +717,34 @@ Rust full-loader теста на оригинальных ресурсах. Ве `None`, без дочернего fallback или синтетических нулей. Точные native writes и открытые вопросы о смысле полей приведены в [техническом разборе загрузки CTLD](../appendices/script-vm.md#профиль-владельца-control-при-загрузке-ctld). +Изолированный x86-вектор подтвердил общий проход `Control.dll!FUN_1000fca0` +(RVA `0x0fac0`): расчёт начинает с исходного profile word 26, а затем пишет +производное значение в изменяемую копию и заполняет три поля-накопителя +владельца, а также `+0x20` каждой строки provider. Для этого выбираются два +сырых слова `Slot68` через `Node38` state 0 / LOD 0, учитываются cache tag 27 и +устройства таблицы. +Смысл слов `Slot68` и итоговых чисел не установлен; мы не называем их массой +или площадью. + +Rust повторяет эту формулу изолированным helper-ом, и его тест совпадает с +оригинальным кодом на синтетических векторах при x87 control word `0x007f`. +В обычной цепочке загрузки AniMesh начинает с масштабов `[1, 1, 1]`, а вызванные +до FAC0 обработчики привязки модели, FX и collision-компонентов эти поля не +меняют. Поэтому загрузчик использует подтверждённые значения конструктора, а +не scale из TMA. Теперь `build_control_device_projection` выполняет FAC0 после +сборки provider rows и списка устройств и публикует результат вместе с копией +профиля владельца. + +Игнорируемый corpus-тест полного загрузчика на оригинальном +`UNITS/BUILDS/MINE/min_l_n1.dat` подтверждает сопоставление всех 25 provider +rows с начальными узлами и проекцию 11 устройств. Для профиля mine получены +агрегаты `+0x530 = 0x470833fc`, `+0x534 = 0x4897e712` и +`+0x538 = 0x4d9452cb`; тест сверяет также все 25 значений строк. Это результат +Rust loader-а на лицензированных данных, а не сравнение с полным native load +оригинала. Смысл слов `Slot68` и итоговых чисел по-прежнему неизвестен. Точные +входы, вектор и границы приведены в +[техническом разборе загрузки CTLD](../appendices/script-vm.md#изолированный-проход-fac0). + Для tag `19` фабрика создаёт объект с его исходными классом и vtable. Базовый конструктор `FUN_100218b0` задаёт multiplier `+0x4c = 1.0`. Override через `FUN_1002d890` вызывает у выбранного объекта vtable slot `+0x1c`, то есть