aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/fparkan-assets/src/lib.rs1276
-rw-r--r--crates/fparkan-fx/src/environment.rs27
-rw-r--r--crates/fparkan-prototype/src/lib.rs543
-rw-r--r--crates/fparkan-runtime/src/lib.rs110
-rw-r--r--crates/fparkan-terrain-format/src/lib.rs34
-rw-r--r--crates/fparkan-terrain/src/lib.rs161
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],