diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/fparkan-assets/src/lib.rs | 1276 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/environment.rs | 27 | ||||
| -rw-r--r-- | crates/fparkan-prototype/src/lib.rs | 543 | ||||
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 110 | ||||
| -rw-r--r-- | crates/fparkan-terrain-format/src/lib.rs | 34 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/lib.rs | 161 |
6 files changed, 2053 insertions, 98 deletions
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index a79220a..7ef546f 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/src/lib.rs @@ -16,9 +16,9 @@ use fparkan_nres::{decode as decode_nres, ReadProfile}; pub use fparkan_nres::{NresDocument, NresError}; use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy, ResourceName}; use fparkan_prototype::{ - EffectivePrototype, PrototypeGeometry, PrototypeGraph, PrototypeGraphEdge, - PrototypeGraphFailure, PrototypeGraphNodeKind, PrototypeGraphProvenance, PrototypeGraphReport, - PrototypeGraphRequiredness, + decode_bas_geometry, BasGeometry, EffectivePrototype, PrototypeGeometry, PrototypeGraph, + PrototypeGraphEdge, PrototypeGraphFailure, PrototypeGraphNodeKind, PrototypeGraphProvenance, + PrototypeGraphReport, PrototypeGraphRequiredness, }; use fparkan_resource::{CachedResourceRepository, ResourceError, ResourceKey, ResourceRepository}; pub use fparkan_terrain::{TerrainError, TerrainWorld}; @@ -901,14 +901,117 @@ pub struct PreparedVisual { pub struct PreparedComponentControl { /// CTLD resource that supplied these bindings. pub source: ResourceKey, + /// 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. pub bindings: Vec<PreparedControlNodeBinding>, + /// Every raw 36-byte animation row in source order. The typed controller + /// map refers to these original row indices, including disabled rows. + pub timeline_rows: Vec<PreparedControlTimelineRow>, + /// Parsed typed records retained for the native controller evaluator. + pub typed_records: Vec<PreparedControlTypedRecord>, + /// Explicit NDP sidecar and its validated rows, when resolved. + pub node_data: Option<PreparedControlNodeDataTable>, /// Parsed native effect rows in this component's control resource. pub effect_section: PreparedControlEffectSection, /// Component-local points selected for parsed effect rows, when present. pub point_list: Option<PreparedControlPointList>, } +/// Component-local BAS geometry loaded from an explicitly referenced BAS +/// resource. This remains aligned with each component instance, not the shared +/// model cache. +#[derive(Clone, Debug, PartialEq)] +pub struct PreparedComponentBas { + /// BAS resource that supplied this geometry. + pub source: ResourceKey, + /// Decoded BAS point sections. + pub geometry: BasGeometry, +} + +/// Raw and decoded fields from one 36-byte CTLD animation row. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PreparedControlTimelineRow { + /// Original row index, used by typed-record map values. + pub row_index: usize, + /// Target node index in the component model. + pub node_index: usize, + /// First animation endpoint. + pub frame_a: f32, + /// Second animation endpoint. + pub frame_b: f32, + /// Initial blend written by native Control during setup. + pub initial_blend: f32, + /// Native auxiliary words at row offsets +0x10, +0x14, +0x18, and +0x1C. + pub raw_parameters: [u32; 4], + /// Raw native row flags at +0x20. + pub flags: u32, +} + +/// One parsed 0xB0-byte typed-record body and its following value/string data. +#[derive(Clone, Debug, PartialEq)] +pub struct PreparedControlTypedRecord { + /// Native record tag at body offset 0. + pub tag: u32, + /// All 44 raw little-endian words from the fixed 0xB0-byte body. + pub fixed_words: [u32; 44], + /// Raw values following the fixed body; tag-specific parsing is deferred. + pub values: Vec<u32>, + /// Raw bytes following the value array, excluding the required trailing NUL. + pub string_bytes: Vec<u8>, + /// Native tag-21 query provider resolved from the explicit NDP sidecar. + /// Other tags use `NotApplicable`. + pub gate_provider: PreparedControlGateProvider, +} + +/// Initial tag-21 query provider resolution for one typed controller record. +#[derive(Clone, Debug, PartialEq)] +pub enum PreparedControlGateProvider { + /// The record is not a tag-21 controller. + NotApplicable, + /// Its raw key selects a validated NDP row. Native Control initializes + /// this type-1 query value to 1.0 before runtime state changes. + Resolved { + key: u32, + ndp_source: ResourceKey, + row_count: usize, + initial_type1_value: f32, + }, + /// No NDP reference was present, or its referenced archive/entry is absent. + MissingResource { + key: u32, + ndp_source: Option<ResourceKey>, + }, + /// The key is not a row in the validated NDP table. + KeyOutOfRange { + key: u32, + ndp_source: ResourceKey, + row_count: usize, + }, +} + +/// Parsed NDP sidecar used as the native Control query-provider table. +#[derive(Clone, Debug, PartialEq)] +pub struct PreparedControlNodeDataTable { + /// Explicit NDP resource paired with the component prototype. + pub source: ResourceKey, + /// Provider rows in their native table order. + pub rows: Vec<PreparedControlNodeDataRow>, +} + +/// One validated 76-byte NDP sidecar row. +#[derive(Clone, Debug, PartialEq)] +pub struct PreparedControlNodeDataRow { + /// Raw native flag bits. + pub raw_flags: u32, + /// Maximum volume value at row offset +4. + pub max_volume: f32, + /// Mobility-per-volume value at row offset +8. + pub mobility_per_volume: f32, + /// Effect resource pair at row offset +12. + pub effect: PreparedControlResourcePair, +} + /// Decoded point records selected by a component's Control configuration. #[derive(Clone, Debug, PartialEq)] pub struct PreparedControlPointList { @@ -994,6 +1097,382 @@ pub struct PreparedControlNodeBinding { pub flags: u32, } +/// Per-component instance state for native tag-21 control animation. +/// +/// Keep one value per model component instance; controls on shared models can +/// have independent clocks and gate samples. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ControlInstanceState { + initialized: bool, + node_times: Vec<Option<f32>>, + tag21_tracks: Vec<ControlTag21InstanceState>, +} + +#[derive(Clone, Debug, PartialEq)] +struct ControlTag21InstanceState { + phase: f32, + segment_start_ms: f32, + segment_end_ms: f32, + reciprocal_duration: f32, + initialized: bool, + maps: Vec<ControlTag21MapInstanceState>, +} + +#[derive(Clone, Debug, PartialEq)] +struct ControlTag21MapInstanceState { + row_index: usize, + previous: f32, + next: f32, + current: f32, +} + +impl Default for ControlTag21InstanceState { + fn default() -> Self { + Self { + phase: 0.0, + segment_start_ms: 0.0, + segment_end_ms: 0.0, + reciprocal_duration: 1.0, + initialized: false, + maps: Vec::new(), + } + } +} + +/// Input or metadata prevented a safe native control sample. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ControlSamplingError { + /// 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. + StateTimelineCountMismatch { expected: usize, actual: usize }, + /// The caller did not provide one query-provider value per tag-21 record. + GateProviderCountMismatch { expected: usize, actual: usize }, + /// A tag-21 record has no corresponding NDP provider resource. + MissingGateProvider { record_index: usize, key: u32 }, + /// A tag-21 query key does not select a row in its NDP table. + GateProviderKeyOutOfRange { + record_index: usize, + key: u32, + row_count: usize, + }, + /// A resolved provider lacks a finite current type-1 value. + MissingGateProviderValue { record_index: usize, key: u32 }, + /// The record uses a tag-21 mode whose native selector is not yet decoded. + UnsupportedTag21Mode { record_index: usize, packed: u32 }, + /// A tag-21 record did not retain a valid source-row map index. + InvalidTag21MapIndex { + record_index: usize, + map_index: usize, + }, + /// A mapped source row reuses the previous typed row's current value. + /// + /// This native flag is currently rejected so callers can use their static + /// fallback instead of silently sampling the wrong row value. + UnsupportedPreviousRowMapping { row_index: usize }, + /// A mapped raw source row has an invalid rate at +0x18. + InvalidTransitionRate { row_index: usize }, + /// A mapped raw source row uses external variable selectors at +0x10/+0x14. + UnsupportedVariableMapping { row_index: usize }, + /// The controller computation produced a non-finite sample. + NonFiniteSample { record_index: usize }, +} + +/// Samples per-node animation times using the decoded Control timeline. +/// +/// `native_clock_ms` is the absolute u32 millisecond clock passed to native +/// Control::Update. `gate_values` carries one provider type-1 float per tag-21 +/// record, in source order. Native query 0x21820 returns 1 when that float is +/// zero, and the tag-21 update stops when that query returns 1; thus each +/// record updates when its provider value is nonzero. `None` values are +/// rejected explicitly. Values are not blend values. The state must be private +/// to one component instance and retained between calls. Rows and typed maps +/// are processed in source order, so later rows targeting one node win as in +/// native Control. +pub fn sample_control_node_times( + control: &PreparedComponentControl, + native_clock_ms: u32, + gate_values: &[Option<f32>], + state: &mut ControlInstanceState, +) -> Result<Vec<Option<f32>>, ControlSamplingError> { + let node_count = control.model_node_count; + let tag21_records: Vec<&PreparedControlTypedRecord> = control + .typed_records + .iter() + .filter(|record| record.tag == 21) + .collect(); + if gate_values.len() != tag21_records.len() { + return Err(ControlSamplingError::GateProviderCountMismatch { + expected: tag21_records.len(), + actual: gate_values.len(), + }); + } + + if state.initialized { + if state.node_times.len() != node_count { + return Err(ControlSamplingError::StateNodeCountMismatch { + expected: node_count, + actual: state.node_times.len(), + }); + } + if state.tag21_tracks.len() != tag21_records.len() { + return Err(ControlSamplingError::StateTimelineCountMismatch { + expected: tag21_records.len(), + actual: state.tag21_tracks.len(), + }); + } + } else { + state.node_times = vec![Some(0.0); node_count]; + for binding in &control.bindings { + if binding.flags & 4 == 0 && binding.node_index < node_count { + let blend = native_control_blend(binding.initial_blend, binding.flags); + state.node_times[binding.node_index] = + Some((1.0 - blend) * binding.frame_a + blend * binding.frame_b); + } + } + state.tag21_tracks = tag21_records + .iter() + .enumerate() + .map(|(record_index, record)| { + let mut track = ControlTag21InstanceState::default(); + for map_index in &record.values { + let row_index = usize::try_from(*map_index).unwrap_or(usize::MAX); + let Some(row) = control.timeline_rows.get(row_index) else { + return Err(ControlSamplingError::InvalidTag21MapIndex { + record_index, + map_index: row_index, + }); + }; + track.maps.push(ControlTag21MapInstanceState { + row_index, + previous: row.initial_blend, + next: row.initial_blend, + current: row.initial_blend, + }); + } + Ok(track) + }) + .collect::<Result<Vec<_>, _>>()?; + state.initialized = true; + } + + let now_ms = native_clock_ms as f32; + for (record_index, record) in tag21_records.iter().enumerate() { + let packed = record.fixed_words[2]; + let raw_flags = record.fixed_words[6]; + let controller_flags = if raw_flags == u32::MAX { 5 } else { raw_flags }; + if packed != 0 || controller_flags != 5 { + return Err(ControlSamplingError::UnsupportedTag21Mode { + record_index, + packed, + }); + } + let coefficient = f32::from_bits(record.fixed_words[9]); + let unused_coefficient = f32::from_bits(record.fixed_words[10]); + if !coefficient.is_finite() || !unused_coefficient.is_finite() { + return Err(ControlSamplingError::NonFiniteSample { record_index }); + } + + let key = record.fixed_words[1]; + match &record.gate_provider { + PreparedControlGateProvider::Resolved { + key: provider_key, + row_count, + .. + } if *provider_key == key && usize::try_from(key).is_ok_and(|key| key < *row_count) => { + } + PreparedControlGateProvider::KeyOutOfRange { + key: provider_key, + row_count, + .. + } if *provider_key == key => { + return Err(ControlSamplingError::GateProviderKeyOutOfRange { + record_index, + key, + row_count: *row_count, + }); + } + PreparedControlGateProvider::MissingResource { + key: provider_key, .. + } if *provider_key == key => { + return Err(ControlSamplingError::MissingGateProvider { record_index, key }); + } + _ => return Err(ControlSamplingError::MissingGateProvider { record_index, key }), + } + let provider_value = gate_values[record_index] + .ok_or(ControlSamplingError::MissingGateProviderValue { record_index, key })?; + if !provider_value.is_finite() { + return Err(ControlSamplingError::MissingGateProviderValue { record_index, key }); + } + + let track = &mut state.tag21_tracks[record_index]; + let mut catch_up_steps = 0_usize; + while !track.initialized || now_ms > track.segment_end_ms { + if track.initialized { + // 2D260 calls 21A30 before opening the next interval. Since + // this loop runs only after `clock > end`, that sample has + // reached every typed map's next value. + for mapping in &mut track.maps { + mapping.current = mapping.next; + } + } + if provider_value == 0.0 { + // A closed native gate keeps the current value and rebases the + // next transition to this absolute sample time. + for mapping in &mut track.maps { + mapping.previous = mapping.current; + mapping.next = mapping.current; + } + track.segment_start_ms = now_ms; + track.segment_end_ms = now_ms; + track.reciprocal_duration = 1.0; + track.initialized = true; + break; + } + + let direction = match controller_flags & 3 { + 1 => 1.0, + 2 => -1.0, + _ => 0.0, + }; + let mut next_phase = track.phase + coefficient * 0.45 * direction; + if controller_flags & 0x0C == 4 { + next_phase = wrap_control_value_once(next_phase); + } else if controller_flags & 0x0C == 8 { + next_phase = next_phase.clamp(0.0, 1.0); + } + let mut duration_ms = 0.0_f32; + for mapping in &mut track.maps { + let row = &control.timeline_rows[mapping.row_index]; + if row.flags & 0x40 != 0 { + return Err(ControlSamplingError::UnsupportedPreviousRowMapping { + row_index: mapping.row_index, + }); + } + if row.raw_parameters[0] != u32::MAX || row.raw_parameters[1] != u32::MAX { + return Err(ControlSamplingError::UnsupportedVariableMapping { + row_index: mapping.row_index, + }); + } + let rate = f32::from_bits(row.raw_parameters[2]); + if !rate.is_finite() || rate <= 0.0 { + return Err(ControlSamplingError::InvalidTransitionRate { + row_index: mapping.row_index, + }); + } + mapping.previous = mapping.current; + mapping.next = next_phase; + let direct_distance = (mapping.next - mapping.previous).abs(); + let distance = if row.flags & 1 != 0 { + shortest_wrapped_distance(mapping.previous, mapping.next) + } else { + direct_distance + }; + let transition_speed = coefficient.abs() * rate; + let candidate_duration = if transition_speed > 0.0 { + 1000.0 * distance / transition_speed + } else { + 100.0 + }; + if !candidate_duration.is_finite() { + return Err(ControlSamplingError::NonFiniteSample { record_index }); + } + duration_ms = duration_ms.max(candidate_duration); + } + + // Native Control only substitutes 100 ms when the proposed + // interval is shorter than 1 ms; ordinary short transitions stay + // short (for example 22.5 ms at rate 20). + if duration_ms < 1.0 { + duration_ms = 100.0; + } + track.segment_start_ms = track.segment_end_ms; + track.phase = next_phase; + track.segment_end_ms = track.segment_start_ms + duration_ms; + let actual_duration = track.segment_end_ms - track.segment_start_ms; + track.reciprocal_duration = if actual_duration > 0.0 { + 1.0 / actual_duration + } else { + 1.0 + }; + track.initialized = true; + catch_up_steps += 1; + if catch_up_steps > 100_000 { + // shortcut: cap catch-up at 100,000 transitions; replace with a bounded + // fast-forward if valid long clock stalls must reproduce every transition. + return Err(ControlSamplingError::NonFiniteSample { record_index }); + } + } + + let duration = track.segment_end_ms - track.segment_start_ms; + let alpha = if duration > 0.0 { + ((now_ms - track.segment_start_ms) * track.reciprocal_duration).clamp(0.0, 1.0) + } else { + 1.0 + }; + for mapping in &mut track.maps { + let row = &control.timeline_rows[mapping.row_index]; + if row.flags & 4 != 0 || row.node_index >= node_count { + continue; + } + let mut delta = mapping.next - mapping.previous; + if row.flags & 1 != 0 { + if delta > 0.5 { + delta -= 1.0; + } else if delta < -0.5 { + delta += 1.0; + } + mapping.current = wrap_control_value_once(mapping.previous + delta * alpha); + } else { + mapping.current = (mapping.previous + delta * alpha).clamp(0.0, 1.0); + } + if !mapping.current.is_finite() { + return Err(ControlSamplingError::NonFiniteSample { record_index }); + } + let blend = native_control_blend(mapping.current, row.flags); + let time = (1.0 - blend) * row.frame_a + blend * row.frame_b; + if !time.is_finite() || time < 0.0 { + return Err(ControlSamplingError::NonFiniteSample { record_index }); + } + state.node_times[row.node_index] = Some(time); + } + } + + Ok(state.node_times.clone()) +} + +fn shortest_wrapped_distance(previous: f32, next: f32) -> f32 { + let direct = (next - previous).abs(); + direct.min((1.0 - direct).abs()) +} + +fn wrap_control_value_once(mut value: f32) -> f32 { + if value > 1.0 { + value -= 1.0; + } + if value < 0.0 { + value += 1.0; + } + value +} + +fn native_control_blend(mut blend: f32, flags: u32) -> f32 { + if flags & 1 != 0 { + if blend > 1.0 { + blend -= 1.0; + } + if blend < 0.0 { + blend += 1.0; + } + } else { + blend = blend.clamp(0.0, 1.0); + } + if flags & 2 != 0 { + blend = 1.0 - blend; + } + blend +} + /// CPU-side validated model ready for a renderer upload path. #[derive(Clone, Debug, PartialEq)] pub struct PreparedModel { @@ -1102,6 +1581,10 @@ pub struct MissionAssets { /// component list. `None` means no CTLD pose override is available because /// there is no explicit reference or its referenced archive/entry is absent. pub object_component_controls: Vec<Vec<Option<PreparedComponentControl>>>, + /// Optional component-local BAS geometry aligned with each object's visual + /// component list. `None` means there is no explicit BAS reference or its + /// archive/entry is absent. + pub object_component_bas: Vec<Vec<Option<PreparedComponentBas>>>, } impl MissionAssets { @@ -1539,7 +2022,15 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>( } let mut prototype_component_controls = Vec::with_capacity(prototypes.len()); + let mut prototype_component_bas = Vec::with_capacity(prototypes.len()); for (prototype_index, prototype) in prototypes.iter().enumerate() { + let bas = match prototype.bas_resource.clone() { + Some(source) => { + prepare_component_bas_from_resource(repository, source, prototype_index)? + } + None => None, + }; + prototype_component_bas.push(bas); let Some(source) = prototype.control_resource.clone() else { prototype_component_controls.push(None); continue; @@ -1563,6 +2054,7 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>( repository, source, prototype.point_resource.clone(), + prototype.node_data_resource.clone(), model.validated.node_count, prototype_index, )?; @@ -1571,6 +2063,7 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>( let mut object_visuals = Vec::with_capacity(root_prototype_spans.len()); let mut object_component_controls = Vec::with_capacity(root_prototype_spans.len()); + let mut object_component_bas = Vec::with_capacity(root_prototype_spans.len()); for (root_index, span) in root_prototype_spans.iter().enumerate() { if span.start > span.end || span.end > prototype_visual_ids.len() { return Err(AssetError::InvalidPrototype(format!( @@ -1579,6 +2072,7 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>( } let mut ids = Vec::new(); let mut controls = Vec::new(); + let mut bases = Vec::new(); for index in span.clone() { let visual_id = prototype_visual_ids[index]; // A unit DAT is an instance tree. Reusing one prepared visual @@ -1586,9 +2080,11 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>( // identical cannons on w_m_wlk2). ids.push(visual_id); controls.push(prototype_component_controls[index].clone()); + bases.push(prototype_component_bas[index].clone()); } object_visuals.push(ids); object_component_controls.push(controls); + object_component_bas.push(bases); } let assets = MissionAssets { @@ -1599,6 +2095,7 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>( visuals, object_visuals, object_component_controls, + object_component_bas, }; let report = AssetPreparationReport { model_count: assets.models.len(), @@ -2861,6 +3358,7 @@ fn prepare_component_control_from_resource<R: ResourceRepository>( repository: &R, source: ResourceKey, point_source: Option<ResourceKey>, + node_data_source: Option<ResourceKey>, model_node_count: usize, prototype_index: usize, ) -> Result<Option<PreparedComponentControl>, AssetError> { @@ -2869,6 +3367,19 @@ fn prepare_component_control_from_resource<R: ResourceRepository>( }; let mut control = decode_component_control_payload(&payload, source, model_node_count, prototype_index)?; + let node_data_payload = match node_data_source.as_ref() { + Some(source) => read_optional_key(repository, source, Some("component node data"))?, + None => None, + }; + control.node_data = match (node_data_source.as_ref(), node_data_payload) { + (Some(source), Some(payload)) => Some(decode_component_node_data_table( + &payload, + source.clone(), + prototype_index, + )?), + _ => None, + }; + resolve_control_gate_providers(&mut control, node_data_source.as_ref()); let has_effect_rows = matches!( &control.effect_section, PreparedControlEffectSection::Parsed(rows) if !rows.is_empty() @@ -2889,6 +3400,113 @@ fn prepare_component_control_from_resource<R: ResourceRepository>( Ok(Some(control)) } +fn prepare_component_bas_from_resource<R: ResourceRepository>( + repository: &R, + source: ResourceKey, + prototype_index: usize, +) -> Result<Option<PreparedComponentBas>, AssetError> { + let Some(payload) = read_optional_key(repository, &source, Some("component BAS geometry"))? + else { + return Ok(None); + }; + let geometry = decode_bas_geometry(&payload).map_err(|error| { + AssetError::InvalidPrototype(format!( + "prototype {prototype_index} BAS geometry {}:{}: {error}", + source.archive.as_str(), + String::from_utf8_lossy(&source.name.0), + )) + })?; + Ok(Some(PreparedComponentBas { source, geometry })) +} + +fn decode_component_node_data_table( + payload: &[u8], + source: ResourceKey, + prototype_index: usize, +) -> Result<PreparedControlNodeDataTable, AssetError> { + const HEADER_BYTES: usize = 4; + const ROW_BYTES: usize = 76; + const ROW_LIMIT: usize = 65_536; + + let invalid = |message: &str| { + AssetError::InvalidPrototype(format!( + "prototype {prototype_index} node data {}:{}: {message}", + source.archive.as_str(), + String::from_utf8_lossy(&source.name.0), + )) + }; + let row_count = + usize::try_from(control_u32_at(payload, 0).ok_or_else(|| invalid("missing row count"))?) + .map_err(|_| invalid("row count overflows usize"))?; + if row_count > ROW_LIMIT { + return Err(invalid("row count exceeds the supported limit")); + } + let table_bytes = row_count + .checked_mul(ROW_BYTES) + .ok_or_else(|| invalid("row table byte length overflows"))?; + let expected_len = HEADER_BYTES + .checked_add(table_bytes) + .ok_or_else(|| invalid("row table end offset overflows"))?; + if payload.len() != expected_len { + return Err(invalid( + "payload length does not match its declared 76-byte row count", + )); + } + + let mut rows = Vec::with_capacity(row_count); + for row_index in 0..row_count { + let start = HEADER_BYTES + row_index * ROW_BYTES; + let word = |offset: usize| { + control_u32_at(payload, start + offset).expect("validated 76-byte NDP row") + }; + let mut archive = [0; 32]; + let mut name = [0; 32]; + archive.copy_from_slice(&payload[start + 12..start + 44]); + name.copy_from_slice(&payload[start + 44..start + 76]); + rows.push(PreparedControlNodeDataRow { + raw_flags: word(0), + max_volume: f32::from_bits(word(4)), + mobility_per_volume: f32::from_bits(word(8)), + effect: PreparedControlResourcePair { archive, name }, + }); + } + Ok(PreparedControlNodeDataTable { source, rows }) +} + +fn resolve_control_gate_providers( + control: &mut PreparedComponentControl, + node_data_source: Option<&ResourceKey>, +) { + const INITIAL_TYPE1_VALUE: f32 = 1.0; + let row_count = control.node_data.as_ref().map(|table| table.rows.len()); + for record in control + .typed_records + .iter_mut() + .filter(|record| record.tag == 21) + { + let key = record.fixed_words[1]; + record.gate_provider = match (control.node_data.as_ref(), row_count) { + (Some(table), Some(count)) if usize::try_from(key).is_ok_and(|key| key < count) => { + PreparedControlGateProvider::Resolved { + key, + ndp_source: table.source.clone(), + row_count: count, + initial_type1_value: INITIAL_TYPE1_VALUE, + } + } + (Some(table), Some(count)) => PreparedControlGateProvider::KeyOutOfRange { + key, + ndp_source: table.source.clone(), + row_count: count, + }, + _ => PreparedControlGateProvider::MissingResource { + key, + ndp_source: node_data_source.cloned(), + }, + }; + } +} + fn decode_component_point_list( payload: &[u8], source: ResourceKey, @@ -2981,7 +3599,7 @@ fn control_i32_at(payload: &[u8], offset: usize) -> Option<i32> { )) } -const CONTROL_TYPED_COMMON_PARSER_TAGS: &[u32] = &[1, 8, 9, 12, 13, 15, 19, 21, 25, 26]; +const CONTROL_TYPED_COMMON_PARSER_TAGS: &[u32] = &[1, 2, 5, 8, 9, 12, 13, 15, 19, 21, 25, 26]; const CONTROL_TYPED_RECORD_FIXED_BYTES: usize = 0xB0; const CONTROL_TYPED_RECORD_COUNT_OFFSET: usize = 0xAC; const CONTROL_TYPED_RECORD_LIMIT: usize = 4096; @@ -2992,24 +3610,32 @@ const CONTROL_EFFECT_ROW_LIMIT: usize = 65_536; const CONTROL_EFFECT_GROUP_HEADER_BYTES: usize = 0x54; const CONTROL_EFFECT_ROW_BYTES: usize = 100; +struct DecodedComponentEffectSection { + section: PreparedControlEffectSection, + typed_records: Vec<PreparedControlTypedRecord>, +} + fn decode_component_effect_section<F>( payload: &[u8], mut cursor: usize, typed_record_count: usize, effect_group_count: usize, invalid: &F, -) -> Result<PreparedControlEffectSection, AssetError> +) -> Result<DecodedComponentEffectSection, AssetError> where F: Fn(&str) -> AssetError, { if typed_record_count == 0 && effect_group_count == 0 { - return Ok(if cursor == payload.len() { - PreparedControlEffectSection::Absent - } else { - PreparedControlEffectSection::Unsupported { - typed_record_index: None, - typed_record_tag: None, - } + return Ok(DecodedComponentEffectSection { + section: if cursor == payload.len() { + PreparedControlEffectSection::Absent + } else { + PreparedControlEffectSection::Unsupported { + typed_record_index: None, + typed_record_tag: None, + } + }, + typed_records: Vec::new(), }); } if typed_record_count > CONTROL_TYPED_RECORD_LIMIT { @@ -3019,13 +3645,17 @@ where return Err(invalid("effect group count exceeds the supported limit")); } + let mut typed_records = Vec::with_capacity(typed_record_count); for record_index in 0..typed_record_count { let tag = control_u32_at(payload, cursor) .ok_or_else(|| invalid("typed record is missing its tag"))?; if !CONTROL_TYPED_COMMON_PARSER_TAGS.contains(&tag) { - return Ok(PreparedControlEffectSection::Unsupported { - typed_record_index: Some(record_index), - typed_record_tag: Some(tag), + return Ok(DecodedComponentEffectSection { + section: PreparedControlEffectSection::Unsupported { + typed_record_index: Some(record_index), + typed_record_tag: Some(tag), + }, + typed_records, }); } @@ -3061,41 +3691,69 @@ where // Native parsing returns immediately for length zero. For UINT32_MAX, // its signed length+1 wraps to zero, also consuming no string bytes. - let string_bytes = if string_length == 0 || string_length == u32::MAX { - 0 + let (string_end, string_content_end) = if string_length == 0 || string_length == u32::MAX { + (after_length, after_length) } else { - usize::try_from(string_length) + let string_bytes = usize::try_from(string_length) .ok() .and_then(|length| length.checked_add(1)) - .ok_or_else(|| invalid("typed record string length overflows"))? + .ok_or_else(|| invalid("typed record string length overflows"))?; + if string_bytes > CONTROL_TYPED_STRING_LIMIT { + return Err(invalid("typed record string exceeds the supported limit")); + } + let string_end = after_length + .checked_add(string_bytes) + .ok_or_else(|| invalid("typed record string end overflows"))?; + if string_end > payload.len() { + return Err(invalid("typed record string extends past the payload")); + } + if payload[string_end - 1] != 0 { + return Err(invalid("typed record string is missing its trailing NUL")); + } + (string_end, string_end - 1) }; - if string_bytes == 0 { - cursor = after_length; - continue; - } - if string_bytes > CONTROL_TYPED_STRING_LIMIT { - return Err(invalid("typed record string exceeds the supported limit")); - } - let string_end = after_length - .checked_add(string_bytes) - .ok_or_else(|| invalid("typed record string end overflows"))?; - if string_end > payload.len() { - return Err(invalid("typed record string extends past the payload")); - } - if payload[string_end - 1] != 0 { - return Err(invalid("typed record string is missing its trailing NUL")); + let mut fixed_words = [0_u32; 44]; + for (word_index, word) in fixed_words.iter_mut().enumerate() { + *word = control_u32_at(payload, cursor + word_index * 4) + .expect("typed record fixed header was checked"); } + let values = (0..value_count) + .map(|index| { + control_u32_at( + payload, + cursor + CONTROL_TYPED_RECORD_FIXED_BYTES + index * 4, + ) + .expect("typed record values were checked") + }) + .collect(); + typed_records.push(PreparedControlTypedRecord { + tag, + fixed_words, + values, + string_bytes: payload[after_length..string_content_end].to_vec(), + gate_provider: if tag == 21 { + PreparedControlGateProvider::MissingResource { + key: fixed_words[1], + ndp_source: None, + } + } else { + PreparedControlGateProvider::NotApplicable + }, + }); cursor = string_end; } if effect_group_count == 0 { - return Ok(if cursor == payload.len() { - PreparedControlEffectSection::Parsed(Vec::new()) - } else { - PreparedControlEffectSection::Unsupported { - typed_record_index: None, - typed_record_tag: None, - } + return Ok(DecodedComponentEffectSection { + section: if cursor == payload.len() { + PreparedControlEffectSection::Parsed(Vec::new()) + } else { + PreparedControlEffectSection::Unsupported { + typed_record_index: None, + typed_record_tag: None, + } + }, + typed_records, }); } @@ -3165,12 +3823,18 @@ where } if cursor != payload.len() { - return Ok(PreparedControlEffectSection::Unsupported { - typed_record_index: None, - typed_record_tag: None, + return Ok(DecodedComponentEffectSection { + section: PreparedControlEffectSection::Unsupported { + typed_record_index: None, + typed_record_tag: None, + }, + typed_records, }); } - Ok(PreparedControlEffectSection::Parsed(effect_rows)) + Ok(DecodedComponentEffectSection { + section: PreparedControlEffectSection::Parsed(effect_rows), + typed_records, + }) } fn control_u32_at(payload: &[u8], offset: usize) -> Option<u32> { @@ -3250,22 +3914,38 @@ fn decode_component_control_payload( } let mut bindings = Vec::with_capacity(row_count); + let mut timeline_rows = Vec::with_capacity(row_count); for row_index in 0..row_count { let row_start = row_start + row_index * ROW_BYTES; let row = &payload[row_start..row_start + ROW_BYTES]; let flags = u32::from_le_bytes(row[0x20..0x24].try_into().expect("row flags")); - if flags & 4 != 0 { - continue; - } let node_index = u32::from_le_bytes(row[0..4].try_into().expect("row node index")) as usize; - if node_index >= model_node_count { - return Err(invalid("enabled binding node index is outside the model")); - } let frame_a = f32::from_bits(u32::from_le_bytes(row[4..8].try_into().expect("frame A"))); let frame_b = f32::from_bits(u32::from_le_bytes(row[8..12].try_into().expect("frame B"))); let initial_blend = f32::from_bits(u32::from_le_bytes( row[12..16].try_into().expect("initial blend"), )); + let raw_parameters = [ + u32::from_le_bytes(row[0x10..0x14].try_into().expect("row parameter +0x10")), + u32::from_le_bytes(row[0x14..0x18].try_into().expect("row parameter +0x14")), + u32::from_le_bytes(row[0x18..0x1C].try_into().expect("row parameter +0x18")), + u32::from_le_bytes(row[0x1C..0x20].try_into().expect("row parameter +0x1C")), + ]; + timeline_rows.push(PreparedControlTimelineRow { + row_index, + node_index, + frame_a, + frame_b, + initial_blend, + raw_parameters, + flags, + }); + if flags & 4 != 0 { + continue; + } + if node_index >= model_node_count { + return Err(invalid("enabled binding node index is outside the model")); + } if !frame_a.is_finite() || !frame_b.is_finite() || !initial_blend.is_finite() @@ -3285,7 +3965,7 @@ fn decode_component_control_payload( }); } - let effect_section = decode_component_effect_section( + let decoded_effect = decode_component_effect_section( payload, row_end, typed_record_count, @@ -3295,8 +3975,12 @@ fn decode_component_control_payload( Ok(PreparedComponentControl { source, + model_node_count, bindings, - effect_section, + timeline_rows, + typed_records: decoded_effect.typed_records, + node_data: None, + effect_section: decoded_effect.section, point_list: None, }) } @@ -3402,6 +4086,164 @@ mod tests { use fparkan_vfs::{DirectoryVfs, MemoryVfs, Vfs}; use std::path::PathBuf; + // 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 tag21_fullcycle_control() -> PreparedComponentControl { + let source = ResourceKey { + archive: parse_path("parts.rlb").expect("archive"), + name: resource_name(b"u_all_def_a_01.ctl"), + type_id: None, + }; + let mut payload = component_control_payload(&[(1, 0.0, 4.0, 0.0, 1)]); + let row_start = 5 * 4 + 108; + payload[row_start + 0x10..row_start + 0x14].copy_from_slice(&u32::MAX.to_le_bytes()); + payload[row_start + 0x14..row_start + 0x18].copy_from_slice(&u32::MAX.to_le_bytes()); + payload[row_start + 0x18..row_start + 0x1C] + .copy_from_slice(&0.5_f32.to_bits().to_le_bytes()); + payload[row_start + 0x1C..row_start + 0x20] + .copy_from_slice(&AUTHORED_TAG21_RAW_PARAMETER_3.to_bits().to_le_bytes()); + payload[12..16].copy_from_slice(&1_u32.to_le_bytes()); + + let typed_start = payload.len(); + let mut fixed = [0_u8; CONTROL_TYPED_RECORD_FIXED_BYTES]; + fixed[0..4].copy_from_slice(&21_u32.to_le_bytes()); + fixed[8..12].copy_from_slice(&0_u32.to_le_bytes()); + fixed[0x18..0x1C].copy_from_slice(&u32::MAX.to_le_bytes()); + fixed[0x24..0x28].copy_from_slice(&1.0_f32.to_bits().to_le_bytes()); + fixed[0x28..0x2C].copy_from_slice(&1.0_f32.to_bits().to_le_bytes()); + fixed[CONTROL_TYPED_RECORD_COUNT_OFFSET..CONTROL_TYPED_RECORD_COUNT_OFFSET + 4] + .copy_from_slice(&1_u32.to_le_bytes()); + payload.extend_from_slice(&fixed); + payload.extend_from_slice(&0_u32.to_le_bytes()); + payload.extend_from_slice(&0_u32.to_le_bytes()); + assert_eq!( + typed_start + CONTROL_TYPED_RECORD_FIXED_BYTES + 8, + payload.len() + ); + + let mut control = + decode_component_control_payload(&payload, source, 2, 0).expect("tag21 CTLD"); + control.typed_records[0].gate_provider = PreparedControlGateProvider::Resolved { + key: 0, + ndp_source: ResourceKey { + archive: parse_path("parts.rlb").expect("NDP archive"), + name: resource_name(b"u_all_def_a_01.ndp"), + type_id: None, + }, + row_count: 1, + initial_type1_value: 1.0, + }; + control + } + + #[test] + fn tag21_sampler_replays_authored_fullcycle_clock_and_gate() { + let control = tag21_fullcycle_control(); + assert_eq!(control.model_node_count, 2); + assert_eq!(control.typed_records.len(), 1); + assert_eq!(control.typed_records[0].tag, 21); + assert_eq!(control.typed_records[0].values, vec![0]); + assert_eq!( + f32::from_bits(control.timeline_rows[0].raw_parameters[2]), + 0.5 + ); + assert_eq!( + f32::from_bits(control.timeline_rows[0].raw_parameters[3]), + AUTHORED_TAG21_RAW_PARAMETER_3 + ); + + // Native Control receives 1.0 from the first default NDP provider row. + // Since 0x21820 tests for zero and its caller stops on return value 1, + // a nonzero provider value opens the tag-21 update path. + let default_provider_value = 1.0_f32; + let gate_values = [Some(default_provider_value)]; + let mut state = ControlInstanceState::default(); + let at_zero = sample_control_node_times(&control, 0, &gate_values, &mut state) + .expect("initial tag21 sample"); + assert_eq!(at_zero, vec![Some(0.0), Some(0.0)]); + + let at_quarter_step = sample_control_node_times(&control, 225, &gate_values, &mut state) + .expect("quarter-step tag21 sample"); + assert!((at_quarter_step[1].expect("node 1") - 0.45).abs() < 1.0e-5); + + let at_first_transition = + sample_control_node_times(&control, 900, &gate_values, &mut state) + .expect("first tag21 transition"); + assert!((at_first_transition[1].expect("node 1") - 1.8).abs() < 1.0e-5); + + let at_second_transition = + sample_control_node_times(&control, 1800, &gate_values, &mut state) + .expect("second tag21 transition"); + assert!((at_second_transition[1].expect("node 1") - 3.6).abs() < 1.0e-5); + + let at_wrap = sample_control_node_times(&control, 2700, &gate_values, &mut state) + .expect("wrapped tag21 transition"); + assert!((at_wrap[1].expect("node 1") - 1.4).abs() < 1.0e-5); + + let at_fullcycle = sample_control_node_times(&control, 18_000, &gate_values, &mut state) + .expect("fullcycle tag21 transition"); + assert!( + (at_fullcycle[1].expect("node 1") - 4.0).abs() < 1.0e-4, + "full-cycle node time was {at_fullcycle:?}" + ); + let zero_provider_value = 0.0_f32; + let gate_closed = [Some(zero_provider_value)]; + let mut closed_gate_state = ControlInstanceState::default(); + for clock in [0, 100, 500, 1000] { + let sample = + sample_control_node_times(&control, clock, &gate_closed, &mut closed_gate_state) + .expect("closed native gate holds the initial blend"); + assert_eq!(sample[1], Some(0.0)); + } + let reopened_provider_value = 1.0_f32; + let reopened_gate = [Some(reopened_provider_value)]; + let opened_gate = + sample_control_node_times(&control, 1001, &reopened_gate, &mut closed_gate_state) + .expect("open native gate starts a transition"); + assert!(opened_gate[1].expect("node 1") > 0.0); + } + + #[test] + fn tag21_sampler_preserves_fast_rates_and_rejects_previous_row_mapping() { + let mut fast = tag21_fullcycle_control(); + fast.timeline_rows[0].raw_parameters[2] = 20.0_f32.to_bits(); + let mut fast_state = ControlInstanceState::default(); + let at_half_transition = + sample_control_node_times(&fast, 11, &[Some(1.0)], &mut fast_state) + .expect("fast sample"); + assert!((at_half_transition[1].expect("node 1") - 0.88).abs() < 1.0e-5); + let at_transition_end = sample_control_node_times(&fast, 23, &[Some(1.0)], &mut fast_state) + .expect("fast transition"); + assert!((at_transition_end[1].expect("node 1") - 1.84).abs() < 1.0e-5); + + let mut direct = tag21_fullcycle_control(); + direct.timeline_rows[0].flags = 0; + direct.typed_records[0].fixed_words[9] = 2.0_f32.to_bits(); + let direct_sample = sample_control_node_times( + &direct, + 100, + &[Some(1.0)], + &mut ControlInstanceState::default(), + ) + .expect("non-wrapping transition"); + assert!((direct_sample[1].expect("node 1") - 0.4).abs() < 1.0e-5); + + let mut previous_row = tag21_fullcycle_control(); + previous_row.timeline_rows[0].flags = 0x40; + let error = sample_control_node_times( + &previous_row, + 0, + &[Some(1.0)], + &mut ControlInstanceState::default(), + ) + .expect_err("previous-row mapping must not silently use the wrong blend"); + assert_eq!( + error, + ControlSamplingError::UnsupportedPreviousRowMapping { row_index: 0 } + ); + } + #[test] fn component_control_rows_preserve_f32_values_and_source_order() { let source = ResourceKey { @@ -3466,7 +4308,7 @@ mod tests { payload[16..20].copy_from_slice(&1_u32.to_le_bytes()); payload.extend_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); - let control = decode_component_control_payload(&payload, source, 2, 3) + let control = decode_component_control_payload(&payload, source.clone(), 2, 3) .expect("unknown tag is retained"); assert_eq!(control.bindings.len(), 1); @@ -3496,7 +4338,7 @@ mod tests { payload.resize(group_header_start + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0); payload.extend_from_slice(&0_u32.to_le_bytes()); - let control = decode_component_control_payload(&payload, source, 2, 3) + let control = decode_component_control_payload(&payload, source.clone(), 2, 3) .expect("zero-length record has no trailing string byte"); assert_eq!(control.bindings.len(), 1); @@ -3507,6 +4349,39 @@ mod tests { } #[test] + fn component_control_tags2_and5_share_the_typed_cursor_parser() { + let source = ResourceKey { + archive: parse_path("controls.rlb").expect("archive"), + name: resource_name(b"parts.ctl"), + type_id: None, + }; + let mut payload = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]); + payload[12..16].copy_from_slice(&3_u32.to_le_bytes()); + append_typed_record(&mut payload, 2, 0); + append_typed_record(&mut payload, 5, 2); + payload.extend_from_slice(b"fx\0"); + append_typed_record(&mut payload, 21, 0); + + let control = decode_component_control_payload(&payload, source, 2, 0) + .expect("tags 2 and 5 share the native typed cursor parser"); + + assert_eq!( + control + .typed_records + .iter() + .map(|record| record.tag) + .collect::<Vec<_>>(), + [2, 5, 21], + "tag 21 must begin at the cursor returned after tag 5's string" + ); + assert_eq!(control.typed_records[1].string_bytes, b"fx"); + assert_eq!( + control.effect_section, + PreparedControlEffectSection::Parsed(Vec::new()) + ); + } + + #[test] fn component_control_malformed_recognized_typed_tail_is_rejected() { let source = ResourceKey { archive: parse_path("controls.rlb").expect("archive"), @@ -3565,7 +4440,7 @@ mod tests { )], ); - let control = decode_component_control_payload(&payload, source, 2, 3) + let control = decode_component_control_payload(&payload, source.clone(), 2, 3) .expect("complete type-4 effect tables"); assert_eq!( @@ -3598,6 +4473,118 @@ mod tests { } #[test] + fn tag21_gate_provider_comes_from_validated_explicit_ndp() { + let source = ResourceKey { + archive: parse_path("parts.rlb").expect("CTL archive"), + name: resource_name(b"locator.ctl"), + type_id: None, + }; + let ndp_source = ResourceKey { + archive: parse_path("parts.rlb").expect("NDP archive"), + name: resource_name(b"locator.ndp"), + type_id: None, + }; + let control_payload = tag21_control_payload(0); + let ndp_payload = node_data_payload(1); + let repository = repository_with_archives(&[( + "parts.rlb", + &[ + (b"locator.ctl", &control_payload), + (b"locator.ndp", &ndp_payload), + ], + )]); + let prepared = prepare_component_control_from_resource( + &repository, + source.clone(), + None, + Some(ndp_source.clone()), + 2, + 0, + ) + .expect("prepare CTL and NDP") + .expect("CTL exists"); + let table = prepared.node_data.as_ref().expect("NDP table attached"); + assert_eq!(table.source, ndp_source); + assert_eq!(table.rows.len(), 1); + assert_eq!(table.rows[0].max_volume, 6000.0); + assert_eq!(table.rows[0].mobility_per_volume, 500.0); + assert!(matches!( + prepared.typed_records[0].gate_provider, + PreparedControlGateProvider::Resolved { + key: 0, + row_count: 1, + initial_type1_value: 1.0, + .. + } + )); + + let mut state = ControlInstanceState::default(); + sample_control_node_times(&prepared, 225, &[Some(1.0)], &mut state) + .expect("resolved nonzero provider opens the transition"); + + let missing_repository = + repository_with_archives(&[("parts.rlb", &[(b"locator.ctl", &control_payload)])]); + let missing = prepare_component_control_from_resource( + &missing_repository, + source.clone(), + None, + Some(ResourceKey { + archive: parse_path("parts.rlb").expect("archive"), + name: resource_name(b"missing.ndp"), + type_id: None, + }), + 2, + 0, + ) + .expect("missing optional NDP is retained as metadata") + .expect("CTL exists"); + assert!(matches!( + sample_control_node_times(&missing, 0, &[None], &mut ControlInstanceState::default()), + Err(ControlSamplingError::MissingGateProvider { key: 0, .. }) + )); + + let out_of_range_payload = tag21_control_payload(1); + let out_of_range_repository = repository_with_archives(&[( + "parts.rlb", + &[ + (b"locator.ctl", &out_of_range_payload), + (b"locator.ndp", &ndp_payload), + ], + )]); + let out_of_range = prepare_component_control_from_resource( + &out_of_range_repository, + source, + None, + Some(ResourceKey { + archive: parse_path("parts.rlb").expect("archive"), + name: resource_name(b"locator.ndp"), + type_id: None, + }), + 2, + 0, + ) + .expect("out-of-range key remains inspectable") + .expect("CTL exists"); + assert!(matches!( + sample_control_node_times( + &out_of_range, + 0, + &[None], + &mut ControlInstanceState::default(), + ), + Err(ControlSamplingError::GateProviderKeyOutOfRange { + key: 1, + row_count: 1, + .. + }) + )); + + let mut malformed_ndp = ndp_payload; + malformed_ndp.push(0); + assert!(decode_component_node_data_table(&malformed_ndp, ndp_source, 0).is_err()); + } + + #[test] fn component_control_loads_explicit_cpt_points_for_effect_offsets() { let mut control = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]); control[16..20].copy_from_slice(&1_u32.to_le_bytes()); @@ -3659,10 +4646,16 @@ mod tests { type_id: None, }; - let control = - prepare_component_control_from_resource(&repository, source, Some(point_source), 2, 0) - .expect("loaded CTL and its explicit CPT") - .expect("CTL entry"); + let control = prepare_component_control_from_resource( + &repository, + source, + Some(point_source), + None, + 2, + 0, + ) + .expect("loaded CTL and its explicit CPT") + .expect("CTL entry"); let point_list = control.point_list.as_ref().expect("point list is attached"); assert_eq!(point_list.source.archive.as_str(), "points.rlb"); @@ -3703,6 +4696,89 @@ mod tests { } #[test] + fn mission_assets_keep_explicit_bas_geometry_at_component_index() { + let mut bas = Vec::new(); + bas.extend_from_slice(&1_u32.to_le_bytes()); // first-section records + bas.extend_from_slice(&1_u32.to_le_bytes()); // declared count + for point in [[1.0_f32, 2.0, 3.0], [4.0, 5.0, 6.0]] { + for value in point { + bas.extend_from_slice(&value.to_le_bytes()); + } + } + bas.extend_from_slice(&0x1122_3344_u32.to_le_bytes()); + bas.extend_from_slice(&0x5566_7788_u32.to_le_bytes()); + bas.extend_from_slice(&1_u32.to_le_bytes()); // second-section records + bas.extend_from_slice(&1_u32.to_le_bytes()); + for point in [[7.0_f32, 8.0, 9.0], [10.0, 11.0, 12.0]] { + for value in point { + bas.extend_from_slice(&value.to_le_bytes()); + } + } + let repository = repository_with_archives(&[("fortif.rlb", &[(b"fr_b_plant.bas", &bas)])]); + let source = ResourceKey { + archive: parse_path("fortif.rlb").expect("archive"), + name: resource_name(b"fr_b_plant.bas"), + type_id: None, + }; + let without_bas = EffectivePrototype { + key: fparkan_prototype::PrototypeKey(resource_name(b"unrelated")), + geometry: PrototypeGeometry::NonGeometric, + source: fparkan_prototype::PrototypeSource::ObjectsRegistry, + source_type: None, + dependencies: Vec::new(), + control_resource: None, + point_resource: None, + node_data_resource: None, + bas_resource: None, + }; + let prototype = EffectivePrototype { + key: fparkan_prototype::PrototypeKey(resource_name(b"fr_b_plant")), + geometry: PrototypeGeometry::NonGeometric, + source: fparkan_prototype::PrototypeSource::ObjectsRegistry, + source_type: None, + dependencies: Vec::new(), + control_resource: None, + point_resource: None, + node_data_resource: None, + bas_resource: Some(source.clone()), + }; + + let assets = + prepare_mission_assets_with_repository(&repository, &[0..2], &[without_bas, prototype]) + .expect("mission BAS sidecar"); + assert_eq!(assets.object_component_bas[0].len(), 2); + assert!(assets.object_component_bas[0][0].is_none()); + let prepared = assets.object_component_bas[0][1] + .as_ref() + .expect("BAS aligned with component one"); + assert_eq!(prepared.source, source); + assert_eq!(prepared.geometry.first[0].points[1], [4.0, 5.0, 6.0]); + assert_eq!(prepared.geometry.first[0].opaque_words_a, [0x1122_3344]); + assert_eq!(prepared.geometry.second[0].points[1], [10.0, 11.0, 12.0]); + } + + #[test] + fn malformed_explicit_bas_geometry_is_reported_at_preparation_boundary() { + let bad_payload = u32::MAX.to_le_bytes(); + let repository = + repository_with_archives(&[("fortif.rlb", &[(b"broken.bas", &bad_payload)])]); + let source = ResourceKey { + archive: parse_path("fortif.rlb").expect("archive"), + name: resource_name(b"broken.bas"), + type_id: None, + }; + + let error = prepare_component_bas_from_resource(&repository, source, 3) + .expect_err("malformed BAS counts are rejected"); + assert!(matches!( + error, + AssetError::InvalidPrototype(message) + if message.contains("prototype 3 BAS geometry fortif.rlb:broken.bas") + && message.contains("limit") + )); + } + + #[test] fn component_control_preserves_unresolved_point_offset_sentinels() { let mut control = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]); control[16..20].copy_from_slice(&1_u32.to_le_bytes()); @@ -3734,10 +4810,16 @@ mod tests { type_id: None, }; - let control = - prepare_component_control_from_resource(&repository, source, Some(point_source), 2, 0) - .expect("point-offset sentinel is retained without guessed bounds") - .expect("CTL entry"); + let control = prepare_component_control_from_resource( + &repository, + source, + Some(point_source), + None, + 2, + 0, + ) + .expect("point-offset sentinel is retained without guessed bounds") + .expect("CTL entry"); let PreparedControlEffectSection::Parsed(rows) = control.effect_section else { panic!("effect rows parsed"); }; @@ -3784,6 +4866,7 @@ mod tests { &repository, source, Some(missing_point_source), + None, 2, 0, ) @@ -3837,6 +4920,46 @@ mod tests { payload } + fn tag21_control_payload(provider_key: u32) -> Vec<u8> { + let mut payload = component_control_payload(&[(1, 0.0, 4.0, 0.0, 1)]); + let row_start = 5 * 4 + 108; + payload[row_start + 0x10..row_start + 0x14].copy_from_slice(&u32::MAX.to_le_bytes()); + payload[row_start + 0x14..row_start + 0x18].copy_from_slice(&u32::MAX.to_le_bytes()); + payload[row_start + 0x18..row_start + 0x1C] + .copy_from_slice(&0.5_f32.to_bits().to_le_bytes()); + payload[row_start + 0x1C..row_start + 0x20] + .copy_from_slice(&AUTHORED_TAG21_RAW_PARAMETER_3.to_bits().to_le_bytes()); + payload[12..16].copy_from_slice(&1_u32.to_le_bytes()); + + let mut fixed = [0_u8; CONTROL_TYPED_RECORD_FIXED_BYTES]; + fixed[0..4].copy_from_slice(&21_u32.to_le_bytes()); + fixed[4..8].copy_from_slice(&provider_key.to_le_bytes()); + fixed[0x18..0x1C].copy_from_slice(&u32::MAX.to_le_bytes()); + fixed[0x24..0x28].copy_from_slice(&1.0_f32.to_bits().to_le_bytes()); + fixed[0x28..0x2C].copy_from_slice(&1.0_f32.to_bits().to_le_bytes()); + fixed[CONTROL_TYPED_RECORD_COUNT_OFFSET..CONTROL_TYPED_RECORD_COUNT_OFFSET + 4] + .copy_from_slice(&1_u32.to_le_bytes()); + payload.extend_from_slice(&fixed); + payload.extend_from_slice(&0_u32.to_le_bytes()); + payload.extend_from_slice(&0_u32.to_le_bytes()); + payload + } + + fn node_data_payload(count: u32) -> Vec<u8> { + const ROW_BYTES: usize = 76; + let mut payload = vec![0; 4 + usize::try_from(count).expect("count") * ROW_BYTES]; + payload[0..4].copy_from_slice(&count.to_le_bytes()); + for row_index in 0..usize::try_from(count).expect("count") { + let start = 4 + row_index * ROW_BYTES; + payload[start..start + 4].copy_from_slice(&0_u32.to_le_bytes()); + payload[start + 4..start + 8].copy_from_slice(&6000.0_f32.to_bits().to_le_bytes()); + payload[start + 8..start + 12].copy_from_slice(&500.0_f32.to_bits().to_le_bytes()); + write_fixed_name(&mut payload[start + 12..start + 44], "system.rlb"); + write_fixed_name(&mut payload[start + 44..start + 76], "explode_frt_m.exp"); + } + payload + } + fn append_typed_record(payload: &mut Vec<u8>, tag: u32, string_length: u32) { let record_start = payload.len(); payload.resize(record_start + CONTROL_TYPED_RECORD_FIXED_BYTES + 4, 0); @@ -4044,6 +5167,8 @@ mod tests { dependencies: Vec::new(), control_resource: None, point_resource: None, + node_data_resource: None, + bas_resource: None, }; let second = EffectivePrototype { key: fparkan_prototype::PrototypeKey(resource_name(b"mesh")), @@ -4058,6 +5183,8 @@ mod tests { dependencies: Vec::new(), control_resource: None, point_resource: None, + node_data_resource: None, + bas_resource: None, }; assert_ne!(stable_visual_id(&first), stable_visual_id(&second)); @@ -4123,6 +5250,8 @@ mod tests { dependencies: vec![mesh_key.clone()], control_resource: None, point_resource: None, + node_data_resource: None, + bas_resource: None, }; let mut graph = prototype_graph_for_mesh(&prototype); let mut report = PrototypeGraphReport { @@ -4239,6 +5368,8 @@ mod tests { dependencies: Vec::new(), control_resource: None, point_resource: None, + node_data_resource: None, + bas_resource: None, }; let mut graph = prototype_graph_for_mesh(&prototype); graph @@ -4393,6 +5524,8 @@ mod tests { dependencies: Vec::new(), control_resource: None, point_resource: None, + node_data_resource: None, + bas_resource: None, }; let mut graph = prototype_graph_for_mesh(&prototype); let mut report = PrototypeGraphReport { @@ -4552,6 +5685,8 @@ mod tests { name: resource_name(b"attachments.cpt"), type_id: None, }), + node_data_resource: None, + bas_resource: None, }, EffectivePrototype { key: fparkan_prototype::PrototypeKey(resource_name(b"tree-b")), @@ -4565,6 +5700,8 @@ mod tests { type_id: None, }), point_resource: None, + node_data_resource: None, + bas_resource: None, }, EffectivePrototype { key: fparkan_prototype::PrototypeKey(resource_name(b"tree-c")), @@ -4578,6 +5715,8 @@ mod tests { type_id: None, }), point_resource: None, + node_data_resource: None, + bas_resource: None, }, ]; @@ -4672,6 +5811,8 @@ mod tests { type_id: None, }), point_resource: None, + node_data_resource: None, + bas_resource: None, }; let assets = prepare_mission_assets_with_repository(&repository, &[0..1], &[prototype]) @@ -4868,6 +6009,8 @@ mod tests { dependencies: vec![mesh_key.clone()], control_resource: None, point_resource: None, + node_data_resource: None, + bas_resource: None, }; let mut graph = prototype_graph_for_mesh(&prototype); let mut report = PrototypeGraphReport { @@ -5078,6 +6221,8 @@ mod tests { dependencies: Vec::new(), control_resource: None, point_resource: None, + node_data_resource: None, + bas_resource: None, }, EffectivePrototype { key: fparkan_prototype::PrototypeKey(resource_name(b"tree_b")), @@ -5091,6 +6236,8 @@ mod tests { dependencies: Vec::new(), control_resource: None, point_resource: None, + node_data_resource: None, + bas_resource: None, }, ]; (repo, prototypes) @@ -5253,6 +6400,7 @@ mod tests { prototype_requests: vec![prototype.key.clone()], root_prototype_request_spans: std::iter::once(0..1).collect(), root_unit_components: vec![Vec::new()], + root_unit_dat_header_flags: vec![None], visual_dependencies_expanded: false, nodes: vec![root_node, prototype_node, mesh_node], edges: vec![ diff --git a/crates/fparkan-fx/src/environment.rs b/crates/fparkan-fx/src/environment.rs index a298f24..a1f6c13 100644 --- a/crates/fparkan-fx/src/environment.rs +++ b/crates/fparkan-fx/src/environment.rs @@ -1695,7 +1695,9 @@ impl PrecipitationEmitter { let size = camera.precipitation_size_scalar() * size_factor; for index in 0..desired { let particle = &mut self.particles[index]; - particle.previous_position = particle.position; + if dt_seconds > 0.0 { + particle.previous_position = particle.position; + } particle.position[0] += velocity[0] * dt_seconds; particle.position[1] += velocity[1] * dt_seconds; particle.position[2] += velocity[2] * dt_seconds; @@ -2470,6 +2472,29 @@ mod tests { } #[test] + fn duplicate_zero_delta_keeps_rain_frame_and_tail_unchanged() { + let mut system = EnvironmentSystem::new(3); + let camera = Camera::new([10.0, 20.0, 30.0]); + let weather = WeatherInput { + rain: WeatherState::with_resources(true, 0.01, [1.0; 4], ["atm_rain1.wav"]), + ..WeatherInput::clear() + }; + let _ = system.update(0.0, weather.clone(), camera); + let advanced = system.update(0.001, weather.clone(), camera); + let time_seconds = system.time_seconds(); + let repeated = system.update(0.0, weather, camera); + + assert_eq!(system.time_seconds(), time_seconds); + assert_eq!(advanced, repeated); + assert!(advanced.primitives.iter().any(|primitive| match primitive { + EnvironmentPrimitive::Particle { screen, .. } => { + screen.world_head != screen.world_tail + } + EnvironmentPrimitive::Lightning(_) => false, + })); + } + + #[test] fn precipitation_uses_native_volume_ratio_and_nearest_even_count() { let reference = PrecipitationVolume::native(); assert_eq!(reference.particle_count_for(reference, 1.0, 0.5004), 500); diff --git a/crates/fparkan-prototype/src/lib.rs b/crates/fparkan-prototype/src/lib.rs index 9d52b7f..beb8526 100644 --- a/crates/fparkan-prototype/src/lib.rs +++ b/crates/fparkan-prototype/src/lib.rs @@ -2,7 +2,7 @@ //! Prototype registry and unit DAT primitives. use encoding_rs::WINDOWS_1251; -use fparkan_binary::{checked_count_bytes, Cursor, DecodeError}; +use fparkan_binary::{checked_allocation_len, checked_count_bytes, Cursor, DecodeError, Limits}; use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName}; use fparkan_resource::{ archive_path, resource_name, ResourceError, ResourceKey, ResourceRepository, @@ -18,6 +18,9 @@ const PROTOTYPE_INHERITANCE_DEPTH_LIMIT: usize = 32; /// `objects.rlb` external geometry entry type (`EXTO`). pub const PROTOTYPE_TYPE_EXTO: u32 = u32::from_le_bytes(*b"EXTO"); +/// Unit DAT second-header-word bit that enables native BAS placement handling. +pub const UNIT_DAT_BAS_PLACEMENT_FLAG: u32 = 0x8000_0000; + /// Prototype key. #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct PrototypeKey(pub ResourceName); @@ -40,6 +43,48 @@ pub struct UnitDat { pub records: Vec<UnitComponentRecord>, } +/// BAS payload with two ordered point-record sections. +/// +/// The `first` and `second` fields describe stream order only. The native +/// loader reads one record shape in the first section and a point-only shape +/// in the second, but this type does not assign gameplay or geometry meaning +/// to either section. +#[derive(Clone, Debug, PartialEq)] +pub struct BasGeometry { + /// First ordered section, whose records also carry two opaque word arrays. + pub first: Vec<BasGeometryRecord>, + /// Second ordered section of point sequences. + pub second: Vec<BasPointSequence>, +} + +/// BAS record from the first ordered section. +#[derive(Clone, Debug, PartialEq)] +pub struct BasGeometryRecord { + /// Count value stored before the point sequence. + pub declared_count: u32, + /// `declared_count + 1` points copied by the native loader. + /// + /// Point sequences are preserved as read; closure is not imposed by the + /// binary decoder. + pub points: Vec<[f32; 3]>, + /// First opaque word array, containing `declared_count` entries. + pub opaque_words_a: Vec<u32>, + /// Second opaque word array, containing `declared_count` entries. + pub opaque_words_b: Vec<u32>, +} + +/// BAS point sequence from the second ordered section. +#[derive(Clone, Debug, PartialEq)] +pub struct BasPointSequence { + /// Count value stored before the point sequence. + pub declared_count: u32, + /// `declared_count + 1` points copied by the native loader. + /// + /// Point sequences are preserved as read; closure is not imposed by the + /// binary decoder. + pub points: Vec<[f32; 3]>, +} + /// Unit DAT binding used by mission object references. #[derive(Clone, Debug, Eq, PartialEq)] pub struct UnitDatBinding { @@ -114,6 +159,12 @@ pub struct EffectivePrototype { /// Explicit component point resource referenced by this prototype's /// resolved `objects.rlb` registry inheritance chain, when present. pub point_resource: Option<ResourceKey>, + /// Explicit component NDP/provider resource referenced by this + /// prototype's resolved `objects.rlb` registry inheritance chain. + pub node_data_resource: Option<ResourceKey>, + /// Explicit BAS geometry resource referenced by this prototype's resolved + /// `objects.rlb` registry inheritance chain. + pub bas_resource: Option<ResourceKey>, } /// Prototype resolution source. @@ -143,6 +194,10 @@ pub struct PrototypeGraph { /// assigning runtime semantics to their `kind`, links, descriptions, or /// opaque tails. pub root_unit_components: Vec<Vec<UnitComponentRecord>>, + /// Second word of each standard component Unit DAT header. This vector is + /// aligned with [`Self::roots`]; direct prototype roots, legacy one-model + /// bindings, and failed decodes are `None`. + pub root_unit_dat_header_flags: Vec<Option<u32>>, /// Whether visual dependency expansion has already been applied. pub visual_dependencies_expanded: bool, /// Materialized prototype dependency graph nodes. @@ -565,6 +620,190 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> { }) } +/// Decodes a BAS payload as two ordered sections of point records. +/// +/// Each section begins with a record count. Every record stores a declared +/// count followed by that count plus one little-endian `Vec3` values. Records +/// in the first section then carry two arrays of `declared_count` opaque +/// words. The decoder preserves points and words without assigning geometric +/// meaning or requiring the point sequence to be closed. +/// +/// # Errors +/// +/// Returns [`PrototypeError::Decode`] when a count exceeds the shared binary +/// limits, a record is truncated, a point is not finite, or trailing bytes +/// remain. +pub fn decode_bas_geometry(payload: &[u8]) -> Result<BasGeometry, PrototypeError> { + let limits = Limits::default(); + let payload_len = u64::try_from(payload.len()).map_err(|_| DecodeError::IntegerOverflow)?; + if payload_len > limits.max_file_bytes { + return Err(DecodeError::LimitExceeded { + count: payload_len, + limit: limits.max_file_bytes, + } + .into()); + } + + let mut cursor = Cursor::new(payload); + let mut budget = BasAllocationBudget::new(limits.max_decoded_bytes); + + let first_count = read_bas_count(&mut cursor, limits.max_entries)?; + check_bas_section_minimum(&cursor, first_count)?; + let first_capacity = + budget.reserve::<BasGeometryRecord>(u64::from(first_count), limits.max_entries)?; + let mut first = Vec::with_capacity(first_capacity); + for _ in 0..first_count { + let declared_count = read_bas_count(&mut cursor, limits.max_array_items)?; + let point_count = bas_point_count(declared_count, limits.max_array_items)?; + check_bas_first_record(&cursor, declared_count, point_count)?; + + let points = read_bas_points(&mut cursor, point_count, &mut budget, limits)?; + let opaque_words_a = read_bas_words(&mut cursor, declared_count, &mut budget, limits)?; + let opaque_words_b = read_bas_words(&mut cursor, declared_count, &mut budget, limits)?; + first.push(BasGeometryRecord { + declared_count, + points, + opaque_words_a, + opaque_words_b, + }); + } + + let second_count = read_bas_count(&mut cursor, limits.max_entries)?; + check_bas_section_minimum(&cursor, second_count)?; + let second_capacity = + budget.reserve::<BasPointSequence>(u64::from(second_count), limits.max_entries)?; + let mut second = Vec::with_capacity(second_capacity); + for _ in 0..second_count { + let declared_count = read_bas_count(&mut cursor, limits.max_array_items)?; + let point_count = bas_point_count(declared_count, limits.max_array_items)?; + let remaining = + u64::try_from(cursor.remaining()).map_err(|_| DecodeError::IntegerOverflow)?; + checked_count_bytes(point_count, 12, remaining)?; + let points = read_bas_points(&mut cursor, point_count, &mut budget, limits)?; + second.push(BasPointSequence { + declared_count, + points, + }); + } + + cursor.require_eof()?; + Ok(BasGeometry { first, second }) +} + +fn read_bas_count(cursor: &mut Cursor<'_>, limit: u32) -> Result<u32, DecodeError> { + let count = cursor.read_u32_le()?; + if count > limit { + return Err(DecodeError::LimitExceeded { + count: u64::from(count), + limit: u64::from(limit), + }); + } + Ok(count) +} + +fn bas_point_count(declared_count: u32, limit: u32) -> Result<u64, DecodeError> { + let point_count = u64::from(declared_count) + .checked_add(1) + .ok_or(DecodeError::IntegerOverflow)?; + if point_count > u64::from(limit) { + return Err(DecodeError::LimitExceeded { + count: point_count, + limit: u64::from(limit), + }); + } + Ok(point_count) +} + +fn check_bas_section_minimum(cursor: &Cursor<'_>, record_count: u32) -> Result<(), DecodeError> { + let remaining = u64::try_from(cursor.remaining()).map_err(|_| DecodeError::IntegerOverflow)?; + // Each record has a count word and at least one point (count zero). + checked_count_bytes(u64::from(record_count), 16, remaining)?; + Ok(()) +} + +fn check_bas_first_record( + cursor: &Cursor<'_>, + declared_count: u32, + point_count: u64, +) -> Result<(), DecodeError> { + let remaining = u64::try_from(cursor.remaining()).map_err(|_| DecodeError::IntegerOverflow)?; + let point_bytes = checked_count_bytes(point_count, 12, remaining)?; + let after_points = remaining + .checked_sub(u64::try_from(point_bytes).map_err(|_| DecodeError::IntegerOverflow)?) + .ok_or(DecodeError::IntegerOverflow)?; + // The two unnamed word arrays each contain `declared_count` u32 entries. + checked_count_bytes(u64::from(declared_count), 8, after_points)?; + Ok(()) +} + +fn read_bas_points( + cursor: &mut Cursor<'_>, + point_count: u64, + budget: &mut BasAllocationBudget, + limits: Limits, +) -> Result<Vec<[f32; 3]>, DecodeError> { + let capacity = budget.reserve::<[f32; 3]>(point_count, limits.max_array_items)?; + let mut points = Vec::with_capacity(capacity); + for _ in 0..point_count { + let point = [ + cursor.read_f32_le()?, + cursor.read_f32_le()?, + cursor.read_f32_le()?, + ]; + if !point.iter().all(|value| value.is_finite()) { + return Err(DecodeError::Invalid("BAS point is not finite")); + } + points.push(point); + } + Ok(points) +} + +fn read_bas_words( + cursor: &mut Cursor<'_>, + word_count: u32, + budget: &mut BasAllocationBudget, + limits: Limits, +) -> Result<Vec<u32>, DecodeError> { + let capacity = budget.reserve::<u32>(u64::from(word_count), limits.max_array_items)?; + let mut words = Vec::with_capacity(capacity); + for _ in 0..word_count { + words.push(cursor.read_u32_le()?); + } + Ok(words) +} + +struct BasAllocationBudget { + used: u64, + limit: u64, +} + +impl BasAllocationBudget { + const fn new(limit: u64) -> Self { + Self { used: 0, limit } + } + + fn reserve<T>(&mut self, count: u64, count_limit: u32) -> Result<usize, DecodeError> { + let capacity = checked_allocation_len(count, u64::from(count_limit))?; + let element_size = + u64::try_from(std::mem::size_of::<T>()).map_err(|_| DecodeError::IntegerOverflow)?; + let allocation = count + .checked_mul(element_size) + .ok_or(DecodeError::IntegerOverflow)?; + let total = self + .used + .checked_add(allocation) + .ok_or(DecodeError::IntegerOverflow)?; + if total > self.limit { + return Err(DecodeError::LimitExceeded { + count: total, + limit: self.limit, + }); + } + self.used = total; + Ok(capacity) + } +} + /// Builds the immediate-child tree encoded by Unit DAT record counts. /// /// Records are stored in preorder. The final word of each 112-byte record is @@ -747,6 +986,7 @@ struct ResolvedPrototypeRequests { expected_count: usize, prototypes: Vec<EffectivePrototype>, unit_components: Vec<UnitComponentRecord>, + unit_dat_header_flags: Option<u32>, } fn resolve_prototype_requests( @@ -763,6 +1003,7 @@ fn resolve_prototype_requests( expected_count: 1, prototypes: prototype.into_iter().collect(), unit_components: Vec::new(), + unit_dat_header_flags: None, }) } @@ -798,6 +1039,11 @@ fn resolve_unit_dat_prototype_requests( expected_count: unit.records.len(), prototypes, unit_components: unit.records, + unit_dat_header_flags: Some(u32::from_le_bytes( + unit.header_opaque[4..8] + .try_into() + .expect("fixed Unit DAT header slice"), + )), }); } @@ -810,6 +1056,7 @@ fn resolve_unit_dat_prototype_requests( expected_count: 1, prototypes: prototype.into_iter().collect(), unit_components: Vec::new(), + unit_dat_header_flags: None, }) } @@ -857,6 +1104,9 @@ pub fn build_prototype_graph( graph .root_unit_components .push(expansion.unit_components.clone()); + graph + .root_unit_dat_header_flags + .push(expansion.unit_dat_header_flags); let unit_component_count = expansion.unit_components.len(); for (prototype_index, prototype) in expansion.prototypes.into_iter().enumerate() { let unit_component_index = is_unit_dat_root @@ -963,6 +1213,9 @@ pub fn build_prototype_graph_report( graph .root_unit_components .push(expansion.unit_components.clone()); + graph + .root_unit_dat_header_flags + .push(expansion.unit_dat_header_flags); if edge == PrototypeGraphEdge::MissionToUnitDat { report.unit_component_count += expected; } @@ -1063,6 +1316,9 @@ pub fn build_prototype_graph_report( if graph.root_unit_components.len() <= root_index { graph.root_unit_components.push(Vec::new()); } + if graph.root_unit_dat_header_flags.len() <= root_index { + graph.root_unit_dat_header_flags.push(None); + } let end = graph.prototype_requests.len(); graph.root_prototype_request_spans.push(start..end); } @@ -1151,6 +1407,8 @@ fn resolve_archive_model( None, None, None, + None, + None, ))) } @@ -1176,6 +1434,8 @@ fn resolve_objects_registry_model( }; let control_resource = resolve_explicit_control_resource(object_key, &refs)?; let point_resource = resolve_explicit_point_resource(object_key, &refs)?; + let node_data_resource = resolve_explicit_node_data_resource(object_key, &refs)?; + let bas_resource = resolve_explicit_bas_resource(object_key, &refs)?; let mut missing_mesh_refs = Vec::new(); for item in refs.iter().filter(|item| is_explicit_mesh_ref(item)) { @@ -1187,6 +1447,8 @@ fn resolve_objects_registry_model( source_type, control_resource.clone(), point_resource.clone(), + node_data_resource.clone(), + bas_resource.clone(), )? { return Ok(Some(prototype)); } @@ -1208,6 +1470,8 @@ fn resolve_objects_registry_model( dependencies: Vec::new(), control_resource, point_resource, + node_data_resource, + bas_resource, })) } @@ -1276,6 +1540,8 @@ fn resolve_object_ref_model( source_type: Option<u32>, control_resource: Option<ResourceKey>, point_resource: Option<ResourceKey>, + node_data_resource: Option<ResourceKey>, + bas_resource: Option<ResourceKey>, ) -> Result<Option<EffectivePrototype>, PrototypeError> { let archive = normalized_path_from_name(&ResourceName(cstr_bytes(&item.archive_raw).to_vec()))?; let Some(mesh) = find_mesh_resource(repository, &archive, &ResourceName(model_name.to_vec()))? @@ -1289,6 +1555,8 @@ fn resolve_object_ref_model( source_type, control_resource, point_resource, + node_data_resource, + bas_resource, ))) } @@ -1304,6 +1572,14 @@ fn is_explicit_point_ref(item: &ObjectRefRecord) -> bool { has_extension_bytes(cstr_bytes(&item.resource_raw), b"cpt") } +fn is_explicit_node_data_ref(item: &ObjectRefRecord) -> bool { + has_extension_bytes(cstr_bytes(&item.resource_raw), b"ndp") +} + +fn is_explicit_bas_ref(item: &ObjectRefRecord) -> bool { + has_extension_bytes(cstr_bytes(&item.resource_raw), b"bas") +} + fn resolve_explicit_control_resource( requested: &ResourceName, refs: &[ObjectRefRecord], @@ -1318,6 +1594,20 @@ fn resolve_explicit_point_resource( resolve_explicit_resource(requested, refs, is_explicit_point_ref, "point") } +fn resolve_explicit_node_data_resource( + requested: &ResourceName, + refs: &[ObjectRefRecord], +) -> Result<Option<ResourceKey>, PrototypeError> { + resolve_explicit_resource(requested, refs, is_explicit_node_data_ref, "node data") +} + +fn resolve_explicit_bas_resource( + requested: &ResourceName, + refs: &[ObjectRefRecord], +) -> Result<Option<ResourceKey>, PrototypeError> { + resolve_explicit_resource(requested, refs, is_explicit_bas_ref, "BAS geometry") +} + fn resolve_explicit_resource( requested: &ResourceName, refs: &[ObjectRefRecord], @@ -1421,6 +1711,8 @@ fn effective( source_type: Option<u32>, control_resource: Option<ResourceKey>, point_resource: Option<ResourceKey>, + node_data_resource: Option<ResourceKey>, + bas_resource: Option<ResourceKey>, ) -> EffectivePrototype { EffectivePrototype { key: PrototypeKey(requested), @@ -1430,6 +1722,8 @@ fn effective( dependencies: vec![mesh], control_resource, point_resource, + node_data_resource, + bas_resource, } } @@ -1565,6 +1859,70 @@ mod tests { } #[test] + fn bas_geometry_preserves_both_sections_and_open_point_sequences() { + let bytes = build_bas_geometry_payload(); + + let geometry = decode_bas_geometry(&bytes).expect("BAS geometry"); + + assert_eq!(geometry.first.len(), 2); + assert_eq!(geometry.first[0].declared_count, 1); + assert_eq!( + geometry.first[0].points, + vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]] + ); + assert_eq!(geometry.first[0].opaque_words_a, vec![0x1122_3344]); + assert_eq!(geometry.first[0].opaque_words_b, vec![0x5566_7788]); + assert_eq!(geometry.first[1].declared_count, 2); + assert_eq!( + geometry.first[1].points, + vec![[10.0, 11.0, 12.0], [13.0, 14.0, 15.0], [16.0, 17.0, 18.0]] + ); + assert_eq!(geometry.first[1].opaque_words_a, vec![7, 8]); + assert_eq!(geometry.first[1].opaque_words_b, vec![9, 10]); + assert_eq!(geometry.second.len(), 1); + assert_eq!(geometry.second[0].declared_count, 1); + assert_eq!( + geometry.second[0].points, + vec![[20.0, 21.0, 22.0], [23.0, 24.0, 25.0]] + ); + } + + #[test] + fn bas_geometry_rejects_truncated_records_and_extreme_counts() { + let mut truncated = build_bas_geometry_payload(); + truncated.pop(); + assert!(matches!( + decode_bas_geometry(&truncated), + Err(PrototypeError::Decode(DecodeError::UnexpectedEof { .. })) + )); + + assert!(matches!( + decode_bas_geometry(&u32::MAX.to_le_bytes()), + Err(PrototypeError::Decode(DecodeError::LimitExceeded { .. })) + )); + } + + #[test] + fn bas_geometry_rejects_non_finite_points_and_trailing_bytes() { + let mut non_finite = build_bas_geometry_payload(); + // The first point follows the first section and first record counts. + non_finite[8..12].copy_from_slice(&f32::NAN.to_bits().to_le_bytes()); + assert!(matches!( + decode_bas_geometry(&non_finite), + Err(PrototypeError::Decode(DecodeError::Invalid( + "BAS point is not finite" + ))) + )); + + let mut trailing = build_bas_geometry_payload(); + trailing.push(0); + assert!(matches!( + decode_bas_geometry(&trailing), + Err(PrototypeError::Decode(DecodeError::TrailingBytes { .. })) + )); + } + + #[test] fn unit_zero_records_uses_exact_size() { let bytes = [0_u8; 8]; let unit = decode_unit_dat(&bytes).expect("unit"); @@ -1606,6 +1964,10 @@ mod tests { let unit = decode_unit_dat(&bytes).expect("unit"); let record = &unit.records[0]; assert_eq!(unit.header_opaque, [0xF1, 0xF0, 1, 2, 3, 4, 5, 6]); + assert_eq!( + u32::from_le_bytes(unit.header_opaque[4..8].try_into().unwrap()), + 0x0605_0403 + ); assert_eq!(record.parent_or_link, -7); assert_eq!(&record.description_raw[..description.len()], description); assert_eq!(record.tail0, 0x1122_3344); @@ -1784,6 +2146,7 @@ mod tests { let (graph, resolved, report) = build_prototype_graph_report(&repo, vfs.as_ref(), &roots); assert_eq!(graph.roots.len(), 2); + assert_eq!(graph.root_unit_dat_header_flags, vec![None, None]); assert_eq!(resolved.len(), 1); assert_eq!(report.root_count, 2); assert_eq!(report.direct_reference_count, 2); @@ -1816,6 +2179,13 @@ mod tests { ); let vfs = Arc::new(vfs); let repo = CachedResourceRepository::new(vfs.clone()); + let (graph, _, report) = build_prototype_graph_report( + &repo, + vfs.as_ref(), + &[resource_name(b"UNITS/AUTO/unit.dat")], + ); + assert!(report.is_success()); + assert_eq!(graph.root_unit_dat_header_flags, vec![None]); let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"UNITS/AUTO/unit.dat")) .expect("resolve") @@ -1901,16 +2271,12 @@ mod tests { let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); let static_path = resource_archive_path(b"static.rlb").expect("static path"); let mesh = minimal_msh_payload(); - vfs.insert( - dat_path, - Arc::from( - build_unit_dat(&[ - (b"objects.rlb".as_slice(), b"component_a".as_slice()), - (b"objects.rlb".as_slice(), b"component_b".as_slice()), - ]) - .into_boxed_slice(), - ), - ); + let mut unit_dat = build_unit_dat(&[ + (b"objects.rlb".as_slice(), b"component_a".as_slice()), + (b"objects.rlb".as_slice(), b"component_b".as_slice()), + ]); + unit_dat[4..8].copy_from_slice(&UNIT_DAT_BAS_PLACEMENT_FLAG.to_le_bytes()); + vfs.insert(dat_path, Arc::from(unit_dat.into_boxed_slice())); vfs.insert( objects_path, Arc::from( @@ -2055,16 +2421,12 @@ mod tests { let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); let static_path = resource_archive_path(b"static.rlb").expect("static path"); let mesh = minimal_msh_payload(); - vfs.insert( - dat_path, - Arc::from( - build_unit_dat(&[ - (b"objects.rlb".as_slice(), b"component_a".as_slice()), - (b"objects.rlb".as_slice(), b"component_b".as_slice()), - ]) - .into_boxed_slice(), - ), - ); + let mut unit_dat = build_unit_dat(&[ + (b"objects.rlb".as_slice(), b"component_a".as_slice()), + (b"objects.rlb".as_slice(), b"component_b".as_slice()), + ]); + unit_dat[4..8].copy_from_slice(&UNIT_DAT_BAS_PLACEMENT_FLAG.to_le_bytes()); + vfs.insert(dat_path, Arc::from(unit_dat.into_boxed_slice())); vfs.insert( objects_path, Arc::from( @@ -2111,6 +2473,10 @@ mod tests { assert_eq!(graph.root_unit_components.len(), 1); assert_eq!(graph.root_unit_components[0].len(), 2); assert_eq!( + graph.root_unit_dat_header_flags, + vec![Some(UNIT_DAT_BAS_PLACEMENT_FLAG)] + ); + assert_eq!( cstr_bytes(&graph.root_unit_components[0][0].resource_raw), b"component_a" ); @@ -2171,6 +2537,7 @@ mod tests { (b"static.rlb".as_slice(), b"parent_proto.msh".as_slice()), (b"fortif.rlb".as_slice(), b"parent_proto.cpt".as_slice()), (b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()), + (b"fortif.rlb".as_slice(), b"parent_proto.ndp".as_slice()), ]) .as_slice(), ), @@ -2181,6 +2548,7 @@ mod tests { (b"fortif.rlb".as_slice(), b"child_proto.bas".as_slice()), (b"fortif.rlb".as_slice(), b"parent_proto.cpt".as_slice()), (b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()), + (b"fortif.rlb".as_slice(), b"parent_proto.ndp".as_slice()), ]) .as_slice(), ), @@ -2201,6 +2569,7 @@ mod tests { (b"child_proto.bas".as_slice(), b"base"), (b"parent_proto.ctl".as_slice(), b"control"), (b"parent_proto.cpt".as_slice(), b"points"), + (b"parent_proto.ndp".as_slice(), b"node data"), ]) .into_boxed_slice(), ), @@ -2223,6 +2592,16 @@ mod tests { let points = resolved.point_resource.expect("inherited explicit CPT"); assert_eq!(points.archive.as_str(), "fortif.rlb"); assert!(points.name.0.eq_ignore_ascii_case(b"parent_proto.cpt")); + let node_data = resolved + .node_data_resource + .expect("inherited explicit NDP provider table"); + assert_eq!(node_data.archive.as_str(), "fortif.rlb"); + assert!(node_data.name.0.eq_ignore_ascii_case(b"parent_proto.ndp")); + let bas = resolved + .bas_resource + .expect("child-local explicit BAS geometry"); + assert_eq!(bas.archive.as_str(), "fortif.rlb"); + assert!(bas.name.0.eq_ignore_ascii_case(b"child_proto.bas")); } #[test] @@ -2334,6 +2713,60 @@ mod tests { } #[test] + fn objects_registry_rejects_distinct_explicit_node_data() { + let raw_name = |value: &[u8]| { + let mut raw = [0; 32]; + raw[..value.len()].copy_from_slice(value); + raw + }; + let refs = [ + ObjectRefRecord { + archive_raw: raw_name(b"first.rlb"), + resource_raw: raw_name(b"first.ndp"), + }, + ObjectRefRecord { + archive_raw: raw_name(b"second.rlb"), + resource_raw: raw_name(b"second.ndp"), + }, + ]; + + let error = resolve_explicit_node_data_resource(&resource_name(b"prototype"), &refs) + .expect_err("distinct explicit node data resources are ambiguous"); + assert!(matches!( + error, + PrototypeError::Resource(ResourceError::Format(message)) + if message.contains("ambiguous explicit node data resources") + )); + } + + #[test] + fn objects_registry_rejects_distinct_explicit_bas_resources() { + let raw_name = |value: &[u8]| { + let mut raw = [0; 32]; + raw[..value.len()].copy_from_slice(value); + raw + }; + let refs = [ + ObjectRefRecord { + archive_raw: raw_name(b"first.rlb"), + resource_raw: raw_name(b"first.bas"), + }, + ObjectRefRecord { + archive_raw: raw_name(b"second.rlb"), + resource_raw: raw_name(b"second.bas"), + }, + ]; + + let error = resolve_explicit_bas_resource(&resource_name(b"prototype"), &refs) + .expect_err("distinct explicit BAS resources are ambiguous"); + assert!(matches!( + error, + PrototypeError::Resource(ResourceError::Format(message)) + if message.contains("ambiguous explicit BAS geometry resources") + )); + } + + #[test] fn objects_registry_inheritance_resolves_multiple_levels() { let mut vfs = MemoryVfs::default(); let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); @@ -2726,6 +3159,27 @@ mod tests { assert_eq!(mesh.archive.as_str().to_ascii_lowercase(), archive); assert!(mesh.name.0.eq_ignore_ascii_case(model)); } + + let plant = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"fr_b_plant")) + .expect("resolve factory prototype") + .expect("factory prototype"); + let bas = plant.bas_resource.expect("explicit BAS registry reference"); + assert_eq!(bas.archive.as_str().to_ascii_lowercase(), "fortif.rlb"); + assert!(bas.name.0.eq_ignore_ascii_case(b"fr_b_plant.bas")); + let bas_archive = repo + .open_archive(&bas.archive) + .expect("open explicit BAS archive"); + let bas_entry = repo + .find(bas_archive, &bas.name) + .expect("find explicit BAS entry") + .expect("BAS entry exists"); + let bas_payload = repo.read(bas_entry).expect("read explicit BAS entry"); + let geometry = + decode_bas_geometry(bas_payload.as_slice()).expect("decode licensed BAS geometry"); + assert_eq!(geometry.first.len(), 1); + assert_eq!(geometry.second.len(), 1); + assert_eq!(geometry.first[0].points.len(), 21); + assert_eq!(geometry.second[0].points.len(), 15); } #[test] @@ -2945,6 +3399,53 @@ mod tests { out } + fn build_bas_geometry_payload() -> Vec<u8> { + let mut out = Vec::new(); + out.extend_from_slice(&2_u32.to_le_bytes()); + + push_bas_record_with_words( + &mut out, + 1, + &[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], + &[0x1122_3344], + &[0x5566_7788], + ); + push_bas_record_with_words( + &mut out, + 2, + &[[10.0, 11.0, 12.0], [13.0, 14.0, 15.0], [16.0, 17.0, 18.0]], + &[7, 8], + &[9, 10], + ); + + out.extend_from_slice(&1_u32.to_le_bytes()); + out.extend_from_slice(&1_u32.to_le_bytes()); + push_bas_points(&mut out, &[[20.0, 21.0, 22.0], [23.0, 24.0, 25.0]]); + out + } + + fn push_bas_record_with_words( + out: &mut Vec<u8>, + declared_count: u32, + points: &[[f32; 3]], + words_a: &[u32], + words_b: &[u32], + ) { + out.extend_from_slice(&declared_count.to_le_bytes()); + push_bas_points(out, points); + for word in words_a.iter().chain(words_b) { + out.extend_from_slice(&word.to_le_bytes()); + } + } + + fn push_bas_points(out: &mut Vec<u8>, points: &[[f32; 3]]) { + for point in points { + for value in point { + out.extend_from_slice(&value.to_le_bytes()); + } + } + } + fn build_object_refs(items: &[(&[u8], &[u8])]) -> Vec<u8> { let mut out = Vec::with_capacity(items.len() * 64); for (archive, resource) in items { diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index e0088a3..57bee81 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -12,7 +12,7 @@ use fparkan_assets::{ use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy}; use fparkan_prototype::{ build_prototype_graph_report, PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport, - UnitComponentRecord, + UnitComponentRecord, UNIT_DAT_BAS_PLACEMENT_FLAG, }; use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository}; use fparkan_script::{ @@ -74,6 +74,13 @@ pub struct MissionObjectDraft { pub resource_name_raw: Vec<u8>, /// Raw identity/clan word from the mission. pub identity_or_clan_raw: u32, + /// Raw word after the mission object's instance name. Native BAS + /// placement uses its zero/nonzero state only when the Unit DAT header + /// gate is set. + pub raw_after_name: u32, + /// Second word of the root Unit DAT header, when it was decoded. A direct + /// prototype root has no Unit DAT header. + pub unit_dat_header_flags: Option<u32>, /// Raw position vector. pub position: [f32; 3], /// Raw orientation vector. @@ -91,6 +98,49 @@ pub struct MissionObjectDraft { pub unit_components: Vec<UnitComponentRecord>, } +/// Native terrain adjustment selected for a mission object's BAS geometry. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BasementPlacementMode { + /// Flatten the BAS inner boundary to mean outer terrain height and adjust + /// the object root transform by the native placement delta. + FlattenInnerAndAdjustRoot, + /// Keep authored BAS inner heights and leave the object root transform as + /// supplied by the mission. + PreserveAuthoredInnerHeight, +} + +impl MissionObjectDraft { + /// Returns the BAS mode selected by the native Unit DAT gate and mission + /// word, independent of resource path naming. + #[must_use] + pub fn basement_placement_mode(&self) -> Option<BasementPlacementMode> { + let header_flags = self.unit_dat_header_flags?; + if header_flags & UNIT_DAT_BAS_PLACEMENT_FLAG == 0 { + return None; + } + Some(if self.raw_after_name == 0 { + BasementPlacementMode::FlattenInnerAndAdjustRoot + } else { + BasementPlacementMode::PreserveAuthoredInnerHeight + }) + } + + /// Returns this mission root's position after applying the native BAS + /// transform adjustment once. Native code subtracts + /// `authored_inner_z - mean_outer_z` from matrix element 11. Component + /// local transforms should continue to be composed beneath this adjusted + /// root position without applying the delta again. + #[must_use] + pub fn root_position_with_basement_delta(&self, placement_delta: f32) -> [f32; 3] { + let mut position = self.position; + if self.basement_placement_mode() == Some(BasementPlacementMode::FlattenInnerAndAdjustRoot) + { + position[2] -= placement_delta; + } + position + } +} + /// A compiled script bundle selected by one mission clan. #[derive(Clone, Debug, Eq, PartialEq)] pub struct MissionScriptBundle { @@ -731,6 +781,12 @@ fn load_mission_with_options( original_id: u32::try_from(index).ok().map(OriginalObjectId), resource_name_raw: object.resource_raw.clone(), identity_or_clan_raw: object.identity_or_clan_raw, + raw_after_name: object.raw_after_name, + unit_dat_header_flags: prototype_graph + .root_unit_dat_header_flags + .get(index) + .copied() + .flatten(), position: object.position, orientation_raw: object.orientation, scale: object.scale, @@ -1329,6 +1385,58 @@ mod tests { use fparkan_vfs::{DirectoryVfs, MemoryVfs, VfsEntry, VfsMetadata}; use std::path::{Path, PathBuf}; + fn basement_draft(header_flags: Option<u32>, raw_after_name: u32) -> MissionObjectDraft { + MissionObjectDraft { + original_id: Some(OriginalObjectId(7)), + resource_name_raw: b"BUILDS/example.dat".to_vec(), + identity_or_clan_raw: 0, + raw_after_name, + unit_dat_header_flags: header_flags, + position: [12.0, 34.0, 100.0], + orientation_raw: [0.0; 3], + scale: [1.0; 3], + visual_ids: Vec::new(), + properties: Vec::new(), + unit_components: Vec::new(), + } + } + + #[test] + fn native_basement_mode_uses_unit_header_gate_not_builds_path() { + assert_eq!( + basement_draft(Some(UNIT_DAT_BAS_PLACEMENT_FLAG), 0).basement_placement_mode(), + Some(BasementPlacementMode::FlattenInnerAndAdjustRoot) + ); + assert_eq!( + basement_draft(Some(UNIT_DAT_BAS_PLACEMENT_FLAG), 1).basement_placement_mode(), + Some(BasementPlacementMode::PreserveAuthoredInnerHeight) + ); + assert_eq!(basement_draft(Some(0), 0).basement_placement_mode(), None); + assert_eq!(basement_draft(None, 1).basement_placement_mode(), None); + } + + #[test] + fn native_basement_delta_adjusts_only_flattened_root_once() { + let flatten = basement_draft(Some(UNIT_DAT_BAS_PLACEMENT_FLAG), 0); + let root = flatten.root_position_with_basement_delta(3.25); + assert_eq!(root, [12.0, 34.0, 96.75]); + // A component remains local to the root; it receives the root shift + // through composition instead of applying the delta itself. + let child_local_z = 8.0; + assert_eq!(root[2] + child_local_z, 104.75); + + let preserve = basement_draft(Some(UNIT_DAT_BAS_PLACEMENT_FLAG), 0x40); + assert_eq!( + preserve.root_position_with_basement_delta(3.25), + preserve.position + ); + let no_gate = basement_draft(Some(0), 0); + assert_eq!( + no_gate.root_position_with_basement_delta(3.25), + no_gate.position + ); + } + #[test] fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() { let mut engine = create(EngineServices::default()).expect("engine"); diff --git a/crates/fparkan-terrain-format/src/lib.rs b/crates/fparkan-terrain-format/src/lib.rs index 88bd325..e4028f9 100644 --- a/crates/fparkan-terrain-format/src/lib.rs +++ b/crates/fparkan-terrain-format/src/lib.rs @@ -1853,12 +1853,44 @@ mod tests { missing_nodes.faces.clear(); assert_eq!(missing_nodes.face_lod_assignments(), Ok(Vec::new())); - let mut overlapping = mesh; + let mut overlapping = mesh.clone(); overlapping.slots.slots_raw[3][..2].copy_from_slice(&2_u16.to_le_bytes()); assert_eq!( overlapping.face_lod_assignments(), Err(TerrainLodError::DuplicateFaceAssignment { face: 2 }) ); + + let mut uncovered = mesh.clone(); + uncovered.slots.slots_raw[1][2..4].copy_from_slice(&0_u16.to_le_bytes()); + assert_eq!( + uncovered.face_lod_assignments(), + Err(TerrainLodError::MissingFaceAssignment { face: 1 }) + ); + + let mut out_of_bounds = mesh.clone(); + out_of_bounds.slots.slots_raw[3][..2].copy_from_slice(&4_u16.to_le_bytes()); + assert_eq!( + out_of_bounds.face_lod_assignments(), + Err(TerrainLodError::FaceRangeOutOfBounds { + node: 1, + lod: TerrainLod::Lod1, + slot: 3, + start: 4, + count: 1, + face_count: 4, + }) + ); + + let mut unsupported = mesh; + unsupported.nodes_raw[12..14].copy_from_slice(&0_u16.to_le_bytes()); + assert_eq!( + unsupported.face_lod_assignments(), + Err(TerrainLodError::UnsupportedNodeSlot { + node: 0, + slot_index: 2, + slot: 0, + }) + ); } #[test] diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs index db55506..54946e7 100644 --- a/crates/fparkan-terrain/src/lib.rs +++ b/crates/fparkan-terrain/src/lib.rs @@ -341,7 +341,7 @@ impl TerrainWorld { let mut surfaces = Vec::with_capacity(foundation.fragments.len()); let mut generated = 0_usize; for (fragment_index, fragment) in foundation.fragments.iter().enumerate() { - if !valid_foundation_triangle(fragment.positions) { + if !valid_foundation_triangle(fragment.positions, fragment.source_face.is_none()) { return Err(TerrainError::InvalidFoundationFragment { fragment: fragment_index, }); @@ -1031,7 +1031,7 @@ fn build_surfaces(mesh: &LandMeshDocument) -> Result<Vec<RuntimeTriangle>, Terra Ok(triangles) } -fn valid_foundation_triangle(vertices: [[f32; 3]; 3]) -> bool { +fn valid_foundation_triangle(vertices: [[f32; 3]; 3], allow_zero_xy_area: bool) -> bool { if !vertices.iter().flatten().all(|value| value.is_finite()) { return false; } @@ -1043,7 +1043,9 @@ fn valid_foundation_triangle(vertices: [[f32; 3]; 3]) -> bool { f64::from(vertices[2][0]) - f64::from(vertices[0][0]), f64::from(vertices[2][1]) - f64::from(vertices[0][1]), ]; - first[0] * second[1] - first[1] * second[0] != 0.0 + let cross = first[0] * second[1] - first[1] * second[0]; + // Native vertex welding can collapse generated band faces; query math naturally misses zero area. + cross != 0.0 || allow_zero_xy_area } fn surface_vertex( @@ -1270,6 +1272,49 @@ mod tests { } #[test] + fn surface_queries_use_lod0_and_reject_missing_node_metadata() { + let mut mesh = synthetic_land_mesh(); + mesh.positions = vec![ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 100.0], + [1.0, 0.0, 100.0], + [0.0, 1.0, 100.0], + ]; + mesh.faces = vec![ + terrain_face(FullSurfaceMask(1), [0, 1, 2]), + terrain_face(FullSurfaceMask(2), [3, 4, 5]), + ]; + assign_terrain_lod_slots(&mut mesh, &[[0, 1]], &[[0, 1], [1, 1]]); + + let world = TerrainWorld::from_land_msh(&mesh).expect("LOD0 terrain"); + assert_eq!(world.surface_count(), 1); + assert_eq!(world.height_at([0.25, 0.25]).expect("height"), Some(0.0)); + assert_eq!( + world + .raycast([0.25, 0.25, 101.0], [0.0, 0.0, -1.0], FullSurfaceMask(2),) + .expect("LOD1-only mask"), + None + ); + + mesh.nodes_raw.clear(); + assert!(matches!( + TerrainWorld::from_land_msh(&mesh), + Err(TerrainError::InvalidLodMapping( + fparkan_terrain_format::TerrainLodError::MissingNodes { face_count: 2 } + )) + )); + mesh.faces.clear(); + assert_eq!( + TerrainWorld::from_land_msh(&mesh) + .expect("empty scene without Node38") + .surface_count(), + 0 + ); + } + + #[test] fn foundation_world_queries_use_the_canonical_cut_and_generated_band() { use crate::{build_basement_foundations, BasementFootprint}; @@ -1365,7 +1410,55 @@ mod tests { } #[test] - fn foundation_install_rejects_invalid_source_identity_masks_and_generated_flags() { + fn foundation_install_keeps_finite_welded_zero_area_band_for_queries() { + let mesh = synthetic_land_mesh(); + let positions = [ + [1644.398, 1118.5295, 132.54382], + [1644.4183, 1118.5342, 132.54755], + [1644.4183, 1118.5342, 132.54755], + ]; + let foundation = BasementFoundation { + fragments: vec![BasementFoundationFragment { + source_face: None, + surface_mask: FullSurfaceMask(0x8300), + positions, + }], + placements: Vec::new(), + }; + let world = TerrainWorld::from_land_msh(&mesh) + .expect("source world") + .with_basement_foundation(foundation) + .expect("finite generated fragment retained after native vertex welding"); + + assert_eq!(world.surface_count(), 1); + assert_eq!( + world + .basement_foundation() + .expect("installed foundation") + .fragments[0] + .positions, + positions + ); + assert_eq!( + world + .height_at([positions[1][0], positions[1][1]]) + .expect("degenerate height query"), + None + ); + assert_eq!( + world + .raycast( + [positions[1][0], positions[1][1], 200.0], + [0.0, 0.0, -1.0], + FullSurfaceMask(0), + ) + .expect("degenerate ray query"), + None + ); + } + + #[test] + fn foundation_install_rejects_invalid_source_identity_masks_flags_and_non_finite_geometry() { let mesh = synthetic_land_mesh(); let source_triangle = [[0.0, 0.0, 0.0], [1.0, 0.0, 1.0], [0.0, 1.0, 1.0]]; let generated_triangle = [[0.0, 0.0, 0.0], [0.25, 0.0, 0.0], [0.0, 0.25, 0.0]]; @@ -1385,6 +1478,11 @@ mod tests { surface_mask: FullSurfaceMask(0x0300), positions: generated_triangle, }, + BasementFoundationFragment { + source_face: None, + surface_mask: FullSurfaceMask(0x8300), + positions: [[0.0, 0.0, 0.0], [0.25, 0.0, 0.0], [0.0, f32::NAN, 0.0]], + }, ]; for (index, fragment) in invalid.into_iter().enumerate() { @@ -1545,8 +1643,35 @@ mod tests { .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); let mesh = fparkan_terrain_format::decode_land_msh(&nres) .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + let expected_lod0_faces = mesh + .face_lod_assignments() + .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")) + .iter() + .filter(|assignment| assignment.lod == TerrainLod::Lod0) + .count(); let world = TerrainWorld::from_land_msh(&mesh) .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + assert_eq!( + world.surface_count(), + expected_lod0_faces, + "{corpus} {path:?}" + ); + if corpus == "IS" { + let map_name = path + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .map(str::to_ascii_lowercase); + let expected = match map_name.as_deref() { + Some("automap") => Some(1_912), + Some("prol") => Some(5_868), + Some("tut_1") => Some(5_046), + _ => None, + }; + if let Some(expected) = expected { + assert_eq!(world.surface_count(), expected, "{corpus} {path:?}"); + } + } if world.surface_count() > SURFACE_BVH_LEAF_SIZE { let triangle = &world.surfaces[0]; let point = [ @@ -1602,15 +1727,31 @@ mod tests { aux18: Vec::new(), faces: vec![face0, face1], }; - let mut node = vec![u8::MAX; 38]; - node[8..10].copy_from_slice(&0_u16.to_le_bytes()); - let mut slot = [0_u8; 68]; - slot[2..4].copy_from_slice(&(mesh.faces.len() as u16).to_le_bytes()); - mesh.nodes_raw = node; - mesh.slots.slots_raw.push(slot); + assign_terrain_lod_slots(&mut mesh, &[[0, u16::MAX]], &[[0, 2]]); mesh } + fn assign_terrain_lod_slots( + mesh: &mut LandMeshDocument, + node_slots: &[[u16; 2]], + face_ranges: &[[u16; 2]], + ) { + const NODE_STRIDE: usize = 38; + let mut nodes = vec![u8::MAX; node_slots.len() * NODE_STRIDE]; + for (node_index, [lod0, lod1]) in node_slots.iter().copied().enumerate() { + let offset = node_index * NODE_STRIDE + 8; + nodes[offset..offset + 2].copy_from_slice(&lod0.to_le_bytes()); + nodes[offset + 2..offset + 4].copy_from_slice(&lod1.to_le_bytes()); + } + let mut slots = vec![[0_u8; 68]; face_ranges.len()]; + for (slot, [start, count]) in slots.iter_mut().zip(face_ranges.iter().copied()) { + slot[..2].copy_from_slice(&start.to_le_bytes()); + slot[2..4].copy_from_slice(&count.to_le_bytes()); + } + mesh.nodes_raw = nodes; + mesh.slots.slots_raw = slots; + } + fn terrain_face( flags: FullSurfaceMask, vertices: [u16; 3], |
