diff options
Diffstat (limited to 'crates/fparkan-assets')
| -rw-r--r-- | crates/fparkan-assets/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/fparkan-assets/src/lib.rs | 571 |
2 files changed, 572 insertions, 0 deletions
diff --git a/crates/fparkan-assets/Cargo.toml b/crates/fparkan-assets/Cargo.toml index ce3cb61..c2b8441 100644 --- a/crates/fparkan-assets/Cargo.toml +++ b/crates/fparkan-assets/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true repository.workspace = true [dependencies] +fparkan-binary = { path = "../fparkan-binary" } fparkan-material = { path = "../fparkan-material" } fparkan-msh = { path = "../fparkan-msh" } fparkan-nres = { path = "../fparkan-nres" } diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index d34effb..e1abea2 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/src/lib.rs @@ -1,6 +1,7 @@ #![forbid(unsafe_code)] //! Asset manager ports and transactional preparation models. +use fparkan_binary::{checked_count_bytes, read_lp_bytes, Cursor, DecodeError}; use fparkan_material::{ decode_mat0, decode_wear, resolve_material, MaterialFallback, ResolvedMaterial, WearTable, MAT0_KIND, WEAR_KIND, @@ -62,6 +63,150 @@ pub struct PreparedMaterialPhase { pub page_uv_transform: [f32; 4], } +/// The two Behavior profile values currently used by recovered resource queries. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BehaviorProfileRates { + /// Second raw value blob for `Transfer_Ore_OffBoard`. + pub transfer_ore_off_board_bits: u32, + /// Second raw value blob for `Transfer_Power_Out`. + pub transfer_power_out_bits: u32, +} + +/// Error while reading a bounded binary Behavior `.var` profile. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BehaviorProfileVarError { + /// The binary record framing is malformed. + Decode(DecodeError), + /// A bound for one of the two fields used here is not a 32-bit float. + InvalidFloatField { + name: &'static str, + size: u32, + type_index: u32, + }, + /// A registered runtime field was absent from the profile. + MissingField { name: &'static str }, +} + +impl fmt::Display for BehaviorProfileVarError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Decode(source) => write!(f, "{source}"), + Self::InvalidFloatField { + name, + size, + type_index, + } => write!( + f, + "field {name} has size {size} and type index {type_index}, expected a 4-byte float" + ), + Self::MissingField { name } => write!(f, "required field {name} is missing"), + } + } +} + +impl std::error::Error for BehaviorProfileVarError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Decode(source) => Some(source), + Self::InvalidFloatField { .. } | Self::MissingField { .. } => None, + } + } +} + +impl From<DecodeError> for BehaviorProfileVarError { + fn from(value: DecodeError) -> Self { + Self::Decode(value) + } +} + +/// Reads the two Behavior rate fields from a native binary `.var` profile. +/// +/// Native records contain two equally sized value blobs. After binding a +/// registered destination, the original applies the second blob. Field names +/// are matched case-sensitively as C strings (up to the first NUL), and the +/// first occurrence wins. Other fields are consumed but their meanings remain +/// outside this decoder. +/// +/// # Errors +/// +/// Returns [`BehaviorProfileVarError`] when record framing is truncated, +/// contains trailing bytes, omits a registered field, or declares a selected +/// field that is not a float row. +pub fn decode_behavior_profile_rates( + bytes: &[u8], +) -> Result<BehaviorProfileRates, BehaviorProfileVarError> { + let mut cursor = Cursor::new(bytes); + let count = cursor.read_u32_le()?; + checked_count_bytes(u64::from(count), 28, cursor.remaining() as u64)?; + + let mut transfer_ore_off_board_bits = None; + let mut transfer_power_out_bits = None; + for _ in 0..count { + let size = cursor.read_u32_le()?; + let type_index = cursor.read_u32_le()?; + let _flags = cursor.read_u32_le()?; + let field_name = read_lp_bytes(&mut cursor, 64 * 1024)?; + let c_string_name = field_name + .split(|byte| *byte == 0) + .next() + .unwrap_or_default(); + let target_name = match c_string_name { + b"Transfer_Ore_OffBoard" => Some("Transfer_Ore_OffBoard"), + b"Transfer_Power_Out" => Some("Transfer_Power_Out"), + _ => None, + }; + let _type_name = read_lp_bytes(&mut cursor, 64 * 1024)?; + let value_size = usize::try_from(size).map_err(|_| DecodeError::IntegerOverflow)?; + cursor.read_exact(value_size)?; + let capture_target = target_name.is_some_and(|name| { + if name == "Transfer_Ore_OffBoard" { + transfer_ore_off_board_bits.is_none() + } else { + transfer_power_out_bits.is_none() + } + }); + let second_value_bits = if capture_target { + let name = target_name.expect("capture requires a target field"); + if size != 4 || type_index != 3 { + return Err(BehaviorProfileVarError::InvalidFloatField { + name, + size, + type_index, + }); + } + let value = cursor.read_exact(value_size)?; + Some(u32::from_le_bytes( + value.try_into().expect("validated four-byte float row"), + )) + } else { + cursor.read_exact(value_size)?; + None + }; + cursor.read_exact(8)?; + + if let (Some(name), Some(value_bits)) = (target_name, second_value_bits) { + if name == "Transfer_Ore_OffBoard" { + transfer_ore_off_board_bits = Some(value_bits); + } else { + transfer_power_out_bits = Some(value_bits); + } + } + } + cursor.require_eof()?; + Ok(BehaviorProfileRates { + transfer_ore_off_board_bits: transfer_ore_off_board_bits.ok_or( + BehaviorProfileVarError::MissingField { + name: "Transfer_Ore_OffBoard", + }, + )?, + transfer_power_out_bits: transfer_power_out_bits.ok_or( + BehaviorProfileVarError::MissingField { + name: "Transfer_Power_Out", + }, + )?, + }) +} + type WearValidationCache = HashMap<(NormalizedPath, Vec<u8>), Result<fparkan_material::WearTable, String>>; @@ -1180,6 +1325,19 @@ impl Default for ControlTag21InstanceState { /// Input or metadata prevented a safe native control sample. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ControlSamplingError { + /// This resource-device helper only implements typed-record tag 26. + UnsupportedResourceDeviceTag { actual: u32 }, + /// The record's raw provider key has no resolved NDP table. + MissingResourceDeviceNodeData { key: u32 }, + /// The record's raw provider key is not a row in its NDP table. + ResourceDeviceNodeDataKeyOutOfRange { key: u32, row_count: usize }, + /// The caller did not supply the provider's current type-1 value. + MissingResourceDeviceProviderValue { key: u32 }, + /// A coefficient is outside the helper's finite normal-or-zero contract. + InvalidResourceDeviceOperand { operand: &'static str }, + /// The native x87 intermediate is outside the range represented by normal + /// f32 values, so the Rust result could differ from the original. + UnsupportedResourceDeviceFloatRange { stage: &'static str }, /// A state object was reused with a different model node count. StateNodeCountMismatch { expected: usize, actual: usize }, /// A state object was reused with a different number of tag-21 records. @@ -1216,6 +1374,74 @@ pub enum ControlSamplingError { NonFiniteSample { record_index: usize }, } +/// Samples Control's tag-26 typed-record query `0x300` for one current state. +/// +/// The key in fixed word 1 must select an existing NDP row. The caller supplies +/// that provider's current type-1 value and the object's current multiplier; +/// this function does not invent their runtime state. Fixed word 11 is the +/// coefficient loaded by selector `0x300`. Native code multiplies in the order +/// `provider_now * coefficient * multiplier_now`. +/// +/// The source x87 code returns a value with an extended exponent range even +/// though it uses 24-bit precision. This f32 helper therefore accepts only +/// finite normal-or-zero operands and normal-or-exact-zero intermediate and +/// final products. It rejects subnormal or out-of-f32-range products rather +/// than silently underflowing or overflowing differently from x87. +pub fn sample_control_resource_device_selector_300( + record: &PreparedControlTypedRecord, + node_data: Option<&PreparedControlNodeDataTable>, + provider_now: Option<f32>, + multiplier_now: f32, +) -> Result<f32, ControlSamplingError> { + if record.tag != 26 { + return Err(ControlSamplingError::UnsupportedResourceDeviceTag { actual: record.tag }); + } + + let key = record.fixed_words[1]; + let Some(node_data) = node_data else { + return Err(ControlSamplingError::MissingResourceDeviceNodeData { key }); + }; + if !usize::try_from(key).is_ok_and(|key| key < node_data.rows.len()) { + return Err(ControlSamplingError::ResourceDeviceNodeDataKeyOutOfRange { + key, + row_count: node_data.rows.len(), + }); + } + let provider_now = + provider_now.ok_or(ControlSamplingError::MissingResourceDeviceProviderValue { key })?; + let base = f32::from_bits(record.fixed_words[11]); + + for (operand, value) in [ + ("provider", provider_now), + ("base coefficient", base), + ("multiplier", multiplier_now), + ] { + if !value.is_finite() || (value != 0.0 && !value.is_normal()) { + return Err(ControlSamplingError::InvalidResourceDeviceOperand { operand }); + } + } + + let provider_times_base = + native_range_compatible_product(provider_now, base, "provider times base")?; + native_range_compatible_product(provider_times_base, multiplier_now, "then multiplier") +} + +fn native_range_compatible_product( + lhs: f32, + rhs: f32, + stage: &'static str, +) -> Result<f32, ControlSamplingError> { + let exact = f64::from(lhs) * f64::from(rhs); + let absolute = exact.abs(); + let minimum_normal = f64::from(f32::MIN_POSITIVE); + let maximum = f64::from(f32::MAX); + if exact != 0.0 && (absolute < minimum_normal || absolute > maximum) { + return Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange { stage }); + } + + Ok(lhs * rhs) +} + /// Samples per-node animation times using the decoded Control timeline. /// /// `native_clock_ms` is the absolute u32 millisecond clock passed to native @@ -4141,6 +4367,297 @@ mod tests { // Preserve the authored CTLD fixture bits; this is not the `f32` value of tau. const AUTHORED_TAG21_RAW_PARAMETER_3: f32 = f32::from_bits(0x40C8_F5C3); + fn behavior_var_row(name: &[u8], type_index: u32, first: u32, second: u32) -> Vec<u8> { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&4_u32.to_le_bytes()); + bytes.extend_from_slice(&type_index.to_le_bytes()); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(&(name.len() as u32).to_le_bytes()); + bytes.extend_from_slice(name); + bytes.extend_from_slice(&0_u32.to_le_bytes()); // Native transfer rows use an empty type-name. + bytes.extend_from_slice(&first.to_le_bytes()); + bytes.extend_from_slice(&second.to_le_bytes()); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes + } + + fn behavior_var(rows: &[Vec<u8>]) -> Vec<u8> { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&(rows.len() as u32).to_le_bytes()); + for row in rows { + bytes.extend_from_slice(row); + } + bytes + } + + #[test] + fn behavior_profile_decoder_uses_first_exact_rows_second_blob_and_float_kind() { + let profile = behavior_var(&[ + behavior_var_row(b"transfer_ore_off_board", 3, 0x1111_1111, 0x2222_2222), + behavior_var_row( + b"Transfer_Ore_OffBoard\0legacy-suffix", + 3, + 0x3333_3333, + 0x3f80_0000, + ), + behavior_var_row(b"Transfer_Ore_OffBoard", 3, 0x4444_4444, 0x4000_0000), + behavior_var_row(b"Transfer_Power_Out", 3, 0x5555_5555, 0x4120_0000), + ]); + + assert_eq!( + decode_behavior_profile_rates(&profile), + Ok(BehaviorProfileRates { + transfer_ore_off_board_bits: 0x3f80_0000, + transfer_power_out_bits: 0x4120_0000, + }) + ); + } + + #[test] + fn behavior_profile_decoder_rejects_every_truncation_and_trailing_bytes() { + let valid = behavior_var(&[ + behavior_var_row(b"Transfer_Ore_OffBoard", 3, 0, 0x3f80_0000), + behavior_var_row(b"Transfer_Power_Out", 3, 0, 0x4120_0000), + ]); + for end in 0..valid.len() { + assert!( + decode_behavior_profile_rates(&valid[..end]).is_err(), + "prefix ending at byte {end} was accepted" + ); + } + + let mut with_trailing = valid.clone(); + with_trailing.push(0); + assert!(matches!( + decode_behavior_profile_rates(&with_trailing), + Err(BehaviorProfileVarError::Decode( + fparkan_binary::DecodeError::TrailingBytes { .. } + )) + )); + } + + #[test] + fn behavior_profile_decoder_requires_both_registered_float_fields() { + let profile = behavior_var(&[behavior_var_row(b"Transfer_Power_Out", 3, 0, 0x4120_0000)]); + assert_eq!( + decode_behavior_profile_rates(&profile), + Err(BehaviorProfileVarError::MissingField { + name: "Transfer_Ore_OffBoard" + }) + ); + + let profile = behavior_var(&[ + behavior_var_row(b"Transfer_Ore_OffBoard", 3, 0, 0x3f80_0000), + behavior_var_row(b"Transfer_Power_Out", 1, 0, 0x4120_0000), + ]); + assert!(matches!( + decode_behavior_profile_rates(&profile), + Err(BehaviorProfileVarError::InvalidFloatField { + name: "Transfer_Power_Out", + size: 4, + type_index: 1, + }) + )); + } + + fn resource_device_record(tag: u32, key: u32, base_bits: u32) -> PreparedControlTypedRecord { + let mut fixed_words = [0; 44]; + fixed_words[1] = key; + fixed_words[11] = base_bits; + PreparedControlTypedRecord { + tag, + fixed_words, + values: Vec::new(), + string_bytes: Vec::new(), + gate_provider: PreparedControlGateProvider::NotApplicable, + } + } + + fn resource_device_node_data_table(row_count: usize) -> PreparedControlNodeDataTable { + PreparedControlNodeDataTable { + source: ResourceKey { + archive: parse_path("parts.rlb").expect("NDP archive"), + name: resource_name(b"resource-device.ndp"), + type_id: None, + }, + rows: vec![ + PreparedControlNodeDataRow { + raw_flags: 0, + max_volume: 0.0, + mobility_per_volume: 0.0, + effect: PreparedControlResourcePair { + archive: [0; 32], + name: [0; 32], + }, + }; + row_count + ], + } + } + + #[test] + fn tag26_selector_300_multiplies_provider_then_fixed_base_then_multiplier() { + let record = resource_device_record(26, 1, 0x3D25_AE4A); + let node_data = resource_device_node_data_table(2); + let provider_now = f32::from_bits(0x3FE6_982F); + let multiplier_now = f32::from_bits(0x4071_5539); + + let sampled = sample_control_resource_device_selector_300( + &record, + Some(&node_data), + Some(provider_now), + multiplier_now, + ) + .expect("valid tag-26 resource-device sample"); + + assert_eq!(sampled.to_bits(), 0x3E8C_B02E); + assert_ne!( + sampled.to_bits(), + ((provider_now * multiplier_now) * f32::from_bits(record.fixed_words[11])).to_bits(), + "the original selector multiplies the provider by the record base first" + ); + } + + #[test] + fn tag26_selector_300_requires_its_ndp_row_and_explicit_provider() { + let record = resource_device_record(26, 1, 2.0_f32.to_bits()); + let one_row = resource_device_node_data_table(1); + assert_eq!( + sample_control_resource_device_selector_300(&record, Some(&one_row), Some(3.0), 4.0,), + Err(ControlSamplingError::ResourceDeviceNodeDataKeyOutOfRange { + key: 1, + row_count: 1, + }) + ); + + let two_rows = resource_device_node_data_table(2); + assert_eq!( + sample_control_resource_device_selector_300(&record, Some(&two_rows), None, 4.0), + Err(ControlSamplingError::MissingResourceDeviceProviderValue { key: 1 }) + ); + assert_eq!( + sample_control_resource_device_selector_300(&record, None, Some(3.0), 4.0), + Err(ControlSamplingError::MissingResourceDeviceNodeData { key: 1 }) + ); + } + + #[test] + fn tag26_selector_300_rejects_other_tags_and_extended_x87_ranges() { + let node_data = resource_device_node_data_table(1); + let tag21 = resource_device_record(21, 0, 1.0_f32.to_bits()); + assert_eq!( + sample_control_resource_device_selector_300(&tag21, Some(&node_data), Some(1.0), 1.0), + Err(ControlSamplingError::UnsupportedResourceDeviceTag { actual: 21 }) + ); + + let tag26 = resource_device_record(26, 0, f32::MIN_POSITIVE.to_bits()); + assert_eq!( + sample_control_resource_device_selector_300( + &tag26, + Some(&node_data), + Some(f32::MIN_POSITIVE), + 1.0, + ), + Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange { + stage: "provider times base", + }) + ); + } + + #[test] + fn tag26_selector_300_preserves_negative_and_signed_zero_values() { + let node_data = resource_device_node_data_table(1); + let record = resource_device_record(26, 0, 0.5_f32.to_bits()); + let negative = + sample_control_resource_device_selector_300(&record, Some(&node_data), Some(-2.0), 4.0) + .expect("negative factors are not clamped"); + assert_eq!(negative, -4.0); + + let negative_zero = sample_control_resource_device_selector_300( + &resource_device_record(26, 0, 2.0_f32.to_bits()), + Some(&node_data), + Some(-0.0), + 3.0, + ) + .expect("signed zero is an exact native input"); + assert_eq!(negative_zero.to_bits(), (-0.0_f32).to_bits()); + + let positive_zero = sample_control_resource_device_selector_300( + &resource_device_record(26, 0, 2.0_f32.to_bits()), + Some(&node_data), + Some(0.0), + 3.0, + ) + .expect("positive zero is an exact native input"); + assert_eq!(positive_zero.to_bits(), 0.0_f32.to_bits()); + } + + #[test] + fn tag26_selector_300_rejects_nonfinite_and_subnormal_operands() { + let node_data = resource_device_node_data_table(1); + for (operand, provider_now, multiplier_now, base_bits) in [ + ("provider", f32::NAN, 1.0, 1.0_f32.to_bits()), + ("multiplier", 1.0, f32::INFINITY, 1.0_f32.to_bits()), + ("base coefficient", 1.0, 1.0, 1), + ] { + let record = resource_device_record(26, 0, base_bits); + assert_eq!( + sample_control_resource_device_selector_300( + &record, + Some(&node_data), + Some(provider_now), + multiplier_now, + ), + Err(ControlSamplingError::InvalidResourceDeviceOperand { operand }) + ); + } + } + + #[test] + fn tag26_selector_300_rejects_extended_intermediate_and_final_ranges() { + let node_data = resource_device_node_data_table(1); + let provider = f32::MAX; + let overflowing_intermediate = resource_device_record(26, 0, 2.0_f32.to_bits()); + assert_eq!( + sample_control_resource_device_selector_300( + &overflowing_intermediate, + Some(&node_data), + Some(provider), + 0.25, + ), + Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange { + stage: "provider times base", + }) + ); + + let underflowing_intermediate = resource_device_record(26, 0, f32::MIN_POSITIVE.to_bits()); + assert_eq!( + sample_control_resource_device_selector_300( + &underflowing_intermediate, + Some(&node_data), + Some(f32::MIN_POSITIVE), + f32::MAX, + ), + Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange { + stage: "provider times base", + }) + ); + + let subnormal_final = resource_device_record(26, 0, 1.0_f32.to_bits()); + assert_eq!( + sample_control_resource_device_selector_300( + &subnormal_final, + Some(&node_data), + Some(f32::MIN_POSITIVE), + 0.5, + ), + Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange { + stage: "then multiplier", + }) + ); + } + fn tag21_fullcycle_control() -> PreparedComponentControl { let source = ResourceKey { archive: parse_path("parts.rlb").expect("archive"), @@ -6166,6 +6683,60 @@ mod tests { #[test] #[ignore = "requires licensed corpus"] + fn tag26_selector_300_uses_a_real_control_record_and_ndp_key() { + let root = fixture_root("IS"); + let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root)); + let repository = CachedResourceRepository::new(Arc::clone(&vfs)); + let roots = [resource_name(b"UNITS/BUILDS/MINE/min_l_n1.dat")]; + let (_graph, prototypes) = + build_prototype_graph(&repository, vfs.as_ref(), &roots).expect("prototype graph"); + let assets = prepare_mission_assets_with_repository( + &repository, + &[0..prototypes.len()], + &prototypes, + ) + .expect("prepared licensed unit controls"); + + let mut sampled_records = 0; + for control in assets.object_component_controls.iter().flatten().flatten() { + for record in control + .typed_records + .iter() + .filter(|record| record.tag == 26) + { + let node_data = control + .node_data + .as_ref() + .expect("tag-26 control has its explicit NDP table"); + assert_eq!(control.source.name.0, b"fr_l_mine.ctl"); + assert_eq!(node_data.source.name.0, b"fr_l_mine.ndp"); + assert_eq!(node_data.rows.len(), 22); + assert_eq!(record.fixed_words[1], 2); + assert_eq!(record.fixed_words[11], 0x3F80_0000); + let sample = sample_control_resource_device_selector_300( + record, + Some(node_data), + Some(1.0), + 1.0, + ) + .expect("real tag-26 key selects an NDP row"); + assert_eq!(sample.to_bits(), record.fixed_words[11]); + println!( + "control={}: ndp={}: key={} rows={} coefficient=0x{:08x}", + String::from_utf8_lossy(&control.source.name.0), + String::from_utf8_lossy(&node_data.source.name.0), + record.fixed_words[1], + node_data.rows.len(), + record.fixed_words[11], + ); + sampled_records += 1; + } + } + assert_eq!(sampled_records, 1); + } + + #[test] + #[ignore = "requires licensed corpus"] fn repository_plan_deduplicates_duplicate_visuals_but_graph_preserves_requests() { let root = fixture_root("IS"); let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root)); |
