aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/fparkan-assets/src/lib.rs1463
-rw-r--r--crates/fparkan-fx/src/environment.rs402
-rw-r--r--crates/fparkan-fx/src/lib.rs1
-rw-r--r--crates/fparkan-fx/src/smoke.rs1360
-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/contour.rs809
-rw-r--r--crates/fparkan-terrain/src/foundation.rs1806
-rw-r--r--crates/fparkan-terrain/src/lib.rs515
10 files changed, 6890 insertions, 153 deletions
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs
index a79220a..d34effb 100644
--- a/crates/fparkan-assets/src/lib.rs
+++ b/crates/fparkan-assets/src/lib.rs
@@ -6,8 +6,8 @@ use fparkan_material::{
MAT0_KIND, WEAR_KIND,
};
pub use fparkan_material::{
- sample_material_phase, Mat0Document, MaterialCoefficients, MaterialError, MaterialPhase,
- MaterialPhaseSample,
+ sample_material_normalized_phase, sample_material_phase, Mat0Document, MaterialCoefficients,
+ MaterialError, MaterialPhase, MaterialPhaseSample,
};
use fparkan_mission_format::{decode_tma, decode_tma_land_path, ClanBody};
pub use fparkan_mission_format::{LpString, MissionDocument, MissionError, TmaProfile};
@@ -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};
@@ -513,11 +513,44 @@ pub fn load_material_name_texture_rgba8_and_phase_with_document_from_root(
material_name: &str,
phase_index: u16,
) -> Result<(Vec<RgbaImage>, PreparedMaterialPhase, Mat0Document), String> {
+ load_material_archive_name_texture_rgba8_and_phase_with_document_from_root(
+ root,
+ "material.lib",
+ material_name,
+ phase_index,
+ )
+}
+
+/// Loads a named MAT0 entry from an FX-selected archive, its selected texture
+/// phase, and the complete document used by the runtime animation sampler.
+pub fn load_material_archive_name_texture_rgba8_and_phase_with_document_from_root(
+ root: &Path,
+ archive_name: &str,
+ material_name: &str,
+ phase_index: u16,
+) -> Result<(Vec<RgbaImage>, PreparedMaterialPhase, Mat0Document), String> {
+ if archive_name.is_empty() {
+ return Err("material archive name is empty".to_string());
+ }
if material_name.is_empty() {
return Err("material name is empty".to_string());
}
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
- let archive = parse_path("material.lib").map_err(|err| err.to_string())?;
+ load_material_archive_name_texture_rgba8_and_phase_from_repository(
+ &repository,
+ archive_name,
+ material_name,
+ phase_index,
+ )
+}
+
+fn load_material_archive_name_texture_rgba8_and_phase_from_repository<R: ResourceRepository>(
+ repository: &R,
+ archive_name: &str,
+ material_name: &str,
+ phase_index: u16,
+) -> Result<(Vec<RgbaImage>, PreparedMaterialPhase, Mat0Document), String> {
+ let archive = parse_path(archive_name).map_err(|err| err.to_string())?;
let archive_id = repository
.open_archive(&archive)
.map_err(|err| err.to_string())?;
@@ -525,12 +558,14 @@ pub fn load_material_name_texture_rgba8_and_phase_with_document_from_root(
let entry = repository
.find(archive_id, &name)
.map_err(|err| err.to_string())?
- .ok_or_else(|| format!("missing material {material_name:?}"))?;
+ .ok_or_else(|| format!("missing material {archive_name}/{material_name}"))?;
let info = repository
.entry_info(entry)
.map_err(|err| err.to_string())?;
if info.key.type_id != Some(MAT0_KIND) {
- return Err(format!("material {material_name:?} is not a MAT0 entry"));
+ return Err(format!(
+ "material {archive_name}/{material_name} is not a MAT0 entry"
+ ));
}
let document = decode_mat0(
repository
@@ -548,7 +583,7 @@ pub fn load_material_name_texture_rgba8_and_phase_with_document_from_root(
};
let document = material.document.clone();
let (mips, phase) =
- load_resolved_material_texture_rgba8_and_phase(&repository, &material, phase_index)?;
+ load_resolved_material_texture_rgba8_and_phase(repository, &material, phase_index)?;
Ok((mips, phase, document))
}
@@ -901,14 +936,120 @@ 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,
+ /// Validated group selected by native Control's first effect-group
+ /// header word; `None` is the native no-group sentinel or an invalid index.
+ pub initial_effect_group: Option<usize>,
/// 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 +1135,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 +1619,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 +2060,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 +2092,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 +2101,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 +2110,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 +2118,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 +2133,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 +3396,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 +3405,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 +3438,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 +3637,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 +3648,34 @@ 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,
+ initial_effect_group: Option<usize>,
+ 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,
+ }
+ },
+ initial_effect_group: None,
+ typed_records: Vec::new(),
});
}
if typed_record_count > CONTROL_TYPED_RECORD_LIMIT {
@@ -3019,13 +3685,18 @@ 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),
+ },
+ initial_effect_group: None,
+ typed_records,
});
}
@@ -3061,50 +3732,86 @@ 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,
+ }
+ },
+ initial_effect_group: None,
+ typed_records,
});
}
- cursor = cursor
+ let group_header_end = cursor
.checked_add(CONTROL_EFFECT_GROUP_HEADER_BYTES)
.ok_or_else(|| invalid("effect group header end overflows"))?;
- if cursor > payload.len() {
+ if group_header_end > payload.len() {
return Err(invalid("effect group header extends past the payload"));
}
+ let initial_effect_group = usize::try_from(
+ control_u32_at(payload, cursor)
+ .expect("validated effect group header contains its initial selector"),
+ )
+ .ok()
+ .filter(|group| *group < effect_group_count);
+ cursor = group_header_end;
let mut effect_rows = Vec::new();
for table_index in 0..effect_group_count {
@@ -3165,12 +3872,20 @@ 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,
+ },
+ initial_effect_group: None,
+ typed_records,
});
}
- Ok(PreparedControlEffectSection::Parsed(effect_rows))
+ Ok(DecodedComponentEffectSection {
+ section: PreparedControlEffectSection::Parsed(effect_rows),
+ initial_effect_group,
+ typed_records,
+ })
}
fn control_u32_at(payload: &[u8], offset: usize) -> Option<u32> {
@@ -3250,22 +3965,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 +4016,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 +4026,13 @@ 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,
+ initial_effect_group: decoded_effect.initial_effect_group,
point_list: None,
})
}
@@ -3402,6 +4138,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 +4360,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 +4390,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 +4401,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 +4492,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 +4525,170 @@ mod tests {
}
#[test]
+ fn component_control_uses_validated_initial_effect_group_header_word() {
+ 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(&1_u32.to_le_bytes());
+ payload[16..20].copy_from_slice(&9_u32.to_le_bytes());
+ append_typed_record(&mut payload, 21, 0);
+ let group_header = payload.len();
+ payload.resize(group_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0);
+ payload[group_header..group_header + 4].copy_from_slice(&8_u32.to_le_bytes());
+ for group in 0..9 {
+ if group == 8 {
+ append_effect_table(
+ &mut payload,
+ &[effect_row(
+ 4,
+ [0; 3],
+ [0; 3],
+ 17,
+ "effects.rlb",
+ "f_smalllight_y",
+ )],
+ );
+ } else {
+ append_effect_table(&mut payload, &[]);
+ }
+ }
+
+ let control = decode_component_control_payload(&payload, source.clone(), 2, 3)
+ .expect("nine declared effect groups parse");
+ assert_eq!(control.initial_effect_group, Some(8));
+ let PreparedControlEffectSection::Parsed(rows) = control.effect_section else {
+ panic!("effect groups should parse");
+ };
+ assert_eq!(rows.len(), 1);
+ assert_eq!(rows[0].table_index, 8);
+
+ payload[group_header..group_header + 4].copy_from_slice(&u32::MAX.to_le_bytes());
+ let no_default = decode_component_control_payload(&payload, source.clone(), 2, 3)
+ .expect("native no-group sentinel is accepted");
+ assert_eq!(no_default.initial_effect_group, None);
+
+ payload[group_header..group_header + 4].copy_from_slice(&9_u32.to_le_bytes());
+ let out_of_range = decode_component_control_payload(&payload, source, 2, 3)
+ .expect("out-of-range selector does not invalidate tables");
+ assert_eq!(out_of_range.initial_effect_group, None);
+ }
+
+ #[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 +4750,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 +4800,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 +4914,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 +4970,7 @@ mod tests {
&repository,
source,
Some(missing_point_source),
+ None,
2,
0,
)
@@ -3837,6 +5024,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 +5271,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 +5287,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 +5354,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 +5472,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 +5628,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 +5789,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 +5804,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 +5819,8 @@ mod tests {
type_id: None,
}),
point_resource: None,
+ node_data_resource: None,
+ bas_resource: None,
},
];
@@ -4672,6 +5915,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 +6113,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 +6325,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 +6340,8 @@ mod tests {
dependencies: Vec::new(),
control_resource: None,
point_resource: None,
+ node_data_resource: None,
+ bas_resource: None,
},
];
(repo, prototypes)
@@ -5129,6 +6380,62 @@ mod tests {
CachedResourceRepository::new(Arc::new(vfs))
}
+ #[test]
+ fn fx_material_loader_uses_the_archive_named_by_the_fx_command() {
+ let fx_mat0 = mat0_with_texture(b"FX_LAMP_TEX");
+ let default_mat0 = mat0_with_texture(b"DEFAULT_TEX");
+ let texm = texm_payload_with_page_rect();
+ let repo = repository_with_archives_meta(&[
+ (
+ "effects.rlb",
+ &[TestNresEntry {
+ name: b"lamp",
+ payload: &fx_mat0,
+ type_id: MAT0_KIND,
+ attr2: 0,
+ }],
+ ),
+ (
+ "material.lib",
+ &[TestNresEntry {
+ name: b"lamp",
+ payload: &default_mat0,
+ type_id: MAT0_KIND,
+ attr2: 0,
+ }],
+ ),
+ (
+ TEXTURES_ARCHIVE,
+ &[
+ TestNresEntry {
+ name: b"FX_LAMP_TEX",
+ payload: &texm,
+ type_id: 0,
+ attr2: 0,
+ },
+ TestNresEntry {
+ name: b"DEFAULT_TEX",
+ payload: &texm,
+ type_id: 0,
+ attr2: 0,
+ },
+ ],
+ ),
+ ]);
+
+ let (_, _, document) = load_material_archive_name_texture_rgba8_and_phase_from_repository(
+ &repo,
+ "effects.rlb",
+ "lamp",
+ 0,
+ )
+ .expect("FX-selected archive has a valid MAT0");
+ assert_eq!(
+ document.primary_texture(),
+ Some(resource_name(b"FX_LAMP_TEX"))
+ );
+ }
+
fn texm_payload() -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&0x6d78_6554_u32.to_le_bytes());
@@ -5143,6 +6450,17 @@ mod tests {
out
}
+ fn texm_payload_with_page_rect() -> Vec<u8> {
+ let mut out = texm_payload();
+ out.extend_from_slice(&0x6567_6150_u32.to_le_bytes());
+ out.extend_from_slice(&1_u32.to_le_bytes());
+ out.extend_from_slice(&0_i16.to_le_bytes());
+ out.extend_from_slice(&1_i16.to_le_bytes());
+ out.extend_from_slice(&0_i16.to_le_bytes());
+ out.extend_from_slice(&1_i16.to_le_bytes());
+ out
+ }
+
fn mat0_with_texture(texture: &[u8]) -> Vec<u8> {
let mut bytes = vec![0; 4 + 34];
bytes[0..2].copy_from_slice(&1_u16.to_le_bytes());
@@ -5253,6 +6571,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 babbd0f..a1f6c13 100644
--- a/crates/fparkan-fx/src/environment.rs
+++ b/crates/fparkan-fx/src/environment.rs
@@ -7,7 +7,7 @@
//! loading behind the simulation.
use crate::sky::SkyMaterials;
-use crate::{FxDocument, FxError, FxOpcode};
+use crate::{FxCommand, FxDocument, FxError, FxOpcode};
use std::sync::Arc;
// Terrain.dll writes these velocity vectors into the shared precipitation
@@ -597,12 +597,14 @@ impl LightningBounds {
pub struct PointLightFrame {
/// Opcode 1 type word. `env_lightning` uses type 7.
pub effect_type: u32,
- /// Effect age supplied to the native evaluator, in seconds.
- pub elapsed_seconds: f32,
+ /// Native LightManager selector selected from the opcode 1 type.
+ pub light_selector: u32,
+ /// Native LightManager flags selected from the opcode 1 type.
+ pub native_flags: u32,
+ /// Native update value compared with the opcode 1 start/end fields.
+ pub command_time: f32,
/// Interpolation parameter between the opcode 1 start/end times.
pub normalized_time: f32,
- /// FXID lifetime in seconds.
- pub duration_seconds: f32,
/// World-space LightManager position.
pub position: [f32; 3],
/// World-space direction submitted alongside the point-light record.
@@ -616,10 +618,146 @@ pub struct PointLightFrame {
/// The scalar at body offset 116. Its native role is an effect factor,
/// not alpha or a UV parameter, so it is preserved without reinterpretation.
pub effect_factor: f32,
- /// Whether the opcode 1 command is active at `elapsed_seconds`.
+ /// Whether the opcode 1 command is active at `command_time`.
pub active: bool,
}
+/// Evaluates a native opcode 1 point-light command at `command_time` and
+/// applies a parent basis to its local position and direction. `basis` stores
+/// the world-space vectors corresponding to local +X, +Y, and +Z.
+/// `command_time` is resolved by the caller; FX header duration and lifecycle
+/// remain with that caller. The interpolated range is retained as authored;
+/// native Op1 does not clamp it to zero.
+#[must_use]
+pub fn evaluate_op1_point_light_with_basis(
+ command: &FxCommand,
+ command_time: f32,
+ origin: [f32; 3],
+ basis: [[f32; 3]; 3],
+) -> Option<PointLightFrame> {
+ if command.opcode != FxOpcode::Op1
+ || !command_time.is_finite()
+ || !origin
+ .into_iter()
+ .chain(basis.into_iter().flatten())
+ .all(f32::is_finite)
+ {
+ return None;
+ }
+
+ let body = command.raw_body.as_ref();
+ const FLOAT_OFFSETS: &[usize] = &[
+ 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 68, 72, 76, 80, 84, 88, 108,
+ 112, 116, 120, 124, 128,
+ ];
+ if body.len() < 132
+ || FLOAT_OFFSETS
+ .iter()
+ .any(|&offset| read_f32(body, offset).is_none_or(|value| !value.is_finite()))
+ {
+ return None;
+ }
+ let normalized_time = native_opcode1_interval(body, command_time)?;
+ if !normalized_time.is_finite() {
+ return None;
+ }
+ let sample = sample_op1_point_light(body, normalized_time)?;
+ let (light_selector, native_flags) = opcode1_light_manager_mapping(sample.effect_type)?;
+ if light_selector != 1 {
+ return None;
+ }
+ let transformed_direction = transform3(basis, sample.local_direction);
+ let position = add3(origin, transform3(basis, sample.local_position));
+ if !position.into_iter().all(f32::is_finite)
+ || !transformed_direction.into_iter().all(f32::is_finite)
+ || !sample.color.into_iter().all(f32::is_finite)
+ || !sample.range.is_finite()
+ || !sample.attenuation.into_iter().all(f32::is_finite)
+ || !sample.effect_factor.is_finite()
+ {
+ return None;
+ }
+ let direction = normalize_op1_direction(transformed_direction);
+
+ Some(PointLightFrame {
+ effect_type: sample.effect_type,
+ light_selector,
+ native_flags,
+ command_time,
+ normalized_time,
+ position,
+ direction,
+ color: sample.color,
+ range: sample.range,
+ attenuation: sample.attenuation,
+ effect_factor: sample.effect_factor,
+ active: true,
+ })
+}
+
+#[derive(Clone, Copy)]
+struct Op1PointLightSample {
+ effect_type: u32,
+ local_position: [f32; 3],
+ local_direction: [f32; 3],
+ color: [f32; 4],
+ range: f32,
+ attenuation: [f32; 3],
+ effect_factor: f32,
+}
+
+fn sample_op1_point_light(body: &[u8], normalized_time: f32) -> Option<Op1PointLightSample> {
+ Some(Op1PointLightSample {
+ effect_type: read_u32(body, 0)?,
+ local_position: lerp3(read_vec3(body, 12)?, read_vec3(body, 24)?, normalized_time),
+ local_direction: lerp3(read_vec3(body, 36)?, read_vec3(body, 48)?, normalized_time),
+ color: lerp4(read_vec4(body, 60)?, read_vec4(body, 76)?, normalized_time),
+ range: lerp(read_f32(body, 108)?, read_f32(body, 112)?, normalized_time),
+ attenuation: [
+ read_f32(body, 120)?,
+ read_f32(body, 124)?,
+ read_f32(body, 128)?,
+ ],
+ effect_factor: read_f32(body, 116)?,
+ })
+}
+
+fn normalize_op1_direction(direction: [f32; 3]) -> [f32; 3] {
+ // Native FastProc normalization preserves a degenerate vector as zero;
+ // this path must not invent a fallback axis.
+ let length =
+ (direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2])
+ .sqrt();
+ if length.is_finite() && length > 0.0 {
+ direction.map(|component| component / length)
+ } else {
+ [0.0; 3]
+ }
+}
+
+fn lightning_point_light_direction(local_direction: [f32; 3], basis: [[f32; 3]; 3]) -> [f32; 3] {
+ // Preserve the pre-existing Lightning consumer fallback, which is
+ // separate from the generic native Op1 behavior.
+ finite_unit(transform3(basis, local_direction))
+ .unwrap_or_else(|| finite_unit(basis[0]).unwrap_or([1.0, 0.0, 0.0]))
+}
+
+/// Maps the native opcode 1 type switch to its LightManager record selector
+/// and flags. Types 3 and 4 select non-point record classes; generic point
+/// lights reject those selectors, while Lightning keeps the full mapping.
+fn opcode1_light_manager_mapping(effect_type: u32) -> Option<(u32, u32)> {
+ Some(match effect_type {
+ 1 => (1, 0x8000_0000),
+ 2 => (1, 0),
+ 3 => (3, 0),
+ 4 => (4, 0),
+ 5 => (1, 0),
+ 6 => (1, 0xA000_0000),
+ 7 => (1, 0x2000_0000),
+ _ => return None,
+ })
+}
+
/// Native quad produced by the opcode 3 lightning visual.
///
/// CLightning creates this effect after moving the sampled position 300
@@ -959,32 +1097,24 @@ impl LightningEffect {
}
let body = self.opcode1_body.as_ref();
- let effect_type = read_u32(body, 0)?;
let (normalized_time, _) = active_interval(body, elapsed_seconds, self.duration_seconds)?;
- let local_position = lerp3(read_vec3(body, 12)?, read_vec3(body, 24)?, normalized_time);
- let local_direction = lerp3(read_vec3(body, 36)?, read_vec3(body, 48)?, normalized_time);
- let color = lerp4(read_vec4(body, 60)?, read_vec4(body, 76)?, normalized_time);
- let range = lerp(read_f32(body, 108)?, read_f32(body, 112)?, normalized_time);
- let attenuation = [
- read_f32(body, 120)?,
- read_f32(body, 124)?,
- read_f32(body, 128)?,
- ];
- let effect_factor = read_f32(body, 116)?;
- let direction = finite_unit(transform3(basis, local_direction))
- .unwrap_or_else(|| finite_unit(basis[0]).unwrap_or([1.0, 0.0, 0.0]));
+ let sample = sample_op1_point_light(body, normalized_time)?;
+ let (light_selector, native_flags) =
+ opcode1_light_manager_mapping(sample.effect_type).unwrap_or((0, 0));
+ let direction = lightning_point_light_direction(sample.local_direction, basis);
Some(PointLightFrame {
- effect_type,
- elapsed_seconds,
+ effect_type: sample.effect_type,
+ light_selector,
+ native_flags,
+ command_time: elapsed_seconds,
normalized_time,
- duration_seconds: self.duration_seconds,
- position: add3(origin, transform3(basis, local_position)),
+ position: add3(origin, transform3(basis, sample.local_position)),
direction,
- color: color.map(sanitize_float),
- range: sanitize_float(range).max(0.0),
- attenuation: attenuation.map(sanitize_float),
- effect_factor: sanitize_float(effect_factor),
+ color: sample.color.map(sanitize_float),
+ range: sanitize_float(sample.range).max(0.0),
+ attenuation: sample.attenuation.map(sanitize_float),
+ effect_factor: sanitize_float(sample.effect_factor),
active: true,
})
}
@@ -1565,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;
@@ -1960,6 +2092,31 @@ fn active_interval(body: &[u8], elapsed: f32, duration: f32) -> Option<(f32, f32
Some((normalized, end.min(duration)))
}
+fn native_opcode1_interval(body: &[u8], command_time: f32) -> Option<f32> {
+ let start = read_f32(body, 4)?;
+ let end = read_f32(body, 8)?;
+ if !command_time.is_finite()
+ || !start.is_finite()
+ || !end.is_finite()
+ || command_time < start
+ || command_time > end
+ {
+ return None;
+ }
+ let span = end - start;
+ if !span.is_finite() {
+ return None;
+ }
+ // Effect.dll divides by the interval span without an epsilon gate
+ // (1000F703-1000F783). A zero-width interval produces 0/0, which cannot
+ // yield a finite LightManager sample; keep that command inactive here.
+ if span == 0.0 {
+ return None;
+ }
+ let normalized = ((command_time - start) / span).clamp(0.0, 1.0);
+ normalized.is_finite().then_some(normalized)
+}
+
fn evaluate_position(body: &[u8], normalized_time: f32) -> Option<[f32; 3]> {
let start = read_vec3(body, 36)?;
let delta = read_vec3(body, 48)?;
@@ -2315,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);
@@ -2593,6 +2773,172 @@ mod tests {
}
#[test]
+ fn generic_opcode1_uses_native_command_time_not_header_duration_phase() {
+ // These type/start/end values come from the shipped f_blinklight_r
+ // (header mode 2, duration 2s) and b_sphere_main (mode 0, duration
+ // 6s) Op1 records. Their local command clocks must reach their own
+ // authored windows; dividing by the FX lifetime would miss both.
+ let blinklight = op1_test_command(6, 0.75, 1.0);
+ let sphere = op1_test_command(5, 1.0 / 3.0, 1.0);
+
+ let blink_midpoint = evaluate_op1_point_light_identity(&blinklight, 0.875, [0.0; 3])
+ .expect("blink light command midpoint");
+ let sphere_midpoint = evaluate_op1_point_light_identity(&sphere, 2.0 / 3.0, [0.0; 3])
+ .expect("sphere command midpoint");
+ assert_eq!(blink_midpoint.effect_type, 6);
+ assert_eq!(blink_midpoint.light_selector, 1);
+ assert_eq!(blink_midpoint.native_flags, 0xA000_0000);
+ assert_eq!(blink_midpoint.command_time, 0.875);
+ assert!((blink_midpoint.normalized_time - 0.5).abs() < 1.0e-6);
+ assert_eq!(sphere_midpoint.effect_type, 5);
+ assert_eq!(sphere_midpoint.light_selector, 1);
+ assert_eq!(sphere_midpoint.native_flags, 0);
+ assert!((sphere_midpoint.normalized_time - 0.5).abs() < 1.0e-6);
+
+ // The native comparisons accept the endpoint; the effect remains
+ // active at `end`, where its interpolation reaches one.
+ let blink_end = evaluate_op1_point_light_identity(&blinklight, 1.0, [0.0; 3])
+ .expect("inclusive end of native interval");
+ assert_eq!(blink_end.normalized_time, 1.0);
+ assert!(
+ evaluate_op1_point_light_identity(&blinklight, 1.0 + f32::EPSILON, [0.0; 3]).is_none()
+ );
+ assert!(
+ evaluate_op1_point_light_identity(&op1_test_command(3, 0.0, 1.0), 0.5, [0.0; 3])
+ .is_none()
+ );
+ assert!(
+ evaluate_op1_point_light_identity(&op1_test_command(4, 0.0, 1.0), 0.5, [0.0; 3])
+ .is_none()
+ );
+ }
+
+ #[test]
+ fn generic_opcode1_preserves_narrow_native_intervals_and_rejects_zero_span() {
+ let narrow_end = f32::EPSILON * 0.5;
+ let narrow = op1_test_command(5, 0.0, narrow_end);
+ let sample = evaluate_op1_point_light_identity(&narrow, narrow_end * 0.5, [0.0; 3])
+ .expect("non-zero native interval below epsilon");
+ assert!((sample.normalized_time - 0.5).abs() < 1.0e-6);
+
+ let zero_width = op1_test_command(5, 0.0, 0.0);
+ assert!(evaluate_op1_point_light_identity(&zero_width, 0.0, [0.0; 3]).is_none());
+ }
+
+ #[test]
+ fn generic_opcode1_preserves_negative_native_range() {
+ let mut command = op1_test_command(5, 0.0, 1.0);
+ let mut body = command.raw_body.to_vec();
+ body[108..112].copy_from_slice(&(-2.0_f32).to_le_bytes());
+ body[112..116].copy_from_slice(&(-4.0_f32).to_le_bytes());
+ command.raw_body = Arc::from(body.into_boxed_slice());
+
+ let sample = evaluate_op1_point_light_identity(&command, 0.5, [0.0; 3])
+ .expect("native Op1 sample with a negative range");
+ assert_eq!(sample.range, -3.0);
+ }
+
+ #[test]
+ fn generic_opcode1_keeps_zero_direction_zero() {
+ let mut command = op1_test_command(5, 0.0, 1.0);
+ let mut body = command.raw_body.to_vec();
+ body[36..60].fill(0);
+ command.raw_body = Arc::from(body.into_boxed_slice());
+
+ let sample = evaluate_op1_point_light_identity(&command, 0.5, [0.0; 3])
+ .expect("native Op1 sample with a zero direction");
+ assert_eq!(sample.direction, [0.0; 3]);
+ }
+
+ #[test]
+ fn generic_opcode1_validates_only_values_consumed_by_the_handler() {
+ let mut command = op1_test_command(7, 0.0, 1.0);
+ let mut body = command.raw_body.to_vec();
+ body[92..96].copy_from_slice(&f32::NAN.to_le_bytes());
+ command.raw_body = Arc::from(body.into_boxed_slice());
+ assert!(evaluate_op1_point_light_identity(&command, 0.5, [0.0; 3]).is_some());
+
+ let mut body = command.raw_body.to_vec();
+ body[80..84].copy_from_slice(&f32::NAN.to_le_bytes());
+ command.raw_body = Arc::from(body.into_boxed_slice());
+ assert!(evaluate_op1_point_light_identity(&command, 0.5, [0.0; 3]).is_none());
+ }
+
+ #[test]
+ fn generic_opcode1_rejects_non_finite_transformed_values() {
+ let command = op1_test_command(5, 0.0, 1.0);
+
+ assert!(evaluate_op1_point_light_with_basis(
+ &command,
+ 0.5,
+ [f32::MAX, 0.0, 0.0],
+ [[f32::MAX, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
+ )
+ .is_none());
+ }
+
+ fn op1_test_command(effect_type: u32, start: f32, end: f32) -> FxCommand {
+ let mut body = vec![0; 132];
+ body[0..4].copy_from_slice(&effect_type.to_le_bytes());
+ body[4..8].copy_from_slice(&start.to_le_bytes());
+ body[8..12].copy_from_slice(&end.to_le_bytes());
+ body[12..24].copy_from_slice(
+ &[0.0_f32; 3]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[24..36].copy_from_slice(
+ &[2.0_f32, 4.0, 6.0]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[36..48].copy_from_slice(
+ &[1.0_f32, 0.0, 0.0]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[48..60].copy_from_slice(
+ &[0.0_f32, 1.0, 0.0]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[60..76].copy_from_slice(
+ &[1.0_f32, 1.0, 1.0, 1.0]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[76..92].copy_from_slice(
+ &[3.0_f32, 3.0, 3.0, 1.0]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[108..112].copy_from_slice(&10.0_f32.to_le_bytes());
+ body[112..116].copy_from_slice(&20.0_f32.to_le_bytes());
+ body[116..120].copy_from_slice(&0.5_f32.to_le_bytes());
+ FxCommand {
+ word: 1,
+ opcode: FxOpcode::Op1,
+ native_flag: false,
+ raw_body: Arc::from(body.into_boxed_slice()),
+ resource_refs: Vec::new(),
+ }
+ }
+
+ fn evaluate_op1_point_light_identity(
+ command: &FxCommand,
+ command_time: f32,
+ origin: [f32; 3],
+ ) -> Option<PointLightFrame> {
+ evaluate_op1_point_light_with_basis(command, command_time, origin, identity_basis())
+ }
+
+ #[test]
fn opcode3_quad_applies_parent_diagonal_before_offset_and_scale() {
let mut body = vec![0; 196];
body[0..4].copy_from_slice(&1_u32.to_le_bytes());
diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs
index 04e54ad..4125a68 100644
--- a/crates/fparkan-fx/src/lib.rs
+++ b/crates/fparkan-fx/src/lib.rs
@@ -13,6 +13,7 @@ pub mod fx_visual;
pub mod op7;
pub mod shadow;
pub mod sky;
+pub mod smoke;
/// Advances the native FX 16-bit-pair random state and returns its new high half.
///
diff --git a/crates/fparkan-fx/src/smoke.rs b/crates/fparkan-fx/src/smoke.rs
new file mode 100644
index 0000000..116dcb7
--- /dev/null
+++ b/crates/fparkan-fx/src/smoke.rs
@@ -0,0 +1,1360 @@
+//! Deterministic evaluator for verified native opcode-8 particle pools.
+
+use crate::environment::EffectResource;
+use crate::fx_visual::{
+ compose_native_effect_matrix, native_camera_facing_basis, native_effect_local_point,
+ transform_native_effect_quad, EffectQuadFrame, EFFECT_QUAD_UV,
+};
+use crate::{FxCommand, FxOpcode};
+use std::sync::Arc;
+
+const RESOURCE_OFFSET: usize = 0xB4;
+const RESOURCE_BYTES: usize = 64;
+const MIN_BODY_SIZE: usize = RESOURCE_OFFSET + RESOURCE_BYTES;
+const MAX_POOL_SIZE: usize = 4096;
+const MAX_CATCH_UP: usize = 100_000;
+const U16_TO_UNIT: f32 = 1.0 / 65_536.0;
+
+/// Bounds for the native per-particle detail threshold.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Op8DetailThresholdRange {
+ /// Lower native threshold bound.
+ min: f32,
+ /// Upper native threshold bound.
+ max: f32,
+}
+
+impl Op8DetailThresholdRange {
+ /// Returns a range only when both endpoints are finite and ordered.
+ #[must_use]
+ pub fn new(min: f32, max: f32) -> Option<Self> {
+ (min.is_finite() && max.is_finite() && min <= max).then_some(Self { min, max })
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+struct NativeLfsr16Pair {
+ state: u32,
+}
+
+impl NativeLfsr16Pair {
+ fn from_word(state: u32) -> Self {
+ Self { state }
+ }
+
+ fn next_u16(&mut self) -> u16 {
+ crate::advance_native_fx_random_state(&mut self.state)
+ }
+
+ fn next_unit(&mut self) -> f32 {
+ f32::from(self.next_u16()) * U16_TO_UNIT
+ }
+
+ fn next_signed(&mut self, width: f32) -> f32 {
+ (self.next_unit() - 0.5) * width
+ }
+}
+
+#[derive(Clone, Copy, Debug)]
+struct Op8Track {
+ start: [f32; 3],
+ end: [f32; 3],
+ exponent: [f32; 3],
+}
+
+#[derive(Clone, Debug)]
+struct ParticleSlot {
+ born_at: f32,
+ threshold: f32,
+ random_phase: f32,
+ owner_matrix: [[f32; 4]; 4],
+ position: Op8Track,
+ scale: Op8Track,
+}
+
+/// Mutable state for one Op8 command. Keep one state per command instance.
+#[derive(Clone, Debug)]
+pub struct SmokeEmitterState {
+ initial_emitter_rng: NativeLfsr16Pair,
+ initial_signed_rng: NativeLfsr16Pair,
+ initial_material_rng: NativeLfsr16Pair,
+ emitter_rng: NativeLfsr16Pair,
+ signed_rng: NativeLfsr16Pair,
+ material_rng: NativeLfsr16Pair,
+ body: Option<Arc<[u8]>>,
+ emitter_position: Option<Op8Track>,
+ stored_anchor: Option<[f32; 3]>,
+ cursor: usize,
+ last_spawn: f32,
+ interval: f32,
+ slots: Vec<Option<ParticleSlot>>,
+}
+
+impl SmokeEmitterState {
+ /// Creates an evaluator with explicit emitter, signed-range, and material seeds.
+ #[must_use]
+ pub fn new(emitter_seed: u32, signed_seed: u32, material_seed: u32) -> Self {
+ let initial_emitter_rng = NativeLfsr16Pair::from_word(emitter_seed);
+ let initial_signed_rng = NativeLfsr16Pair::from_word(signed_seed);
+ let initial_material_rng = NativeLfsr16Pair::from_word(material_seed);
+ Self {
+ initial_emitter_rng,
+ initial_signed_rng,
+ initial_material_rng,
+ emitter_rng: initial_emitter_rng,
+ signed_rng: initial_signed_rng,
+ material_rng: initial_material_rng,
+ body: None,
+ emitter_position: None,
+ stored_anchor: None,
+ cursor: 0,
+ last_spawn: 0.0,
+ interval: 0.0,
+ slots: Vec::new(),
+ }
+ }
+
+ /// Resets the three streams and the native pool state.
+ fn reset(&mut self) {
+ self.emitter_rng = self.initial_emitter_rng;
+ self.signed_rng = self.initial_signed_rng;
+ self.material_rng = self.initial_material_rng;
+ self.body = None;
+ self.emitter_position = None;
+ self.stored_anchor = None;
+ self.cursor = 0;
+ self.last_spawn = 0.0;
+ self.interval = 0.0;
+ self.slots.clear();
+ }
+
+ /// Number of slots retained by the native ring.
+ #[must_use]
+ #[cfg(test)]
+ fn pool_len(&self) -> usize {
+ self.slots.len()
+ }
+
+ /// Native ring cursor. A scheduled event advances this index before access.
+ #[must_use]
+ #[cfg(test)]
+ fn next_slot(&self) -> usize {
+ self.cursor
+ }
+
+ fn material_threshold(&mut self, range: Op8DetailThresholdRange) -> f32 {
+ range.min + self.material_rng.next_unit() * (range.max - range.min)
+ }
+}
+
+/// Tests the native inclusive detail gate.
+#[must_use]
+fn detail_class_passes_threshold(detail_class: f32, threshold: f32) -> bool {
+ detail_class.is_finite() && threshold.is_finite() && detail_class <= threshold
+}
+
+/// Evaluates the native Op8 pool and returns one frame per slot in ring order.
+///
+/// The body is parsed from its verified shared offsets, so material names and
+/// pool sizes can vary. The caller supplies the native update clock, normalized
+/// effect phase, detail threshold bounds, owner transform, camera position,
+/// and explicit RNG state. The first in-window call captures the owner pose
+/// and emits nothing, matching the native update path. Each emitted particle
+/// keeps its interpolated birth anchor and a motion-oriented slot basis.
+#[must_use]
+#[allow(clippy::too_many_arguments)]
+pub fn evaluate_op8_emitter(
+ command: &FxCommand,
+ command_index: usize,
+ world_from_effect: &[[f32; 4]; 4],
+ camera_world: [f32; 3],
+ update_clock: f32,
+ effect_phase: f32,
+ detail_class: u32,
+ detail_threshold_range: Op8DetailThresholdRange,
+ state: &mut SmokeEmitterState,
+) -> Option<Vec<EffectQuadFrame>> {
+ if command.opcode != FxOpcode::Op8
+ || !world_from_effect
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ || !camera_world.iter().all(|value| value.is_finite())
+ || !update_clock.is_finite()
+ || !effect_phase.is_finite()
+ || !detail_threshold_range.min.is_finite()
+ || !detail_threshold_range.max.is_finite()
+ || detail_threshold_range.min > detail_threshold_range.max
+ || !(detail_threshold_range.max - detail_threshold_range.min).is_finite()
+ {
+ return None;
+ }
+
+ let params = Op8Params::parse(command)?;
+ if state
+ .body
+ .as_deref()
+ .is_some_and(|body| body != command.raw_body.as_ref())
+ {
+ state.reset();
+ }
+ state.body = Some(Arc::clone(&command.raw_body));
+
+ if state.slots.len() != params.pool_count {
+ if !state.slots.is_empty() {
+ state.reset();
+ state.body = Some(Arc::clone(&command.raw_body));
+ }
+ state.slots.resize_with(params.pool_count, || None);
+ state.cursor = 0;
+ }
+ if state.emitter_position.is_none() {
+ state.emitter_position = Some(sample_emitter_position(&params, state)?);
+ }
+
+ let in_window = effect_phase >= params.window_start && effect_phase <= params.window_end;
+ let interval_phase =
+ normalized_window_phase(effect_phase, params.window_start, params.window_end);
+ let due_interval =
+ params.interval_start + (params.interval_end - params.interval_start) * interval_phase;
+ let schedule_interval =
+ params.interval_start + (params.interval_end - params.interval_start) * effect_phase;
+ if !due_interval.is_finite() || due_interval <= 0.0 {
+ return None;
+ }
+ let emitter_offset = sample_track(state.emitter_position?, interval_phase)?;
+ let current_anchor = apply_emitter_offset(world_from_effect, emitter_offset)?;
+
+ if !in_window && !state.slots.iter().any(Option::is_some) {
+ // The native update returns early once its live-particle count is zero.
+ // A later in-window update captures a fresh owner anchor before emitting.
+ state.stored_anchor = None;
+ state.last_spawn = 0.0;
+ state.interval = due_interval;
+ } else if state.last_spawn == 0.0 {
+ // Native uses a zero schedule base as its initialization sentinel.
+ // If the first update clock is zero, the next update captures again.
+ state.stored_anchor = Some(matrix_translation(&current_anchor));
+ state.last_spawn = update_clock;
+ state.interval = due_interval;
+ } else {
+ // Native clamps only the schedule base on a time rewind. Existing
+ // particles and all three random streams remain intact.
+ if state.last_spawn > update_clock {
+ state.last_spawn = update_clock;
+ }
+ let catch_up = catch_up_count(
+ state.last_spawn,
+ update_clock,
+ due_interval,
+ schedule_interval,
+ )?;
+ let schedule_origin = state.last_spawn;
+ let mut stored_anchor = state.stored_anchor?;
+ let velocity = if catch_up == 0 {
+ [0.0; 3]
+ } else {
+ owner_anchor_velocity(
+ &current_anchor,
+ stored_anchor,
+ update_clock,
+ schedule_origin,
+ )?
+ };
+ for _ in 0..catch_up {
+ let schedule_base = state.last_spawn;
+ let born_at = schedule_base + due_interval;
+ let next_schedule_base = schedule_base + schedule_interval;
+ if !born_at.is_finite() {
+ return None;
+ }
+ if !next_schedule_base.is_finite() || next_schedule_base <= schedule_base {
+ return None;
+ }
+ let (owner_matrix, next_anchor) =
+ advance_owner_anchor(&current_anchor, stored_anchor, velocity, due_interval)?;
+ if in_window {
+ spawn_particle(
+ &params,
+ detail_threshold_range,
+ born_at,
+ owner_matrix,
+ state,
+ )?;
+ } else {
+ retire_particle(params.pool_count, state);
+ }
+ stored_anchor = next_anchor;
+ state.stored_anchor = Some(next_anchor);
+ state.last_spawn = next_schedule_base;
+ }
+ state.interval = due_interval;
+ }
+
+ let interval_period = state.interval * params.pool_count as f32;
+ if !interval_period.is_finite() || interval_period <= 0.0 {
+ return None;
+ }
+ let mut frames = Vec::with_capacity(params.pool_count);
+
+ for (slot_index, slot) in state.slots.iter().enumerate() {
+ let Some(particle) = slot else {
+ frames.push(empty_frame(command_index, &params.resource));
+ continue;
+ };
+ let mut raw_phase = (update_clock - state.last_spawn) / interval_period
+ + (state.cursor as f32 - slot_index as f32) / params.pool_count as f32;
+ if raw_phase < 0.0 {
+ raw_phase += 1.0;
+ }
+ if !raw_phase.is_finite() {
+ return None;
+ }
+
+ let position = sample_track(particle.position, raw_phase)?;
+ let scale = sample_track(particle.scale, raw_phase)?;
+ let opacity = params.opacity_start
+ + (params.opacity_end - params.opacity_start) * raw_phase.powf(params.opacity_exponent);
+ let material_phase = if params.material_phase_exponent < 0.0 {
+ particle.random_phase
+ } else {
+ raw_phase.powf(params.material_phase_exponent)
+ };
+ if !opacity.is_finite() || !material_phase.is_finite() {
+ return None;
+ }
+
+ let local_position = position;
+ let detail_passes = detail_class_passes_threshold(detail_class as f32, particle.threshold);
+ let camera_local = native_effect_local_point(&particle.owner_matrix, camera_world)?;
+ let view = subtract3(camera_local, local_position);
+ let Some(local_basis) = native_camera_facing_basis(view, local_position) else {
+ frames.push(EffectQuadFrame {
+ command_index,
+ opcode: FxOpcode::Op8,
+ material: params.resource.clone(),
+ vertices: [[0.0; 3]; 4],
+ uv: EFFECT_QUAD_UV,
+ color: [1.0, 1.0, 1.0, 0.0],
+ material_phase,
+ elapsed_seconds: (update_clock - particle.born_at).max(0.0),
+ normalized_time: raw_phase,
+ active: false,
+ native_flag_base: None,
+ });
+ continue;
+ };
+ let matrix = compose_native_effect_matrix(
+ &particle.owner_matrix,
+ &local_basis,
+ local_position,
+ scale,
+ )?;
+ let vertices = transform_native_effect_quad(&matrix)?;
+ let visible = detail_passes;
+ frames.push(EffectQuadFrame {
+ command_index,
+ opcode: FxOpcode::Op8,
+ material: params.resource.clone(),
+ vertices,
+ uv: EFFECT_QUAD_UV,
+ color: [1.0, 1.0, 1.0, if visible { opacity } else { 0.0 }],
+ material_phase,
+ elapsed_seconds: (update_clock - particle.born_at).max(0.0),
+ normalized_time: raw_phase,
+ active: visible,
+ native_flag_base: None,
+ });
+ }
+ Some(frames)
+}
+
+#[derive(Clone, Debug)]
+struct Op8Params {
+ resource: EffectResource,
+ pool_count: usize,
+ window_start: f32,
+ window_end: f32,
+ interval_start: f32,
+ interval_end: f32,
+ material_phase_exponent: f32,
+ opacity_start: f32,
+ opacity_end: f32,
+ opacity_exponent: f32,
+ emitter_position_start: [f32; 3],
+ emitter_position_end_center: [f32; 3],
+ emitter_position_width: [f32; 3],
+ emitter_position_exponent: [f32; 3],
+ position_start: [f32; 3],
+ position_end_center: [f32; 3],
+ position_width: [f32; 3],
+ position_exponent: [f32; 3],
+ scale_start: [f32; 3],
+ scale_end_center: [f32; 3],
+ scale_width: [f32; 3],
+ scale_exponent: [f32; 3],
+}
+
+impl Op8Params {
+ fn parse(command: &FxCommand) -> Option<Self> {
+ let body = &command.raw_body;
+ if body.len() < MIN_BODY_SIZE {
+ return None;
+ }
+ let pool_count = read_u32(body, 0x20)? as usize;
+ let window_start = read_f32(body, 0x0C)?;
+ let window_end = read_f32(body, 0x10)?;
+ let interval_start = read_f32(body, 0x14)?;
+ let interval_end = read_f32(body, 0x18)?;
+ let material_phase_exponent = read_f32(body, 0x1C)?;
+ let opacity_start = read_f32(body, 0x00)?;
+ let opacity_end = read_f32(body, 0x04)?;
+ let opacity_exponent = read_f32(body, 0x08)?;
+ let emitter_position_start = read_vec3(body, 0x24)?;
+ let emitter_position_end_center = read_vec3(body, 0x30)?;
+ let emitter_position_width = read_vec3(body, 0x3C)?;
+ let emitter_position_exponent = read_vec3(body, 0x48)?;
+ let position_start = read_vec3(body, 0x54)?;
+ let position_end_center = read_vec3(body, 0x60)?;
+ let position_width = read_vec3(body, 0x6C)?;
+ let position_exponent = read_vec3(body, 0x78)?;
+ let scale_start = read_vec3(body, 0x84)?;
+ let scale_end_center = read_vec3(body, 0x90)?;
+ let scale_width = read_vec3(body, 0x9C)?;
+ let scale_exponent = read_vec3(body, 0xA8)?;
+ if pool_count == 0
+ || pool_count > MAX_POOL_SIZE
+ || ![
+ window_start,
+ window_end,
+ interval_start,
+ interval_end,
+ material_phase_exponent,
+ opacity_start,
+ opacity_end,
+ opacity_exponent,
+ ]
+ .iter()
+ .all(|value| value.is_finite())
+ || window_start > window_end
+ || interval_start <= 0.0
+ || interval_end <= 0.0
+ || !emitter_position_start
+ .iter()
+ .chain(emitter_position_end_center.iter())
+ .chain(emitter_position_width.iter())
+ .chain(emitter_position_exponent.iter())
+ .chain(position_start.iter())
+ .chain(position_end_center.iter())
+ .chain(position_width.iter())
+ .chain(position_exponent.iter())
+ .chain(scale_start.iter())
+ .chain(scale_end_center.iter())
+ .chain(scale_width.iter())
+ .chain(scale_exponent.iter())
+ .all(|value| value.is_finite())
+ {
+ return None;
+ }
+
+ Some(Self {
+ resource: read_resource(body)?,
+ pool_count,
+ window_start,
+ window_end,
+ interval_start,
+ interval_end,
+ material_phase_exponent,
+ opacity_start,
+ opacity_end,
+ opacity_exponent,
+ emitter_position_start,
+ emitter_position_end_center,
+ emitter_position_width,
+ emitter_position_exponent,
+ position_start,
+ position_end_center,
+ position_width,
+ position_exponent,
+ scale_start,
+ scale_end_center,
+ scale_width,
+ scale_exponent,
+ })
+ }
+}
+
+fn normalized_window_phase(phase: f32, start: f32, end: f32) -> f32 {
+ let phase = (phase - start) / (end - start);
+ if !(phase >= 0.0) {
+ 0.0
+ } else if phase > 1.0 {
+ 1.0
+ } else {
+ phase
+ }
+}
+
+fn catch_up_count(
+ last_spawn: f32,
+ update_clock: f32,
+ due_interval: f32,
+ schedule_interval: f32,
+) -> Option<usize> {
+ if !last_spawn.is_finite() || !update_clock.is_finite() || !due_interval.is_finite() {
+ return None;
+ }
+ if due_interval <= 0.0 {
+ return None;
+ }
+
+ let mut scheduled = last_spawn;
+ let mut count = 0;
+ loop {
+ let due_at = scheduled + due_interval;
+ if !due_at.is_finite() {
+ return None;
+ }
+ if !(due_at < update_clock) {
+ return Some(count);
+ }
+ if count == MAX_CATCH_UP || !schedule_interval.is_finite() || schedule_interval <= 0.0 {
+ return None;
+ }
+ let next_base = scheduled + schedule_interval;
+ if !next_base.is_finite() || next_base <= scheduled {
+ return None;
+ }
+ scheduled = next_base;
+ count += 1;
+ }
+}
+
+fn sample_emitter_position(params: &Op8Params, state: &mut SmokeEmitterState) -> Option<Op8Track> {
+ // Native 11343..113A4 draws Z from the uniform stream first and centers
+ // it around zero, then draws centered Y and X from the signed-range stream.
+ let mut end = params.emitter_position_end_center;
+ end[2] += state.emitter_rng.next_unit() * params.emitter_position_width[2]
+ - params.emitter_position_width[2] * 0.5;
+ end[1] += state
+ .signed_rng
+ .next_signed(params.emitter_position_width[1]);
+ end[0] += state
+ .signed_rng
+ .next_signed(params.emitter_position_width[0]);
+ end.iter()
+ .all(|value| value.is_finite())
+ .then_some(Op8Track {
+ start: params.emitter_position_start,
+ end,
+ exponent: params.emitter_position_exponent,
+ })
+}
+
+fn apply_emitter_offset(owner: &[[f32; 4]; 4], offset: [f32; 3]) -> Option<[[f32; 4]; 4]> {
+ let mut anchor = *owner;
+ for axis in 0..3 {
+ anchor[axis][3] += offset[axis];
+ }
+ anchor
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ .then_some(anchor)
+}
+
+fn spawn_particle(
+ params: &Op8Params,
+ threshold_range: Op8DetailThresholdRange,
+ born_at: f32,
+ owner_matrix: [[f32; 4]; 4],
+ state: &mut SmokeEmitterState,
+) -> Option<()> {
+ let threshold = state.material_threshold(threshold_range);
+ let random_phase = state.emitter_rng.next_unit();
+ let position_z = params.position_end_center[2]
+ + state.emitter_rng.next_unit() * params.position_width[2]
+ - params.position_width[2] * 0.5;
+ let position_y =
+ params.position_end_center[1] + state.signed_rng.next_signed(params.position_width[1]);
+ let position_x =
+ params.position_end_center[0] + state.signed_rng.next_signed(params.position_width[0]);
+ let scale_z = params.scale_end_center[2] + state.signed_rng.next_signed(params.scale_width[2]);
+ let scale_y = params.scale_end_center[1] + state.signed_rng.next_signed(params.scale_width[1]);
+ let scale_x = params.scale_end_center[0] + state.signed_rng.next_signed(params.scale_width[0]);
+ let position_end = [position_x, position_y, position_z];
+ let scale_end = [scale_x, scale_y, scale_z];
+ if !threshold.is_finite()
+ || !position_end.iter().all(|value| value.is_finite())
+ || !scale_end.iter().all(|value| value.is_finite())
+ || !born_at.is_finite()
+ {
+ return None;
+ }
+ // The native ring increments its cursor before using the slot.
+ state.cursor = (state.cursor + 1) % params.pool_count;
+ state.slots[state.cursor] = Some(ParticleSlot {
+ born_at,
+ threshold,
+ random_phase,
+ owner_matrix,
+ position: Op8Track {
+ start: params.position_start,
+ end: position_end,
+ exponent: params.position_exponent,
+ },
+ scale: Op8Track {
+ start: params.scale_start,
+ end: scale_end,
+ exponent: params.scale_exponent,
+ },
+ });
+ Some(())
+}
+
+fn retire_particle(pool_count: usize, state: &mut SmokeEmitterState) {
+ // Out-of-window ticks follow the same cursor cadence, but mark the slot
+ // reached by that tick inactive instead of emitting a replacement.
+ state.cursor = (state.cursor + 1) % pool_count;
+ state.slots[state.cursor] = None;
+}
+
+fn matrix_translation(matrix: &[[f32; 4]; 4]) -> [f32; 3] {
+ [matrix[0][3], matrix[1][3], matrix[2][3]]
+}
+
+fn owner_anchor_velocity(
+ current: &[[f32; 4]; 4],
+ stored_anchor: [f32; 3],
+ update_clock: f32,
+ schedule_base: f32,
+) -> Option<[f32; 3]> {
+ let elapsed = update_clock - schedule_base;
+ if !elapsed.is_finite() || elapsed <= 0.0 {
+ return None;
+ }
+ let velocity = std::array::from_fn(|axis| (current[axis][3] - stored_anchor[axis]) / elapsed);
+ velocity
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some(velocity)
+}
+
+fn advance_owner_anchor(
+ current: &[[f32; 4]; 4],
+ stored_anchor: [f32; 3],
+ velocity: [f32; 3],
+ due_interval: f32,
+) -> Option<([[f32; 4]; 4], [f32; 3])> {
+ let mut velocity = velocity;
+ if vector_length_squared(velocity)? == 0.0 {
+ // Native replaces the zero velocity vector with the current owner's X
+ // column before both stepping the anchor and building the slot frame.
+ velocity = [current[0][0], current[1][0], current[2][0]];
+ }
+ let translation =
+ std::array::from_fn(|axis| stored_anchor[axis] + velocity[axis] * due_interval);
+ let tangent = normalize_vector(velocity)?;
+
+ let owner_up = [current[0][2], current[1][2], current[2][2]];
+ let side_candidate = cross(owner_up, tangent);
+ let side = if vector_length_squared(side_candidate)? == 0.0 {
+ native_fallback_side(tangent)?
+ } else {
+ normalize_vector(side_candidate)?
+ };
+ let up = cross(tangent, side);
+ let matrix = [
+ [tangent[0], side[0], up[0], translation[0]],
+ [tangent[1], side[1], up[1], translation[1]],
+ [tangent[2], side[2], up[2], translation[2]],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+ (matrix.iter().flatten().all(|value| value.is_finite())
+ && translation.iter().all(|value| value.is_finite()))
+ .then_some((matrix, translation))
+}
+
+fn vector_length_squared(vector: [f32; 3]) -> Option<f32> {
+ let length_squared = vector
+ .iter()
+ .map(|component| component * component)
+ .sum::<f32>();
+ length_squared.is_finite().then_some(length_squared)
+}
+
+fn normalize_vector(vector: [f32; 3]) -> Option<[f32; 3]> {
+ let length_squared = vector_length_squared(vector)?;
+ if length_squared == 0.0 {
+ return None;
+ }
+ let inverse_length = length_squared.sqrt().recip();
+ let normalized = vector.map(|component| component * inverse_length);
+ normalized
+ .iter()
+ .all(|component| component.is_finite())
+ .then_some(normalized)
+}
+
+fn native_fallback_side(tangent: [f32; 3]) -> Option<[f32; 3]> {
+ // Match 38D0: choose X when tangent X is zero, otherwise Y when tangent
+ // Y is zero, otherwise use (-Y, X, 0) and normalize only when Z is nonzero.
+ let mut side = if tangent[0] == 0.0 {
+ [1.0, 0.0, 0.0]
+ } else if tangent[1] == 0.0 {
+ [0.0, 1.0, 0.0]
+ } else {
+ [-tangent[1], tangent[0], 0.0]
+ };
+ if tangent[0] != 0.0 && tangent[1] != 0.0 && tangent[2] != 0.0 {
+ side = normalize_vector(side)?;
+ }
+ Some(side)
+}
+
+fn cross(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [
+ left[1] * right[2] - left[2] * right[1],
+ left[2] * right[0] - left[0] * right[2],
+ left[0] * right[1] - left[1] * right[0],
+ ]
+}
+
+fn sample_track(track: Op8Track, phase: f32) -> Option<[f32; 3]> {
+ let sample = std::array::from_fn(|axis| {
+ track.start[axis] + (track.end[axis] - track.start[axis]) * phase.powf(track.exponent[axis])
+ });
+ sample
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some(sample)
+}
+
+fn empty_frame(command_index: usize, resource: &EffectResource) -> EffectQuadFrame {
+ EffectQuadFrame {
+ command_index,
+ opcode: FxOpcode::Op8,
+ material: resource.clone(),
+ vertices: [[0.0; 3]; 4],
+ uv: EFFECT_QUAD_UV,
+ color: [1.0, 1.0, 1.0, 0.0],
+ material_phase: 0.0,
+ elapsed_seconds: 0.0,
+ normalized_time: 0.0,
+ active: false,
+ native_flag_base: None,
+ }
+}
+
+fn read_resource(body: &[u8]) -> Option<EffectResource> {
+ let bytes = body.get(RESOURCE_OFFSET..RESOURCE_OFFSET + RESOURCE_BYTES)?;
+ Some(EffectResource {
+ archive: read_name(&bytes[..32])?,
+ name: read_name(&bytes[32..])?,
+ })
+}
+
+fn read_name(bytes: &[u8]) -> Option<String> {
+ let end = bytes
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(bytes.len());
+ let name = std::str::from_utf8(&bytes[..end]).ok()?.trim();
+ (!name.is_empty()).then(|| name.to_owned())
+}
+
+fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
+ Some(u32::from_le_bytes(
+ bytes.get(offset..offset + 4)?.try_into().ok()?,
+ ))
+}
+
+fn read_f32(bytes: &[u8], offset: usize) -> Option<f32> {
+ Some(f32::from_le_bytes(
+ bytes.get(offset..offset + 4)?.try_into().ok()?,
+ ))
+}
+
+fn read_vec3(bytes: &[u8], offset: usize) -> Option<[f32; 3]> {
+ Some([
+ read_f32(bytes, offset)?,
+ read_f32(bytes, offset + 4)?,
+ read_f32(bytes, offset + 8)?,
+ ])
+}
+
+fn subtract3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ std::array::from_fn(|axis| left[axis] - right[axis])
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ detail_class_passes_threshold, evaluate_op8_emitter, Op8DetailThresholdRange,
+ SmokeEmitterState,
+ };
+ use crate::{fx_visual::EffectQuadFrame, FxCommand, FxOpcode};
+ use std::sync::Arc;
+
+ const IDENTITY: [[f32; 4]; 4] = [
+ [1.0, 0.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+
+ fn command(resource: &str, pool_count: u32, native_flag: bool) -> FxCommand {
+ let mut body = vec![0_u8; 0xF4];
+ put_f32(&mut body, 0x00, 1.0);
+ put_f32(&mut body, 0x04, 0.0);
+ put_f32(&mut body, 0x08, 1.0);
+ put_f32(&mut body, 0x0C, 0.0);
+ put_f32(&mut body, 0x10, 1.0);
+ put_f32(&mut body, 0x14, 0.08);
+ put_f32(&mut body, 0x18, 0.08);
+ put_f32(&mut body, 0x1C, 1.0);
+ put_u32(&mut body, 0x20, pool_count);
+ put_vec3(&mut body, 0x24, [0.0; 3]);
+ put_vec3(&mut body, 0x30, [0.0; 3]);
+ put_vec3(&mut body, 0x3C, [0.0; 3]);
+ put_vec3(&mut body, 0x48, [1.0; 3]);
+ put_vec3(&mut body, 0x54, [0.0; 3]);
+ put_vec3(&mut body, 0x60, [0.0, 0.0, 50.0]);
+ put_vec3(&mut body, 0x6C, [10.0, 30.0, 30.0]);
+ put_vec3(&mut body, 0x78, [1.0; 3]);
+ put_vec3(&mut body, 0x84, [10.0; 3]);
+ put_vec3(&mut body, 0x90, [30.0; 3]);
+ put_vec3(&mut body, 0x9C, [0.0, 1.0, 1.0]);
+ put_vec3(&mut body, 0xA8, [1.0; 3]);
+ put_name(&mut body, 0xB4, "material.lib");
+ put_name(&mut body, 0xD4, resource);
+ FxCommand {
+ word: if native_flag { 0x108 } else { 8 },
+ opcode: FxOpcode::Op8,
+ native_flag,
+ raw_body: Arc::from(body),
+ resource_refs: Vec::new(),
+ }
+ }
+
+ fn evaluate(
+ command: &FxCommand,
+ clock: f32,
+ phase: f32,
+ detail_class: u32,
+ state: &mut SmokeEmitterState,
+ ) -> Vec<EffectQuadFrame> {
+ evaluate_with_owner(command, clock, phase, detail_class, &IDENTITY, state)
+ }
+
+ fn evaluate_with_owner(
+ command: &FxCommand,
+ clock: f32,
+ phase: f32,
+ detail_class: u32,
+ owner: &[[f32; 4]; 4],
+ state: &mut SmokeEmitterState,
+ ) -> Vec<EffectQuadFrame> {
+ evaluate_op8_emitter(
+ command,
+ 0,
+ owner,
+ [0.0, 0.0, 500.0],
+ clock,
+ phase,
+ detail_class,
+ Op8DetailThresholdRange::new(1.0, 1.0).unwrap(),
+ state,
+ )
+ .unwrap()
+ }
+
+ fn put_u32(body: &mut [u8], offset: usize, value: u32) {
+ body[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ }
+
+ fn put_f32(body: &mut [u8], offset: usize, value: f32) {
+ body[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ }
+
+ fn put_vec3(body: &mut [u8], offset: usize, value: [f32; 3]) {
+ for (axis, component) in value.into_iter().enumerate() {
+ put_f32(body, offset + axis * 4, component);
+ }
+ }
+
+ fn put_name(body: &mut [u8], offset: usize, value: &str) {
+ body[offset..offset + value.len()].copy_from_slice(value.as_bytes());
+ }
+
+ fn centroid(frame: &EffectQuadFrame) -> [f32; 3] {
+ std::array::from_fn(|axis| {
+ frame
+ .vertices
+ .iter()
+ .map(|vertex| vertex[axis])
+ .sum::<f32>()
+ / 4.0
+ })
+ }
+
+ fn distance(left: [f32; 3], right: [f32; 3]) -> f32 {
+ let delta = std::array::from_fn::<_, 3, _>(|axis| left[axis] - right[axis]);
+ (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt()
+ }
+
+ #[test]
+ fn first_update_strict_interval_and_catch_up_match_native_schedule() {
+ let command = command("fire_smoke", 30, false);
+ let mut state = SmokeEmitterState::new(13, 13, 13);
+ assert!(evaluate(&command, 0.0, 0.0, 0, &mut state)
+ .iter()
+ .all(|frame| !frame.active));
+ assert_eq!(state.next_slot(), 0);
+ assert_eq!(state.last_spawn, 0.0);
+ assert_eq!(
+ evaluate(&command, 0.08, 0.0, 0, &mut state)
+ .iter()
+ .filter(|frame| frame.active)
+ .count(),
+ 0
+ );
+ assert_eq!(state.last_spawn, 0.08);
+ assert_eq!(
+ evaluate(&command, 0.16, 0.0, 0, &mut state)
+ .iter()
+ .filter(|frame| frame.active)
+ .count(),
+ 0
+ );
+ assert_eq!(
+ evaluate(&command, 0.160_001, 0.0, 0, &mut state)
+ .iter()
+ .filter(|frame| frame.active)
+ .count(),
+ 1
+ );
+ assert_eq!(
+ evaluate(&command, 0.320_001, 0.0, 0, &mut state)
+ .iter()
+ .filter(|frame| frame.active)
+ .count(),
+ 3
+ );
+ assert_eq!(state.next_slot(), 3);
+ }
+
+ #[test]
+ fn b_sphere_start_uses_normalized_due_interval_and_raw_schedule_step() {
+ let mut command = command("B_Sphere_Start", 6, true);
+ let body = Arc::make_mut(&mut command.raw_body);
+ // These values are from effects.rlb / B_Sphere_Start command 14.
+ put_f32(body, 0x0C, 0.269);
+ put_f32(body, 0x10, 0.885);
+ put_f32(body, 0x14, 0.05);
+ put_f32(body, 0x18, 0.01);
+ let mut state = SmokeEmitterState::new(17, 29, 43);
+
+ evaluate(&command, 0.01, 0.5, 0, &mut state);
+ evaluate(&command, 0.09, 0.5, 0, &mut state);
+
+ // At phase .5, native normalizes to .375 and tests events at .035,
+ // then .065 relative to the captured .01 base. After each event it
+ // advances the schedule base by the raw-phase interval .03.
+ assert!((state.last_spawn - 0.07).abs() < 1e-6);
+ assert_eq!(state.next_slot(), 2);
+ assert!((state.slots[1].as_ref().unwrap().born_at - 0.045).abs() < 1e-6);
+ assert!((state.slots[2].as_ref().unwrap().born_at - 0.075).abs() < 1e-6);
+ }
+
+ #[test]
+ fn equal_window_phase_matches_native_unordered_and_clamp_comparisons() {
+ assert_eq!(super::normalized_window_phase(0.269, 0.269, 0.269), 0.0);
+ assert_eq!(super::normalized_window_phase(0.268, 0.269, 0.269), 0.0);
+ assert_eq!(super::normalized_window_phase(0.270, 0.269, 0.269), 1.0);
+ }
+
+ #[test]
+ fn clock_rewind_clamps_only_schedule_base() {
+ let command = command("smoke_yg", 6, true);
+ let mut state = SmokeEmitterState::new(17, 29, 43);
+ evaluate(&command, 0.01, 0.5, 0, &mut state);
+ evaluate(&command, 0.090_001, 0.5, 0, &mut state);
+
+ let cursor = state.cursor;
+ let born_at = state.slots[1].as_ref().unwrap().born_at;
+ let emitter_rng = state.emitter_rng;
+ let signed_rng = state.signed_rng;
+ let material_rng = state.material_rng;
+ assert!((state.last_spawn - 0.09).abs() < 1e-6);
+
+ evaluate(&command, 0.04, 0.5, 0, &mut state);
+
+ assert_eq!(state.last_spawn, 0.04);
+ assert_eq!(state.cursor, cursor);
+ assert_eq!(state.slots[1].as_ref().unwrap().born_at, born_at);
+ assert_eq!(state.emitter_rng, emitter_rng);
+ assert_eq!(state.signed_rng, signed_rng);
+ assert_eq!(state.material_rng, material_rng);
+ }
+
+ #[test]
+ fn catch_up_requires_advancing_base_and_honors_validation_bound() {
+ assert_eq!(super::catch_up_count(0.0, 1.0, 0.5, 0.25), Some(2));
+ assert!(super::catch_up_count(0.0, 200_001.0, 1.0, 1.0).is_none());
+ assert!(super::catch_up_count(16_777_216.0, 16_777_220.0, 0.01, 0.01).is_none());
+ }
+
+ #[test]
+ fn moving_owner_advances_from_the_last_emitted_anchor() {
+ let command = command("smoke_yg", 6, true);
+ let owner_start = IDENTITY;
+ let mut owner_end = [
+ [0.0, -1.0, 0.0, 0.0],
+ [1.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+ let mut owner_later = IDENTITY;
+ owner_end[0][3] = 10.0;
+ owner_later[0][3] = 20.0;
+ let mut state = SmokeEmitterState::new(17, 17, 17);
+
+ evaluate_with_owner(&command, 0.01, 0.0, 0, &owner_start, &mut state);
+ evaluate_with_owner(&command, 0.05, 0.5, 0, &owner_end, &mut state);
+ assert_eq!(state.stored_anchor.unwrap(), [0.0, 0.0, 0.0]);
+
+ // No scheduled event has happened, so the stored anchor stays at the
+ // initialization pose. Native divides the full displacement by the
+ // time from that anchor's schedule base when an event becomes due.
+ evaluate_with_owner(&command, 0.110_001, 0.5, 0, &owner_end, &mut state);
+ let slot = state.slots[1].as_ref().unwrap();
+ assert!((slot.owner_matrix[0][3] - 8.0).abs() < 1e-4);
+ assert_eq!(
+ state.stored_anchor.unwrap(),
+ [slot.owner_matrix[0][3], 0.0, 0.0]
+ );
+ // The native slot frame uses motion direction and current owner Z.
+ // The yawed owner with X-directed motion yields an identity basis.
+ assert_eq!(
+ slot.owner_matrix[0],
+ [1.0, 0.0, 0.0, slot.owner_matrix[0][3]]
+ );
+ assert_eq!(slot.owner_matrix[1], [0.0, 1.0, 0.0, 0.0]);
+ assert_eq!(slot.owner_matrix[2], [0.0, 0.0, 1.0, 0.0]);
+
+ let frames = evaluate_with_owner(&command, 0.12, 0.6, 0, &owner_later, &mut state);
+ let slot = state.slots[1].as_ref().unwrap();
+ let local_position = super::sample_track(slot.position, frames[1].normalized_time).unwrap();
+ // A no-event update leaves the previously emitted anchor unchanged.
+ assert!((slot.owner_matrix[0][3] - 8.0).abs() < 1e-4);
+ assert!((state.stored_anchor.unwrap()[0] - 8.0).abs() < 1e-4);
+ let center_x = centroid(&frames[1])[0];
+ assert!((center_x - (slot.owner_matrix[0][3] + local_position[0])).abs() < 1e-4);
+ assert!((center_x - (owner_later[0][3] + local_position[0])).abs() > 10.0);
+
+ let frames = evaluate_with_owner(&command, 0.250_001, 0.7, 0, &owner_later, &mut state);
+ assert!((state.slots[2].as_ref().unwrap().born_at - 0.17).abs() < 1e-6);
+ assert!((state.slots[3].as_ref().unwrap().born_at - 0.25).abs() < 1e-6);
+ // Both catch-up particles use the single velocity computed from the
+ // stored X=8 anchor and the original schedule base, stepping by .08.
+ assert!((state.slots[2].as_ref().unwrap().owner_matrix[0][3] - 14.0).abs() < 1e-3);
+ assert!((state.slots[3].as_ref().unwrap().owner_matrix[0][3] - 20.0).abs() < 1e-3);
+ assert!(frames[2].active && frames[3].active);
+ }
+
+ #[test]
+ fn moving_owner_uses_native_zero_motion_and_parallel_axis_fallbacks() {
+ let command = command("smoke_yg", 6, true);
+ let previous = IDENTITY;
+ let mut current = IDENTITY;
+ // Tiny nonzero movement must remain a velocity, not take the exact
+ // zero-motion branch. A parallel owner-up axis then exercises 38D0.
+ current[0][3] = 0.000_01;
+ current[0][2] = 1.0;
+ current[2][2] = 0.0;
+ current[0][0] = 0.0;
+ current[1][0] = 1.0;
+ current[2][0] = 0.0;
+ let mut state = SmokeEmitterState::new(19, 19, 19);
+ evaluate_with_owner(&command, 0.01, 0.0, 0, &previous, &mut state);
+ evaluate_with_owner(&command, 0.090_001, 0.5, 0, &current, &mut state);
+ let matrix = state.slots[1].as_ref().unwrap().owner_matrix;
+ assert_eq!([matrix[0][0], matrix[1][0], matrix[2][0]], [1.0, 0.0, 0.0]);
+ assert_eq!([matrix[0][1], matrix[1][1], matrix[2][1]], [0.0, 1.0, 0.0]);
+ assert_eq!([matrix[0][2], matrix[1][2], matrix[2][2]], [0.0, 0.0, 1.0]);
+
+ // Exactly zero movement uses the current owner X column, matching the
+ // separate 11B83 branch before the 38D0 parallel-axis fallback.
+ let mut static_owner = IDENTITY;
+ static_owner[0][0] = 0.0;
+ static_owner[1][0] = 1.0;
+ static_owner[2][0] = 0.0;
+ let mut stationary = SmokeEmitterState::new(23, 23, 23);
+ evaluate_with_owner(&command, 0.01, 0.0, 0, &previous, &mut stationary);
+ evaluate_with_owner(&command, 0.090_001, 0.5, 0, &static_owner, &mut stationary);
+ let matrix = stationary.slots[1].as_ref().unwrap().owner_matrix;
+ assert_eq!([matrix[0][0], matrix[1][0], matrix[2][0]], [0.0, 1.0, 0.0]);
+ assert_eq!([matrix[0][1], matrix[1][1], matrix[2][1]], [-1.0, 0.0, 0.0]);
+ assert_eq!([matrix[0][2], matrix[1][2], matrix[2][2]], [0.0, 0.0, 1.0]);
+ assert!((matrix[1][3] - 0.08).abs() < 1e-6);
+ assert_eq!(stationary.stored_anchor.unwrap(), [0.0, 0.08, 0.0]);
+ }
+
+ #[test]
+ fn particles_drain_through_the_ring_and_keep_fading_after_the_window() {
+ let mut command = command("smoke_yg", 6, true);
+ put_f32(Arc::make_mut(&mut command.raw_body), 0x10, 0.5);
+ let mut state = SmokeEmitterState::new(31, 31, 31);
+
+ evaluate(&command, 0.01, 0.25, 0, &mut state);
+ assert_eq!(
+ evaluate(&command, 0.090_001, 0.25, 0, &mut state)
+ .iter()
+ .filter(|frame| frame.active)
+ .count(),
+ 1
+ );
+
+ let tail = evaluate(&command, 0.170_001, 0.75, 0, &mut state);
+ assert_eq!(tail.iter().filter(|frame| frame.active).count(), 1);
+ assert!(tail[1].color[3] > 0.0 && tail[1].color[3] < 1.0);
+
+ // Out-of-window events still advance the native ring. This particle
+ // remains alive until the cursor reaches its slot on the sixth tick.
+ let before_wrap = evaluate(&command, 0.490_001, 0.75, 0, &mut state);
+ assert_eq!(before_wrap.iter().filter(|frame| frame.active).count(), 1);
+ assert!(before_wrap[1].active);
+ assert!(before_wrap[1].color[3] < tail[1].color[3]);
+
+ let drained = evaluate(&command, 0.570_001, 0.75, 0, &mut state);
+ assert!(drained.iter().all(|frame| !frame.active));
+ assert!(state.slots.iter().all(Option::is_none));
+ }
+
+ #[test]
+ fn generic_six_slot_smoke_yg_body_samples_tracks_opacity_and_phase() {
+ let mut command = command("smoke_yg", 6, true);
+ let body = Arc::make_mut(&mut command.raw_body);
+ put_f32(body, 0x00, 0.25);
+ put_f32(body, 0x04, 0.75);
+ put_f32(body, 0x08, 2.0);
+ put_f32(body, 0x1C, -1.0);
+ put_f32(body, 0x14, 0.08);
+ put_f32(body, 0x18, 0.08);
+ put_f32(body, 0x44, 0.0);
+ put_vec3(body, 0x48, [1.0; 3]);
+ put_vec3(body, 0x24, [0.0, 0.0, 3.0]);
+ put_vec3(body, 0x30, [0.0, 0.0, 3.0]);
+ put_vec3(body, 0x54, [0.0, 0.0, 3.0]);
+ put_vec3(body, 0x60, [0.0, 0.0, 13.0]);
+ put_vec3(body, 0x6C, [0.0, 0.0, 6.0]);
+ put_vec3(body, 0x78, [1.0, 1.0, 0.5]);
+ put_vec3(body, 0x84, [0.5, 0.75, 1.0]);
+ put_vec3(body, 0x90, [1.5, 1.75, 2.0]);
+ put_vec3(body, 0x9C, [0.0; 3]);
+ put_vec3(body, 0xA8, [1.0, 2.0, 3.0]);
+
+ let mut state = SmokeEmitterState::new(17, 29, 43);
+ let first = evaluate(&command, 0.01, 0.0, 1, &mut state);
+ assert_eq!(first.len(), 6);
+ assert!(first.iter().all(|frame| !frame.active));
+ let frames = evaluate(&command, 0.090_001, 0.5, 1, &mut state);
+ let frame = &frames[1];
+ let particle = state.slots[1].as_ref().unwrap();
+ let phase: f32 = (0.090_001 - 0.09) / (0.08 * 6.0);
+ let position: [f32; 3] = std::array::from_fn(|axis| {
+ particle.position.start[axis]
+ + (particle.position.end[axis] - particle.position.start[axis])
+ * phase.powf(particle.position.exponent[axis])
+ });
+ let scale: [f32; 3] = std::array::from_fn(|axis| {
+ particle.scale.start[axis]
+ + (particle.scale.end[axis] - particle.scale.start[axis])
+ * phase.powf(particle.scale.exponent[axis])
+ });
+ let expected_center =
+ crate::fx_visual::transform_native_effect_point(&particle.owner_matrix, position)
+ .unwrap();
+ let expected_opacity = 0.25 + (0.75 - 0.25) * phase.powf(2.0);
+ let camera_local =
+ crate::fx_visual::native_effect_local_point(&particle.owner_matrix, [0.0, 0.0, 500.0])
+ .unwrap();
+ let view = std::array::from_fn(|axis| camera_local[axis] - position[axis]);
+ let local_basis = super::native_camera_facing_basis(view, position).unwrap();
+ let expected_edge_length = |basis_column| {
+ let edge: [f32; 3] = std::array::from_fn(|row| {
+ (0..3)
+ .map(|axis| {
+ particle.owner_matrix[row][axis]
+ * scale[axis]
+ * local_basis[axis][basis_column]
+ })
+ .sum::<f32>()
+ });
+ (edge[0] * edge[0] + edge[1] * edge[1] + edge[2] * edge[2]).sqrt()
+ };
+
+ assert!(frame.active);
+ assert_eq!(frame.material.name, "smoke_yg");
+ assert!((frame.normalized_time - phase).abs() < 1e-6);
+ assert!((frame.material_phase - particle.random_phase).abs() < 1e-6);
+ assert!((frame.color[3] - expected_opacity).abs() < 1e-6);
+ for (actual, expected) in centroid(frame).into_iter().zip(expected_center) {
+ assert!(
+ (actual - expected).abs() < 1e-4,
+ "actual={actual}, expected={expected}"
+ );
+ }
+ assert!(
+ (distance(frame.vertices[0], frame.vertices[3]) - expected_edge_length(0)).abs() < 1e-4
+ );
+ assert!(
+ (distance(frame.vertices[0], frame.vertices[1]) - expected_edge_length(1)).abs() < 1e-4
+ );
+ }
+
+ #[test]
+ fn mission02_breath_anl_03_op8_fixture_uses_shared_body_fields() {
+ let mut command = command("smoke_yg", 6, true);
+ let body = Arc::make_mut(&mut command.raw_body);
+ put_f32(body, 0x44, 0.0);
+ put_vec3(body, 0x54, [0.0, 0.0, 1.0]);
+ put_vec3(body, 0x60, [0.0, 0.0, 3.0]);
+ put_vec3(body, 0x6C, [3.0, 2.0, 1.0]);
+ put_vec3(body, 0x78, [1.0; 3]);
+ put_vec3(body, 0x84, [4.0; 3]);
+ put_vec3(body, 0x90, [5.0; 3]);
+ put_vec3(body, 0x9C, [1.0; 3]);
+ put_vec3(body, 0xA8, [1.0; 3]);
+
+ let mut state = SmokeEmitterState::new(17, 29, 43);
+ evaluate(&command, 0.01, 0.0, 1, &mut state);
+ let frames = evaluate(&command, 0.090_001, 0.5, 1, &mut state);
+ let frame = &frames[1];
+ let particle = state.slots[1].as_ref().unwrap();
+ let phase: f32 = (0.090_001 - 0.09) / (0.08 * 6.0);
+ let position = super::sample_track(particle.position, phase).unwrap();
+
+ assert!(frame.active);
+ assert_eq!(frame.material.name, "smoke_yg");
+ assert!((frame.normalized_time - phase).abs() < 1e-6);
+ assert!((frame.material_phase - phase).abs() < 1e-6);
+ assert!((frame.color[3] - (1.0 - phase)).abs() < 1e-6);
+ let expected_center =
+ crate::fx_visual::transform_native_effect_point(&particle.owner_matrix, position)
+ .unwrap();
+ for (actual, expected) in centroid(frame).into_iter().zip(expected_center) {
+ assert!((actual - expected).abs() < 1e-4);
+ }
+ }
+
+ #[test]
+ fn generic_body_uses_dynamic_resource_pool_and_wraps_ring() {
+ let command = command("cloud_B2_ADD", 6, false);
+ let mut left = SmokeEmitterState::new(77, 77, 77);
+ let mut right = SmokeEmitterState::new(77, 77, 77);
+ evaluate(&command, 0.01, 0.0, 0, &mut left);
+ evaluate(&command, 0.01, 0.0, 0, &mut right);
+ let left_frames = evaluate(&command, 0.570_001, 0.0, 0, &mut left);
+ let right_frames = evaluate(&command, 0.570_001, 0.0, 0, &mut right);
+ assert_eq!(left.pool_len(), 6);
+ assert_eq!(left.next_slot(), 1);
+ assert_eq!(left_frames, right_frames);
+ assert_eq!(left_frames[0].material.name, "cloud_B2_ADD");
+ }
+
+ #[test]
+ fn emitter_position_track_moves_birth_anchor_from_start_to_randomized_end() {
+ let mut command = command("smoke_yg", 6, true);
+ let body = Arc::make_mut(&mut command.raw_body);
+ put_vec3(body, 0x24, [1.0, 2.0, 3.0]);
+ put_vec3(body, 0x30, [11.0, 22.0, 33.0]);
+ put_vec3(body, 0x3C, [4.0, 6.0, 8.0]);
+ put_vec3(body, 0x48, [1.0, 2.0, 3.0]);
+
+ let uniform_seed = 0x0001_0000;
+ let signed_seed = 0x0002_0000;
+ let mut state = SmokeEmitterState::new(uniform_seed, signed_seed, 1);
+ evaluate(&command, 0.01, 0.0, 0, &mut state);
+ let track = state.emitter_position.unwrap();
+ let signed = |word: f32, width: f32| (word / 65_536.0 - 0.5) * width;
+ let expected_end = [
+ 11.0 + signed(6.0, 4.0),
+ 22.0 + signed(3.0, 6.0),
+ 33.0 + 1.0 / 65_536.0 * 8.0 - 4.0,
+ ];
+ assert_eq!(track.start, [1.0, 2.0, 3.0]);
+ for (actual, expected) in track.end.into_iter().zip(expected_end) {
+ assert!((actual - expected).abs() < 1e-6);
+ }
+ let mut expected_uniform_rng = super::NativeLfsr16Pair::from_word(uniform_seed);
+ let mut expected_signed_rng = super::NativeLfsr16Pair::from_word(signed_seed);
+ expected_uniform_rng.next_unit();
+ expected_signed_rng.next_signed(6.0);
+ expected_signed_rng.next_signed(4.0);
+ assert_eq!(state.emitter_rng, expected_uniform_rng);
+ assert_eq!(state.signed_rng, expected_signed_rng);
+
+ // The midpoint uses each component's own exponent, and the sampled
+ // offset is added to the owner translation before the slot is born.
+ let midpoint = super::sample_track(track, 0.5).unwrap();
+ let mut state = SmokeEmitterState::new(uniform_seed, signed_seed, 1);
+ evaluate(&command, 0.01, 0.0, 0, &mut state);
+ let mut owner = IDENTITY;
+ owner[0][3] = 5.0;
+ let frames = evaluate_with_owner(&command, 0.090_001, 0.5, 0, &owner, &mut state);
+ let particle = state.slots[1].as_ref().unwrap();
+ let expected_position_z = 50.0 + 4.0 / 65_536.0 * 30.0 - 15.0;
+ assert!((particle.position.end[2] - expected_position_z).abs() < 1e-6);
+ // The spawn is almost at the current update, whose owner anchor is
+ // the trajectory sample at phase 0.5.
+ for axis in 0..3 {
+ assert!(
+ (particle.owner_matrix[axis][3] - (owner[axis][3] + midpoint[axis])).abs() < 1e-3
+ );
+ }
+ let local_position =
+ super::sample_track(particle.position, frames[1].normalized_time).unwrap();
+ let expected_center =
+ crate::fx_visual::transform_native_effect_point(&particle.owner_matrix, local_position)
+ .unwrap();
+ for (actual, expected) in centroid(&frames[1]).into_iter().zip(expected_center) {
+ assert!((actual - expected).abs() < 1e-3);
+ }
+ }
+
+ #[test]
+ fn detail_gate_is_inclusive_and_rejects_the_next_float() {
+ assert!(detail_class_passes_threshold(3.0, 3.0));
+ let next_float = f32::from_bits(3.0_f32.to_bits() + 1);
+ assert!(!detail_class_passes_threshold(next_float, 3.0));
+ assert!(detail_class_passes_threshold(0.0, 0.0));
+ }
+
+ #[test]
+ fn rejects_malformed_or_oversized_op8_bodies_before_state_changes() {
+ let command = FxCommand {
+ word: 8,
+ opcode: FxOpcode::Op8,
+ native_flag: false,
+ raw_body: Arc::from(vec![0_u8; 32]),
+ resource_refs: Vec::new(),
+ };
+ let mut state = SmokeEmitterState::new(13, 13, 13);
+ assert!(evaluate_op8_emitter(
+ &command,
+ 0,
+ &IDENTITY,
+ [0.0, 0.0, 500.0],
+ 1.0,
+ 0.0,
+ 0,
+ Op8DetailThresholdRange::new(0.0, 3.0).unwrap(),
+ &mut state,
+ )
+ .is_none());
+ assert_eq!(state.pool_len(), 0);
+ assert_eq!(state.next_slot(), 0);
+ }
+}
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/contour.rs b/crates/fparkan-terrain/src/contour.rs
new file mode 100644
index 0000000..de5077a
--- /dev/null
+++ b/crates/fparkan-terrain/src/contour.rs
@@ -0,0 +1,809 @@
+/// One triangle fragment retained after subtracting a building contour.
+/// `source_triangle` indexes the triangle array passed to
+/// [`subtract_contour`].
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub(crate) struct TerrainTriangleFragment {
+ /// Source triangle index, for preserving its material and vertex channels.
+ pub source_triangle: usize,
+ /// Fragment triangle vertices in the input coordinate space.
+ pub positions: [[f32; 3]; 3],
+}
+
+/// Error returned when a terrain contour cannot be subtracted safely.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) enum TerrainContourError {
+ /// The contour is non-finite, degenerate, or self-intersecting.
+ UnsupportedContour,
+ /// A source triangle has non-finite coordinates or a degenerate XY plane.
+ InvalidSourceTriangle {
+ /// Index of the source triangle.
+ triangle: usize,
+ },
+}
+
+impl std::fmt::Display for TerrainContourError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::UnsupportedContour => write!(
+ f,
+ "terrain contour must be finite, simple, and non-degenerate"
+ ),
+ Self::InvalidSourceTriangle { triangle } => {
+ write!(
+ f,
+ "terrain source triangle {triangle} is not a finite XY surface"
+ )
+ }
+ }
+ }
+}
+
+impl std::error::Error for TerrainContourError {}
+
+/// Subtracts a finite simple contour using the native per-source-face point set:
+/// the three source vertices plus the actual contour segments clipped to that
+/// source triangle. It does not extend cuts across the triangle or triangulate
+/// the contour into unrelated internal edges.
+///
+/// # Errors
+///
+/// Returns [`TerrainContourError::UnsupportedContour`] for malformed or
+/// self-intersecting contours, and
+/// [`TerrainContourError::InvalidSourceTriangle`] when a source face cannot be
+/// triangulated or lifted onto its original plane.
+pub(crate) fn subtract_contour(
+ triangles: &[[[f32; 3]; 3]],
+ contour: &[[f32; 2]],
+) -> Result<Vec<TerrainTriangleFragment>, TerrainContourError> {
+ let ring = normalize_ring(contour)?;
+ let contour_min: [f64; 2] = std::array::from_fn(|axis| {
+ ring.iter()
+ .map(|point| point[axis])
+ .fold(f64::INFINITY, f64::min)
+ });
+ let contour_max: [f64; 2] = std::array::from_fn(|axis| {
+ ring.iter()
+ .map(|point| point[axis])
+ .fold(f64::NEG_INFINITY, f64::max)
+ });
+ let mut output = Vec::new();
+
+ for (source_triangle, source) in triangles.iter().copied().enumerate() {
+ if !source.iter().flatten().all(|value| value.is_finite()) {
+ return Err(TerrainContourError::InvalidSourceTriangle {
+ triangle: source_triangle,
+ });
+ }
+ let source_xy = source.map(|point| [f64::from(point[0]), f64::from(point[1])]);
+ let source_area = cross2(source_xy[0], source_xy[1], source_xy[2]);
+ if source_area.abs() <= 1.0e-10 {
+ return Err(TerrainContourError::InvalidSourceTriangle {
+ triangle: source_triangle,
+ });
+ }
+ let source_min: [f64; 2] = std::array::from_fn(|axis| {
+ source_xy
+ .iter()
+ .map(|point| point[axis])
+ .fold(f64::INFINITY, f64::min)
+ });
+ let source_max: [f64; 2] = std::array::from_fn(|axis| {
+ source_xy
+ .iter()
+ .map(|point| point[axis])
+ .fold(f64::NEG_INFINITY, f64::max)
+ });
+ if (0..2).any(|axis| {
+ source_max[axis] < contour_min[axis] || contour_max[axis] < source_min[axis]
+ }) {
+ output.push(TerrainTriangleFragment {
+ source_triangle,
+ positions: source,
+ });
+ continue;
+ }
+
+ let mut vertices = source_xy.to_vec();
+ let initial = if source_area > 0.0 {
+ [0, 1, 2]
+ } else {
+ [0, 2, 1]
+ };
+ let mut triangulation = vec![initial];
+ let mut clipped_segments = Vec::new();
+ for index in 0..ring.len() {
+ let first = ring[index];
+ let second = ring[(index + 1) % ring.len()];
+ if let Some((start, end)) = clip_segment_to_triangle(first, second, source_xy) {
+ let start_id = insert_unique_point(&mut vertices, start);
+ let end_id = insert_unique_point(&mut vertices, end);
+ if start_id != end_id {
+ clipped_segments.push((start_id, end_id));
+ }
+ }
+ }
+
+ if clipped_segments.is_empty() {
+ let center = [
+ (source_xy[0][0] + source_xy[1][0] + source_xy[2][0]) / 3.0,
+ (source_xy[0][1] + source_xy[1][1] + source_xy[2][1]) / 3.0,
+ ];
+ if !point_in_ring(center, &ring) {
+ output.push(TerrainTriangleFragment {
+ source_triangle,
+ positions: source,
+ });
+ }
+ continue;
+ }
+
+ for point_id in 3..vertices.len() {
+ insert_triangulation_point(point_id, &vertices, &mut triangulation).map_err(|()| {
+ TerrainContourError::InvalidSourceTriangle {
+ triangle: source_triangle,
+ }
+ })?;
+ }
+
+ let mut constraints = source_boundary_constraints(&vertices, source_xy);
+ for (first, second) in clipped_segments {
+ let mut on_segment = vertices
+ .iter()
+ .copied()
+ .enumerate()
+ .filter_map(|(index, point)| {
+ point_on_segment(point, vertices[first], vertices[second]).then_some((
+ segment_parameter(point, vertices[first], vertices[second]),
+ index,
+ ))
+ })
+ .collect::<Vec<_>>();
+ on_segment.sort_by(|left, right| left.0.total_cmp(&right.0));
+ on_segment.dedup_by_key(|(_, index)| *index);
+ for pair in on_segment.windows(2) {
+ let edge = canonical_edge(pair[0].1, pair[1].1);
+ if edge.0 == edge.1 {
+ continue;
+ }
+ recover_constraint(edge, &mut triangulation, &vertices, &constraints).map_err(
+ |()| TerrainContourError::InvalidSourceTriangle {
+ triangle: source_triangle,
+ },
+ )?;
+ constraints.insert(edge);
+ }
+ }
+ legalize_constrained_delaunay(&mut triangulation, &vertices, &constraints).map_err(
+ |()| TerrainContourError::InvalidSourceTriangle {
+ triangle: source_triangle,
+ },
+ )?;
+
+ for triangle in triangulation {
+ let center = triangle.map(|index| vertices[index]);
+ let center = [
+ (center[0][0] + center[1][0] + center[2][0]) / 3.0,
+ (center[0][1] + center[1][1] + center[2][1]) / 3.0,
+ ];
+ if point_in_ring(center, &ring) {
+ continue;
+ }
+ let mut positions: [[f32; 3]; 3] = triangle
+ .map(|index| lift_to_source_plane(vertices[index], source))
+ .into_iter()
+ .collect::<Option<Vec<_>>>()
+ .and_then(|points| points.try_into().ok())
+ .ok_or(TerrainContourError::InvalidSourceTriangle {
+ triangle: source_triangle,
+ })?;
+ if source_area < 0.0 {
+ positions.swap(1, 2);
+ }
+ output.push(TerrainTriangleFragment {
+ source_triangle,
+ positions,
+ });
+ }
+ }
+ Ok(output)
+}
+
+fn normalize_ring(contour: &[[f32; 2]]) -> Result<Vec<[f64; 2]>, TerrainContourError> {
+ let mut ring = contour
+ .iter()
+ .map(|point| [f64::from(point[0]), f64::from(point[1])])
+ .collect::<Vec<_>>();
+ if ring.iter().flatten().any(|value| !value.is_finite()) {
+ return Err(TerrainContourError::UnsupportedContour);
+ }
+ if ring.len() > 1 && distance_squared(ring[0], *ring.last().expect("nonempty")) <= 1.0e-16 {
+ ring.pop();
+ }
+ let mut normalized = Vec::with_capacity(ring.len());
+ for point in ring {
+ if normalized
+ .last()
+ .is_none_or(|previous| distance_squared(*previous, point) > 1.0e-16)
+ {
+ normalized.push(point);
+ }
+ }
+ if normalized.len() > 1
+ && distance_squared(normalized[0], *normalized.last().expect("nonempty")) <= 1.0e-16
+ {
+ normalized.pop();
+ }
+ if normalized.len() < 3 {
+ return Err(TerrainContourError::UnsupportedContour);
+ }
+ let signed_area = polygon_area(&normalized);
+ if !signed_area.is_finite() || signed_area.abs() <= 1.0e-10 {
+ return Err(TerrainContourError::UnsupportedContour);
+ }
+ for first in 0..normalized.len() {
+ let first_next = (first + 1) % normalized.len();
+ for second in (first + 1)..normalized.len() {
+ let second_next = (second + 1) % normalized.len();
+ if first_next == second || second_next == first {
+ continue;
+ }
+ if segments_intersect(
+ normalized[first],
+ normalized[first_next],
+ normalized[second],
+ normalized[second_next],
+ ) {
+ return Err(TerrainContourError::UnsupportedContour);
+ }
+ }
+ }
+ if signed_area < 0.0 {
+ normalized.reverse();
+ }
+ Ok(normalized)
+}
+
+fn clip_segment_to_triangle(
+ first: [f64; 2],
+ second: [f64; 2],
+ triangle: [[f64; 2]; 3],
+) -> Option<([f64; 2], [f64; 2])> {
+ let mut triangle = triangle;
+ if cross2(triangle[0], triangle[1], triangle[2]) < 0.0 {
+ triangle.swap(1, 2);
+ }
+ let mut start = 0.0_f64;
+ let mut end = 1.0_f64;
+ for edge in 0..3 {
+ let edge_first = triangle[edge];
+ let edge_second = triangle[(edge + 1) % 3];
+ let at_start = cross2(edge_first, edge_second, first);
+ let at_end = cross2(edge_first, edge_second, second);
+ let change = at_end - at_start;
+ if change.abs() <= 1.0e-14 {
+ if at_start < -1.0e-10 {
+ return None;
+ }
+ continue;
+ }
+ let crossing = -at_start / change;
+ if change > 0.0 {
+ start = start.max(crossing);
+ } else {
+ end = end.min(crossing);
+ }
+ if start > end + 1.0e-12 {
+ return None;
+ }
+ }
+ start = start.clamp(0.0, 1.0);
+ end = end.clamp(0.0, 1.0);
+ if end - start <= 1.0e-12 {
+ return None;
+ }
+ Some((
+ interpolate(first, second, start),
+ interpolate(first, second, end),
+ ))
+}
+
+fn insert_unique_point(vertices: &mut Vec<[f64; 2]>, point: [f64; 2]) -> usize {
+ // Terrain positions are f32 throughout the native data and renderer. Make
+ // clipped crossings use that same representable coordinate before building
+ // the local CDT, so distinct f64 intersections cannot collapse to one XY
+ // vertex only after fragment emission.
+ let point = [f64::from(point[0] as f32), f64::from(point[1] as f32)];
+ if let Some(index) = vertices
+ .iter()
+ .position(|candidate| distance_squared(*candidate, point) <= 1.0e-18)
+ {
+ return index;
+ }
+ let index = vertices.len();
+ vertices.push(point);
+ index
+}
+
+fn insert_triangulation_point(
+ point_id: usize,
+ vertices: &[[f64; 2]],
+ triangles: &mut Vec<[usize; 3]>,
+) -> Result<(), ()> {
+ let point = vertices[point_id];
+ let edge = triangulation_edges(triangles)
+ .keys()
+ .copied()
+ .find(|&(first, second)| point_on_open_segment(point, vertices[first], vertices[second]));
+ if let Some(edge) = edge {
+ let mut split = Vec::new();
+ triangles.retain(|triangle| {
+ if triangle_has_edge(*triangle, edge) {
+ let opposite = triangle
+ .iter()
+ .copied()
+ .find(|index| *index != edge.0 && *index != edge.1)
+ .expect("triangle has opposite vertex");
+ split.push(opposite);
+ false
+ } else {
+ true
+ }
+ });
+ if split.is_empty() {
+ return Err(());
+ }
+ for opposite in split {
+ let first = make_ccw([edge.0, point_id, opposite], vertices).ok_or(())?;
+ let second = make_ccw([point_id, edge.1, opposite], vertices).ok_or(())?;
+ triangles.extend([first, second]);
+ }
+ return Ok(());
+ }
+
+ let containing = triangles.iter().position(|triangle| {
+ point_in_triangle(
+ point,
+ vertices[triangle[0]],
+ vertices[triangle[1]],
+ vertices[triangle[2]],
+ )
+ });
+ let Some(containing) = containing else {
+ return Err(());
+ };
+ let triangle = triangles.swap_remove(containing);
+ for edge in 0..3 {
+ let split = make_ccw(
+ [triangle[edge], triangle[(edge + 1) % 3], point_id],
+ vertices,
+ )
+ .ok_or(())?;
+ triangles.push(split);
+ }
+ Ok(())
+}
+
+fn source_boundary_constraints(
+ vertices: &[[f64; 2]],
+ triangle: [[f64; 2]; 3],
+) -> std::collections::BTreeSet<(usize, usize)> {
+ let mut constraints = std::collections::BTreeSet::new();
+ for edge in 0..3 {
+ let first = triangle[edge];
+ let second = triangle[(edge + 1) % 3];
+ let mut points = vertices
+ .iter()
+ .copied()
+ .enumerate()
+ .filter_map(|(index, point)| {
+ point_on_segment(point, first, second)
+ .then_some((segment_parameter(point, first, second), index))
+ })
+ .collect::<Vec<_>>();
+ points.sort_by(|left, right| left.0.total_cmp(&right.0));
+ points.dedup_by_key(|(_, index)| *index);
+ for pair in points.windows(2) {
+ constraints.insert(canonical_edge(pair[0].1, pair[1].1));
+ }
+ }
+ constraints
+}
+
+fn recover_constraint(
+ target: (usize, usize),
+ triangles: &mut [[usize; 3]],
+ vertices: &[[f64; 2]],
+ constraints: &std::collections::BTreeSet<(usize, usize)>,
+) -> Result<(), ()> {
+ let limit = vertices
+ .len()
+ .saturating_mul(vertices.len())
+ .saturating_mul(8)
+ .max(1);
+ for _ in 0..limit {
+ if edge_exists(triangles, target) {
+ return Ok(());
+ }
+ let mut crossed = triangulation_edges(triangles)
+ .into_iter()
+ .filter_map(|(edge, adjacent)| {
+ if adjacent.len() != 2
+ || edge.0 == target.0
+ || edge.0 == target.1
+ || edge.1 == target.0
+ || edge.1 == target.1
+ {
+ return None;
+ }
+ proper_intersection_parameter(
+ vertices[target.0],
+ vertices[target.1],
+ vertices[edge.0],
+ vertices[edge.1],
+ )
+ .map(|parameter| (parameter, edge, adjacent))
+ })
+ .collect::<Vec<_>>();
+ crossed.sort_by(|left, right| left.0.total_cmp(&right.0));
+ let mut flipped = false;
+ for (_, edge, adjacent) in crossed {
+ if constraints.contains(&edge) {
+ return Err(());
+ }
+ let first_index = adjacent[0].0;
+ let second_index = adjacent[1].0;
+ let first_opposite = adjacent[0].1;
+ let second_opposite = adjacent[1].1;
+ if !convex_quadrilateral(
+ vertices[edge.0],
+ vertices[edge.1],
+ vertices[first_opposite],
+ vertices[second_opposite],
+ ) {
+ continue;
+ }
+ let replacement = canonical_edge(first_opposite, second_opposite);
+ if constraints.contains(&replacement)
+ || proper_intersection_parameter(
+ vertices[target.0],
+ vertices[target.1],
+ vertices[replacement.0],
+ vertices[replacement.1],
+ )
+ .is_some()
+ {
+ continue;
+ }
+ let (a, b) = directed_edge(triangles[first_index], edge).ok_or(())?;
+ triangles[first_index] =
+ make_ccw([first_opposite, second_opposite, b], vertices).ok_or(())?;
+ triangles[second_index] =
+ make_ccw([second_opposite, first_opposite, a], vertices).ok_or(())?;
+ flipped = true;
+ break;
+ }
+ if !flipped {
+ return Err(());
+ }
+ }
+ Err(())
+}
+
+fn legalize_constrained_delaunay(
+ triangles: &mut [[usize; 3]],
+ vertices: &[[f64; 2]],
+ constraints: &std::collections::BTreeSet<(usize, usize)>,
+) -> Result<(), ()> {
+ let limit = triangles
+ .len()
+ .saturating_mul(triangles.len())
+ .saturating_mul(8)
+ .max(1);
+ for _ in 0..limit {
+ let edges = triangulation_edges(triangles);
+ let mut flipped = false;
+ for (edge, adjacent) in edges {
+ if adjacent.len() != 2 || constraints.contains(&edge) {
+ continue;
+ }
+ let first_index = adjacent[0].0;
+ let second_index = adjacent[1].0;
+ let c = adjacent[0].1;
+ let d = adjacent[1].1;
+ if !convex_quadrilateral(vertices[edge.0], vertices[edge.1], vertices[c], vertices[d]) {
+ continue;
+ }
+ let (a, b) = directed_edge(triangles[first_index], edge).ok_or(())?;
+ if in_circle(vertices[a], vertices[b], vertices[c], vertices[d]) <= 1.0e-10 {
+ continue;
+ }
+ let replacement = canonical_edge(c, d);
+ if constraints.iter().any(|constraint| {
+ proper_intersection_parameter(
+ vertices[replacement.0],
+ vertices[replacement.1],
+ vertices[constraint.0],
+ vertices[constraint.1],
+ )
+ .is_some()
+ }) {
+ continue;
+ }
+ triangles[first_index] = make_ccw([c, d, b], vertices).ok_or(())?;
+ triangles[second_index] = make_ccw([d, c, a], vertices).ok_or(())?;
+ flipped = true;
+ break;
+ }
+ if !flipped {
+ return Ok(());
+ }
+ }
+ Err(())
+}
+
+fn triangulation_edges(
+ triangles: &[[usize; 3]],
+) -> std::collections::BTreeMap<(usize, usize), Vec<(usize, usize)>> {
+ let mut edges = std::collections::BTreeMap::new();
+ for (triangle_index, triangle) in triangles.iter().copied().enumerate() {
+ for edge in 0..3 {
+ let first = triangle[edge];
+ let second = triangle[(edge + 1) % 3];
+ let opposite = triangle[(edge + 2) % 3];
+ edges
+ .entry(canonical_edge(first, second))
+ .or_insert_with(Vec::new)
+ .push((triangle_index, opposite));
+ }
+ }
+ edges
+}
+
+fn triangle_has_edge(triangle: [usize; 3], edge: (usize, usize)) -> bool {
+ triangle.contains(&edge.0) && triangle.contains(&edge.1)
+}
+
+fn edge_exists(triangles: &[[usize; 3]], edge: (usize, usize)) -> bool {
+ triangles
+ .iter()
+ .copied()
+ .any(|triangle| triangle_has_edge(triangle, edge))
+}
+
+fn directed_edge(triangle: [usize; 3], edge: (usize, usize)) -> Option<(usize, usize)> {
+ (0..3).find_map(|index| {
+ let first = triangle[index];
+ let second = triangle[(index + 1) % 3];
+ (canonical_edge(first, second) == edge).then_some((first, second))
+ })
+}
+
+fn canonical_edge(first: usize, second: usize) -> (usize, usize) {
+ if first < second {
+ (first, second)
+ } else {
+ (second, first)
+ }
+}
+
+fn make_ccw(triangle: [usize; 3], vertices: &[[f64; 2]]) -> Option<[usize; 3]> {
+ let area = cross2(
+ vertices[triangle[0]],
+ vertices[triangle[1]],
+ vertices[triangle[2]],
+ );
+ if !area.is_finite() || area.abs() <= 1.0e-12 {
+ return None;
+ }
+ Some(if area > 0.0 {
+ triangle
+ } else {
+ [triangle[0], triangle[2], triangle[1]]
+ })
+}
+
+fn point_in_triangle(point: [f64; 2], a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> bool {
+ let edges = [(a, b), (b, c), (c, a)];
+ edges.into_iter().all(|(first, second)| {
+ let scale = distance_squared(first, second).sqrt();
+ cross2(first, second, point) >= -linear_tolerance(first, second, point) * scale
+ })
+}
+
+fn point_on_open_segment(point: [f64; 2], first: [f64; 2], second: [f64; 2]) -> bool {
+ if !point_on_segment(point, first, second) {
+ return false;
+ }
+ let t = segment_parameter(point, first, second);
+ t > 1.0e-9 && t < 1.0 - 1.0e-9
+}
+
+fn point_on_segment(point: [f64; 2], first: [f64; 2], second: [f64; 2]) -> bool {
+ let length_squared = distance_squared(first, second);
+ if length_squared <= 1.0e-20 {
+ return distance_squared(point, first) <= 1.0e-18;
+ }
+ let cross = cross2(first, second, point).abs();
+ let dot = (point[0] - first[0]) * (second[0] - first[0])
+ + (point[1] - first[1]) * (second[1] - first[1]);
+ let tolerance = linear_tolerance(first, second, point);
+ let length = length_squared.sqrt();
+ cross <= tolerance * length
+ && dot >= -tolerance * length
+ && dot <= length_squared + tolerance * length
+}
+
+fn linear_tolerance(first: [f64; 2], second: [f64; 2], point: [f64; 2]) -> f64 {
+ let coordinate_scale = first
+ .into_iter()
+ .chain(second)
+ .chain(point)
+ .map(f64::abs)
+ .fold(1.0_f64, f64::max);
+ 2.0 * f64::from(f32::EPSILON) * coordinate_scale
+}
+
+fn segment_parameter(point: [f64; 2], first: [f64; 2], second: [f64; 2]) -> f64 {
+ let delta = [second[0] - first[0], second[1] - first[1]];
+ let length_squared = delta[0] * delta[0] + delta[1] * delta[1];
+ if length_squared <= 1.0e-20 {
+ 0.0
+ } else {
+ ((point[0] - first[0]) * delta[0] + (point[1] - first[1]) * delta[1]) / length_squared
+ }
+}
+
+fn proper_intersection_parameter(
+ first: [f64; 2],
+ second: [f64; 2],
+ edge_first: [f64; 2],
+ edge_second: [f64; 2],
+) -> Option<f64> {
+ let first_delta = [second[0] - first[0], second[1] - first[1]];
+ let edge_delta = [
+ edge_second[0] - edge_first[0],
+ edge_second[1] - edge_first[1],
+ ];
+ let denominator = first_delta[0] * edge_delta[1] - first_delta[1] * edge_delta[0];
+ if denominator.abs() <= 1.0e-12 {
+ return None;
+ }
+ let offset = [edge_first[0] - first[0], edge_first[1] - first[1]];
+ let t = (offset[0] * edge_delta[1] - offset[1] * edge_delta[0]) / denominator;
+ let u = (offset[0] * first_delta[1] - offset[1] * first_delta[0]) / denominator;
+ (t > 1.0e-9 && t < 1.0 - 1.0e-9 && u > 1.0e-9 && u < 1.0 - 1.0e-9).then_some(t)
+}
+
+fn convex_quadrilateral(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool {
+ cross2(a, b, c) * cross2(a, b, d) < -1.0e-12 && cross2(c, d, a) * cross2(c, d, b) < -1.0e-12
+}
+
+fn in_circle(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> f64 {
+ let a = [a[0] - d[0], a[1] - d[1]];
+ let b = [b[0] - d[0], b[1] - d[1]];
+ let c = [c[0] - d[0], c[1] - d[1]];
+ let a2 = a[0] * a[0] + a[1] * a[1];
+ let b2 = b[0] * b[0] + b[1] * b[1];
+ let c2 = c[0] * c[0] + c[1] * c[1];
+ a2 * (b[0] * c[1] - b[1] * c[0]) - b2 * (a[0] * c[1] - a[1] * c[0])
+ + c2 * (a[0] * b[1] - a[1] * b[0])
+}
+
+fn segments_intersect(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool {
+ let ab_c = cross2(a, b, c);
+ let ab_d = cross2(a, b, d);
+ let cd_a = cross2(c, d, a);
+ let cd_b = cross2(c, d, b);
+ let eps = 1.0e-8;
+ (ab_c.abs() <= eps && point_on_segment(c, a, b))
+ || (ab_d.abs() <= eps && point_on_segment(d, a, b))
+ || (cd_a.abs() <= eps && point_on_segment(a, c, d))
+ || (cd_b.abs() <= eps && point_on_segment(b, c, d))
+ || (ab_c * ab_d < 0.0 && cd_a * cd_b < 0.0)
+}
+
+fn point_in_ring(point: [f64; 2], ring: &[[f64; 2]]) -> bool {
+ let mut winding = 0_i32;
+ for index in 0..ring.len() {
+ let first = ring[index];
+ let second = ring[(index + 1) % ring.len()];
+ let side = cross2(first, second, point);
+ if first[1] <= point[1] {
+ if second[1] > point[1] && side > 0.0 {
+ winding += 1;
+ }
+ } else if second[1] <= point[1] && side < 0.0 {
+ winding -= 1;
+ }
+ }
+ winding != 0
+}
+
+fn polygon_area(points: &[[f64; 2]]) -> f64 {
+ (0..points.len())
+ .map(|index| {
+ let first = points[index];
+ let second = points[(index + 1) % points.len()];
+ first[0] * second[1] - second[0] * first[1]
+ })
+ .sum::<f64>()
+ * 0.5
+}
+
+fn lift_to_source_plane(point: [f64; 2], source: [[f32; 3]; 3]) -> Option<[f32; 3]> {
+ for vertex in source {
+ if distance_squared(point, [f64::from(vertex[0]), f64::from(vertex[1])]) <= 1.0e-18 {
+ return Some(vertex);
+ }
+ }
+ let a = source[0].map(f64::from);
+ let b = source[1].map(f64::from);
+ let c = source[2].map(f64::from);
+ let denominator = cross2([a[0], a[1]], [b[0], b[1]], [c[0], c[1]]);
+ if denominator.abs() <= 1.0e-10 {
+ return None;
+ }
+ let second_weight = cross2([a[0], a[1]], point, [c[0], c[1]]) / denominator;
+ let third_weight = cross2([a[0], a[1]], [b[0], b[1]], point) / denominator;
+ let first_weight = 1.0 - second_weight - third_weight;
+ let z = first_weight * a[2] + second_weight * b[2] + third_weight * c[2];
+ let position = [point[0] as f32, point[1] as f32, z as f32];
+ position
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some(position)
+}
+
+fn cross2(first: [f64; 2], second: [f64; 2], third: [f64; 2]) -> f64 {
+ (second[0] - first[0]) * (third[1] - first[1]) - (second[1] - first[1]) * (third[0] - first[0])
+}
+
+fn interpolate(first: [f64; 2], second: [f64; 2], amount: f64) -> [f64; 2] {
+ [
+ first[0] + (second[0] - first[0]) * amount,
+ first[1] + (second[1] - first[1]) * amount,
+ ]
+}
+
+fn distance_squared(first: [f64; 2], second: [f64; 2]) -> f64 {
+ let delta_x = first[0] - second[0];
+ let delta_y = first[1] - second[1];
+ delta_x * delta_x + delta_y * delta_y
+}
+
+#[cfg(test)]
+mod tests {
+ use super::subtract_contour;
+
+ #[test]
+ fn translated_thin_outside_strip_keeps_its_area() {
+ let base = 10_000.0_f32;
+ let source = [[
+ [base, base, 0.0],
+ [base + 10.0, base, 0.0],
+ [base, base + 10.0, 0.0],
+ ]];
+ let outer = [
+ [base + 0.003, base + 0.003],
+ [base + 1.0, base + 0.003],
+ [base + 1.0, base + 0.997],
+ [base + 0.003, base + 0.997],
+ ];
+ let output = subtract_contour(&source, &outer).expect("simple contour");
+ let retained_area = output
+ .iter()
+ .map(|fragment| {
+ let [a, b, c] = fragment.positions;
+ let ab = [f64::from(b[0] - a[0]), f64::from(b[1] - a[1])];
+ let ac = [f64::from(c[0] - a[0]), f64::from(c[1] - a[1])];
+ (ab[0] * ac[1] - ab[1] * ac[0]).abs() * 0.5
+ })
+ .sum::<f64>();
+ let expected_area =
+ 50.0 - (f64::from(outer[1][0] - outer[0][0]) * f64::from(outer[2][1] - outer[1][1]));
+ assert!(
+ (retained_area - expected_area).abs() < 0.001,
+ "retained area {retained_area}, expected {expected_area}"
+ );
+ }
+}
diff --git a/crates/fparkan-terrain/src/foundation.rs b/crates/fparkan-terrain/src/foundation.rs
new file mode 100644
index 0000000..594bbfe
--- /dev/null
+++ b/crates/fparkan-terrain/src/foundation.rs
@@ -0,0 +1,1806 @@
+//! Native-shaped building basement foundation geometry.
+
+use crate::contour::subtract_contour;
+use fparkan_terrain_format::{FullSurfaceMask, LandMeshDocument, TerrainLod};
+
+const LINE_EPSILON: f64 = 1.0e-5;
+const POINT_EPSILON_SQUARED: f64 = 1.0e-12;
+
+/// A terrain or generated basement triangle shared by preview consumers.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct BasementFoundationFragment {
+ /// Original Land.msh face for terrain fragments; `None` for generated band faces.
+ pub source_face: Option<usize>,
+ /// Raw terrain or generated basement face flags.
+ pub surface_mask: FullSurfaceMask,
+ /// Triangle coordinates shared by rendering, shadows, and terrain queries.
+ pub positions: [[f32; 3]; 3],
+}
+
+/// Canonical output of the native-shaped terrain basement preparation.
+#[derive(Clone, Debug, PartialEq)]
+pub struct BasementFoundation {
+ pub fragments: Vec<BasementFoundationFragment>,
+ /// Native placement-height inputs in footprint order.
+ pub placements: Vec<BasementPlacement>,
+}
+
+/// One building footprint in native preparation order.
+#[derive(Clone, Copy, Debug)]
+pub struct BasementFootprint<'a> {
+ /// Authored outside ring in world XY coordinates.
+ pub outer: &'a [[f32; 2]],
+ /// Authored inner ring, retaining its source Z for placement calculations.
+ pub inner: &'a [[f32; 3]],
+ /// Whether native getter mode flattens the generated inner boundary to mean outer Z.
+ pub flatten_inner_to_mean_outer_z: bool,
+}
+
+/// Native placement-height inputs captured for one building.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct BasementPlacement {
+ /// Native transform adjustment: first authored inner Z minus mean sampled outer terrain Z.
+ pub placement_delta: f32,
+}
+
+/// Error returned while preparing a basement foundation.
+#[derive(Clone, Debug, PartialEq)]
+pub enum BasementFoundationError {
+ /// Terrain Node38/Slot68 data cannot resolve LOD0 faces.
+ InvalidLodMapping(fparkan_terrain_format::TerrainLodError),
+ /// A selected LOD0 face refers to a missing vertex.
+ InvalidTerrainFace { face: usize, vertex: u16 },
+ /// A contour, nested-ring relationship, or generated annulus is invalid.
+ InvalidContour,
+ /// The LOD0 mesh has no usable terrain faces.
+ EmptyLod0,
+ /// The outer contour did not cross any LOD0 face.
+ NoOuterCrossings,
+ /// A point of the authored outer contour lies outside the LOD0 terrain.
+ OuterPointOutsideLod0 { point: usize },
+ /// An authored outer segment leaves the current terrain surface.
+ UncoveredOuterSegment { segment: usize },
+ /// Footprint outer contours overlap; native overlap semantics remain unverified.
+ OverlappingFootprints,
+}
+
+impl std::fmt::Display for BasementFoundationError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::InvalidLodMapping(error) => write!(f, "invalid terrain LOD mapping: {error}"),
+ Self::InvalidTerrainFace { face, vertex } => {
+ write!(f, "terrain face {face} references missing vertex {vertex}")
+ }
+ Self::InvalidContour => write!(f, "invalid basement contour geometry"),
+ Self::EmptyLod0 => write!(f, "terrain has no LOD0 faces"),
+ Self::NoOuterCrossings => write!(f, "outer contour does not cross LOD0 terrain"),
+ Self::OuterPointOutsideLod0 { point } => {
+ write!(f, "outer contour point {point} is outside LOD0 terrain")
+ }
+ Self::UncoveredOuterSegment { segment } => {
+ write!(
+ f,
+ "outer contour segment {segment} leaves the terrain surface"
+ )
+ }
+ Self::OverlappingFootprints => {
+ write!(f, "overlapping basement footprints are not supported")
+ }
+ }
+ }
+}
+
+impl std::error::Error for BasementFoundationError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::InvalidLodMapping(error) => Some(error),
+ Self::InvalidTerrainFace { .. }
+ | Self::InvalidContour
+ | Self::EmptyLod0
+ | Self::NoOuterCrossings => None,
+ Self::OuterPointOutsideLod0 { .. } | Self::UncoveredOuterSegment { .. } => None,
+ Self::OverlappingFootprints => None,
+ }
+ }
+}
+
+/// Строит основания ландшафта в порядке нативной подготовки размещений.
+///
+/// Вершины внешнего контура нормализуются по топологии LOD0: сначала проверяются
+/// вершины граней и соседних граней, затем применяется проекция, используемая
+/// нативным `FUN_10006240`. Это косинусная формула с отдельной проверкой
+/// расстояния, а не обычная ортогональная проекция. При обходе сегмента
+/// вставляются только вершины LOD0, прошедшие тот же нативный предикат и
+/// находящиеся не ближе 0.5 мировых единиц к обоим концам; геометрическое
+/// совпадение с отрезком само по себе недостаточно.
+///
+/// Для каждого контура функция упорядочивает пересечения с плоскостями текущих
+/// канонических треугольников, вычисляет среднюю высоту внешней границы и строит
+/// ограниченную триангуляцию кольца. Явный флаг выбирает нативную ветвь высоты
+/// внутренней границы. Непересекающиеся контуры последовательно вырезаются из
+/// текущей поверхности; пересекающиеся контуры отклоняются, поскольку их
+/// взаимодействие в нативном коде пока не установлено.
+///
+/// Возвращённый набор фрагментов является общей геометрией для рендеринга,
+/// теней и запросов поверхности. Фрагменты исходной поверхности сохраняют
+/// номера и маски исходных граней; у сгенерированных граней маска соответствует
+/// записи `AddBasementFaceProc`.
+///
+/// The returned fragment vector is the canonical geometry for rendering,
+/// shadows, and terrain queries. Source faces preserve their original face IDs
+/// and masks. Generated faces use the raw mask written by AddBasementFaceProc.
+pub fn build_basement_foundations(
+ mesh: &LandMeshDocument,
+ footprints: &[BasementFootprint<'_>],
+) -> Result<BasementFoundation, BasementFoundationError> {
+ let assignments = mesh
+ .face_lod_assignments()
+ .map_err(BasementFoundationError::InvalidLodMapping)?;
+ let mut lod0_face_indices = Vec::new();
+ let mut terrain = Vec::new();
+ for assignment in assignments
+ .iter()
+ .filter(|assignment| assignment.lod == TerrainLod::Lod0)
+ {
+ let face = &mesh.faces[assignment.face_index];
+ let positions = terrain_face_positions(mesh, assignment.face_index)?;
+ terrain.push(BasementFoundationFragment {
+ source_face: Some(assignment.face_index),
+ surface_mask: face.flags,
+ positions,
+ });
+ lod0_face_indices.push(assignment.face_index);
+ }
+ if terrain.is_empty() {
+ return Err(BasementFoundationError::EmptyLod0);
+ }
+
+ let mut bands = Vec::new();
+ let mut placements = Vec::with_capacity(footprints.len());
+ let mut previous_outer_rings: Vec<Vec<[f64; 2]>> = Vec::with_capacity(footprints.len());
+ for footprint in footprints {
+ let (outer_points, outer_closed) = normalize_ring(footprint.outer)?;
+ let snapped_outer = normalize_outer_points(&outer_points, &lod0_face_indices, mesh)?;
+ if !simple_ring(&snapped_outer) {
+ return Err(BasementFoundationError::InvalidContour);
+ }
+ if previous_outer_rings
+ .iter()
+ .any(|previous| rings_overlap(previous, &snapped_outer))
+ {
+ return Err(BasementFoundationError::OverlappingFootprints);
+ }
+ previous_outer_rings.push(snapped_outer.clone());
+ let mut outer_ring = snapped_outer
+ .iter()
+ .map(|point| [point[0] as f32, point[1] as f32])
+ .collect::<Vec<_>>();
+ if outer_closed {
+ outer_ring.push(outer_ring[0]);
+ }
+ let samples = terrain
+ .iter()
+ .map(|fragment| {
+ (
+ fragment
+ .source_face
+ .expect("terrain fragments have source faces"),
+ fragment.surface_mask,
+ fragment.positions,
+ )
+ })
+ .collect::<Vec<_>>();
+ let mut outer_crossings = contour_crossings(&snapped_outer, &samples)?;
+ if outer_crossings.is_empty() {
+ return Err(BasementFoundationError::NoOuterCrossings);
+ }
+ // Crossings are open here; restore the authored duplicate only afterward.
+ let mean_outer_z = outer_crossings
+ .iter()
+ .map(|crossing| f64::from(crossing[2]))
+ .sum::<f64>()
+ / outer_crossings.len() as f64;
+ if !mean_outer_z.is_finite() {
+ return Err(BasementFoundationError::InvalidContour);
+ }
+ let mean_outer_z = mean_outer_z as f32;
+ if outer_closed {
+ let closing = *outer_crossings
+ .first()
+ .ok_or(BasementFoundationError::NoOuterCrossings)?;
+ outer_crossings.push(closing);
+ }
+
+ let inner_xy = footprint
+ .inner
+ .iter()
+ .map(|point| [point[0], point[1]])
+ .collect::<Vec<_>>();
+ let (inner_points, inner_closed) = normalize_ring(&inner_xy)?;
+ if footprint
+ .inner
+ .iter()
+ .flatten()
+ .any(|value| !value.is_finite())
+ || !nested_rings(&snapped_outer, &inner_points)
+ {
+ return Err(BasementFoundationError::InvalidContour);
+ }
+ let authored_inner_z = footprint
+ .inner
+ .first()
+ .ok_or(BasementFoundationError::InvalidContour)?[2];
+ let placement_delta = authored_inner_z - mean_outer_z;
+ if !placement_delta.is_finite() {
+ return Err(BasementFoundationError::InvalidContour);
+ }
+ placements.push(BasementPlacement { placement_delta });
+ let inner_vertex_count = if inner_closed {
+ footprint.inner.len() - 1
+ } else {
+ footprint.inner.len()
+ };
+ let mut inner_ring = footprint.inner[..inner_vertex_count]
+ .iter()
+ .map(|point| {
+ [
+ point[0],
+ point[1],
+ if footprint.flatten_inner_to_mean_outer_z {
+ mean_outer_z
+ } else {
+ point[2]
+ },
+ ]
+ })
+ .collect::<Vec<_>>();
+ if inner_closed {
+ inner_ring.push(inner_ring[0]);
+ }
+ let outer_surface = crossings_as_ring(&outer_crossings, outer_closed)?;
+ let inner_surface = inner_ring_without_closure(&inner_ring);
+ for positions in triangulate_annulus(&outer_surface, inner_surface)? {
+ bands.push(BasementFoundationFragment {
+ source_face: None,
+ surface_mask: basement_surface_mask(positions),
+ positions,
+ });
+ }
+
+ let source_positions = terrain
+ .iter()
+ .map(|fragment| fragment.positions)
+ .collect::<Vec<_>>();
+ let outside = subtract_contour(&source_positions, &outer_ring)
+ .map_err(|_| BasementFoundationError::InvalidContour)?;
+ let clipped = outside
+ .into_iter()
+ .map(|fragment| {
+ let source = terrain[fragment.source_triangle];
+ BasementFoundationFragment {
+ source_face: source.source_face,
+ surface_mask: source.surface_mask,
+ positions: fragment.positions,
+ }
+ })
+ .collect();
+ terrain = clipped;
+ }
+ terrain.extend(bands);
+ Ok(BasementFoundation {
+ fragments: terrain,
+ placements,
+ })
+}
+
+fn terrain_face_positions(
+ mesh: &LandMeshDocument,
+ face_index: usize,
+) -> Result<[[f32; 3]; 3], BasementFoundationError> {
+ let face = &mesh.faces[face_index];
+ let resolve = |vertex: u16| {
+ mesh.positions.get(usize::from(vertex)).copied().ok_or(
+ BasementFoundationError::InvalidTerrainFace {
+ face: face_index,
+ vertex,
+ },
+ )
+ };
+ Ok([
+ resolve(face.vertices[0])?,
+ resolve(face.vertices[1])?,
+ resolve(face.vertices[2])?,
+ ])
+}
+
+fn normalize_ring(ring: &[[f32; 2]]) -> Result<(Vec<[f64; 2]>, bool), BasementFoundationError> {
+ if ring.iter().flatten().any(|value| !value.is_finite()) {
+ return Err(BasementFoundationError::InvalidContour);
+ }
+ let mut points = ring
+ .iter()
+ .map(|point| [f64::from(point[0]), f64::from(point[1])])
+ .collect::<Vec<_>>();
+ let closed = points.len() > 1
+ && distance_squared(points[0], *points.last().expect("nonempty")) <= POINT_EPSILON_SQUARED;
+ if closed {
+ points.pop();
+ }
+ if points.len() < 3 || !simple_ring(&points) {
+ return Err(BasementFoundationError::InvalidContour);
+ }
+ Ok((points, closed))
+}
+
+fn normalize_outer_points(
+ points: &[[f64; 2]],
+ lod0_faces: &[usize],
+ mesh: &LandMeshDocument,
+) -> Result<Vec<[f64; 2]>, BasementFoundationError> {
+ // shortcut: first-face lookup and neighbor ordering still use source LOD0
+ // IDs for every placement. The crossing sampler and cutter use the current
+ // canonical fragment list, but dynamic neighbor-ID reconstruction is not
+ // modeled yet, so later placements are not claimed as native-identical.
+ let mut output = points
+ .iter()
+ .enumerate()
+ .map(|(point_index, point)| {
+ native_normalize_outer_point(
+ mesh,
+ lod0_faces,
+ [point[0] as f32, point[1] as f32],
+ point_index,
+ )
+ })
+ .collect::<Result<Vec<_>, _>>()?
+ .into_iter()
+ .map(|point| [f64::from(point[0]), f64::from(point[1])])
+ .collect::<Vec<_>>();
+
+ // The native contour pass asks for terrain faces overlapping the current
+ // segment's AABB, then tries their three vertices in face order. On one
+ // accepted vertex it inserts the full terrain point and resumes from the
+ // shortened segment at the same index.
+ let mut segment_index = 0;
+ while segment_index < output.len() {
+ let first = output[segment_index].map(|value| value as f32);
+ let second = output[(segment_index + 1) % output.len()].map(|value| value as f32);
+ let min = [first[0].min(second[0]), first[1].min(second[1])];
+ let max = [first[0].max(second[0]), first[1].max(second[1])];
+ let mut inserted = false;
+ 'faces: for &face_index in lod0_faces {
+ let Some(face) = mesh.faces.get(face_index) else {
+ continue;
+ };
+ let triangle = face
+ .vertices
+ .map(|vertex| mesh.positions.get(usize::from(vertex)).copied());
+ let [Some(a), Some(b), Some(c)] = triangle else {
+ continue;
+ };
+ let face_min = [a[0].min(b[0]).min(c[0]), a[1].min(b[1]).min(c[1])];
+ let face_max = [a[0].max(b[0]).max(c[0]), a[1].max(b[1]).max(c[1])];
+ if (0..2).any(|axis| face_max[axis] < min[axis] || max[axis] < face_min[axis]) {
+ continue;
+ }
+ for vertex in [a, b, c] {
+ if !native_segment_inserts_vertex(first, second, vertex) {
+ continue;
+ }
+ let vertex_xy = [f64::from(vertex[0]), f64::from(vertex[1])];
+ if output
+ .iter()
+ .any(|existing| distance_squared(*existing, vertex_xy) <= POINT_EPSILON_SQUARED)
+ {
+ continue;
+ }
+ output.insert(segment_index + 1, vertex_xy);
+ inserted = true;
+ break 'faces;
+ }
+ }
+ if !inserted {
+ segment_index += 1;
+ }
+ }
+ Ok(output)
+}
+
+fn native_normalize_outer_point(
+ mesh: &LandMeshDocument,
+ lod0_faces: &[usize],
+ point: [f32; 2],
+ point_index: usize,
+) -> Result<[f32; 2], BasementFoundationError> {
+ let containing_face = lod0_faces.iter().copied().find(|&face_index| {
+ let Some(face) = mesh.faces.get(face_index) else {
+ return false;
+ };
+ let triangle = face
+ .vertices
+ .map(|vertex| mesh.positions[usize::from(vertex)]);
+ point_in_triangle_xy(point, triangle)
+ });
+ let containing_face = containing_face
+ .ok_or(BasementFoundationError::OuterPointOutsideLod0 { point: point_index })?;
+ let mut candidates = vec![containing_face];
+ for neighbor in mesh.faces[containing_face].neighbors.iter().flatten() {
+ let neighbor = usize::from(*neighbor);
+ if lod0_faces.contains(&neighbor) && !candidates.contains(&neighbor) {
+ candidates.push(neighbor);
+ }
+ }
+ let normalized = point;
+ for face_index in candidates {
+ let face = &mesh.faces[face_index];
+ let vertices = face
+ .vertices
+ .map(|vertex| mesh.positions.get(usize::from(vertex)).copied());
+ let [Some(a), Some(b), Some(c)] = vertices else {
+ return Err(BasementFoundationError::InvalidTerrainFace {
+ face: face_index,
+ vertex: face.vertices[0],
+ });
+ };
+ let vertices = [a, b, c];
+ for (first, second) in [(0, 1), (1, 2), (2, 0)] {
+ if let Some(candidate) =
+ native_normalize_edge(normalized, vertices[first], vertices[second])?
+ {
+ return Ok(candidate);
+ }
+ }
+ }
+ Ok(normalized)
+}
+
+fn point_in_triangle_xy(point: [f32; 2], triangle: [[f32; 3]; 3]) -> bool {
+ let sign = |a: [f32; 3], b: [f32; 3]| {
+ (point[0] - b[0]) * (a[1] - b[1]) - (a[0] - b[0]) * (point[1] - b[1])
+ };
+ let d1 = sign(triangle[0], triangle[1]);
+ let d2 = sign(triangle[1], triangle[2]);
+ let d3 = sign(triangle[2], triangle[0]);
+ let has_negative = d1 < -1.0e-4 || d2 < -1.0e-4 || d3 < -1.0e-4;
+ let has_positive = d1 > 1.0e-4 || d2 > 1.0e-4 || d3 > 1.0e-4;
+ !(has_negative && has_positive)
+}
+
+fn native_normalize_edge(
+ point: [f32; 2],
+ first: [f32; 3],
+ second: [f32; 3],
+) -> Result<Option<[f32; 2]>, BasementFoundationError> {
+ if distance_xy(point, [first[0], first[1]]) < 0.5 {
+ let point = [first[0], first[1]];
+ return Ok(Some(point));
+ }
+ if distance_xy(point, [second[0], second[1]]) < 0.5 {
+ let point = [second[0], second[1]];
+ return Ok(Some(point));
+ }
+ let Some((offset, _)) = native_edge_projection(point, first, second) else {
+ return Ok(None);
+ };
+ let mut projected = [point[0] + offset[0], point[1] + offset[1]];
+ let Some((check_offset, _)) = native_edge_projection(projected, first, second) else {
+ return Err(BasementFoundationError::InvalidContour);
+ };
+ if check_offset[0].hypot(check_offset[1]) >= 0.0001 {
+ return Err(BasementFoundationError::InvalidContour);
+ }
+ if distance_xy(projected, [first[0], first[1]]) < 0.5 {
+ projected = [first[0], first[1]];
+ }
+ if distance_xy(projected, [second[0], second[1]]) < 0.5 {
+ projected = [second[0], second[1]];
+ }
+ Ok(Some(projected))
+}
+
+fn native_segment_inserts_vertex(first: [f32; 2], second: [f32; 2], point: [f32; 3]) -> bool {
+ if distance_xy([point[0], point[1]], first) < 0.5
+ || distance_xy([point[0], point[1]], second) < 0.5
+ {
+ return false;
+ }
+ native_edge_projection(
+ [point[0], point[1]],
+ [first[0], first[1], 0.0],
+ [second[0], second[1], 0.0],
+ )
+ .is_some()
+}
+
+fn native_edge_projection(
+ point: [f32; 2],
+ first: [f32; 3],
+ second: [f32; 3],
+) -> Option<([f32; 2], f32)> {
+ let ux = point[0] - first[0];
+ let uy = point[1] - first[1];
+ let ex = second[0] - first[0];
+ let ey = second[1] - first[1];
+ let edge_length = (ex * ex + ey * ey).sqrt();
+ let point_length = (ux * ux + uy * uy).sqrt();
+ if edge_length == 0.0 || point_length == 0.0 {
+ return None;
+ }
+ let t = ((ex * ux + ey * uy) / edge_length) / point_length;
+ if !(0.0..=1.0).contains(&t) {
+ return None;
+ }
+ let offset = [
+ (t * ex + first[0]) - point[0],
+ (t * ey + first[1]) - point[1],
+ ];
+ (offset[0].hypot(offset[1]) < 0.5).then_some((offset, t))
+}
+
+fn distance_xy(first: [f32; 2], second: [f32; 2]) -> f32 {
+ ((first[0] - second[0]).powi(2) + (first[1] - second[1]).powi(2)).sqrt()
+}
+
+fn contour_crossings(
+ ring: &[[f64; 2]],
+ terrain_faces: &[(usize, FullSurfaceMask, [[f32; 3]; 3])],
+) -> Result<Vec<[f32; 3]>, BasementFoundationError> {
+ let mut crossings = Vec::new();
+ for segment_index in 0..ring.len() {
+ let first = ring[segment_index];
+ let second = ring[(segment_index + 1) % ring.len()];
+ let mut segment_crossings = Vec::new();
+ let mut coverage = Vec::new();
+ for (source_face, _, triangle) in terrain_faces {
+ let Some((start, end)) = segment_triangle_interval(first, second, *triangle) else {
+ continue;
+ };
+ coverage.push((start, end));
+ for t in [start, end] {
+ let point = [
+ first[0] + (second[0] - first[0]) * t,
+ first[1] + (second[1] - first[1]) * t,
+ ];
+ let Some(position) = lift_to_face(point, *triangle) else {
+ continue;
+ };
+ segment_crossings.push((t, *source_face, position));
+ }
+ }
+ segment_crossings.sort_by(|left, right| {
+ left.0
+ .total_cmp(&right.0)
+ .then_with(|| left.1.cmp(&right.1))
+ });
+ coverage.sort_by(|left, right| {
+ left.0
+ .total_cmp(&right.0)
+ .then_with(|| left.1.total_cmp(&right.1))
+ });
+ let mut covered_until = 0.0_f64;
+ for (start, end) in coverage {
+ if start > covered_until + 1.0e-8 {
+ return Err(BasementFoundationError::UncoveredOuterSegment {
+ segment: segment_index,
+ });
+ }
+ covered_until = covered_until.max(end);
+ }
+ if covered_until < 1.0 - 1.0e-8 {
+ return Err(BasementFoundationError::UncoveredOuterSegment {
+ segment: segment_index,
+ });
+ }
+ for (_, _, position) in segment_crossings {
+ let duplicate_of_previous = crossings.last().is_some_and(|previous: &[f32; 3]| {
+ distance_squared(
+ [f64::from(previous[0]), f64::from(previous[1])],
+ [f64::from(position[0]), f64::from(position[1])],
+ ) <= POINT_EPSILON_SQUARED
+ });
+ if duplicate_of_previous {
+ continue;
+ }
+ crossings.push(position);
+ }
+ }
+ if crossings.len() > 1
+ && distance_squared(
+ [f64::from(crossings[0][0]), f64::from(crossings[0][1])],
+ [
+ f64::from(crossings.last().expect("nonempty")[0]),
+ f64::from(crossings.last().expect("nonempty")[1]),
+ ],
+ ) <= POINT_EPSILON_SQUARED
+ {
+ crossings.pop();
+ }
+ Ok(crossings)
+}
+
+fn segment_triangle_interval(
+ first: [f64; 2],
+ second: [f64; 2],
+ triangle: [[f32; 3]; 3],
+) -> Option<(f64, f64)> {
+ let vertices = triangle.map(|point| [f64::from(point[0]), f64::from(point[1])]);
+ let area = cross2(vertices[0], vertices[1], vertices[2]);
+ if !area.is_finite() || area.abs() <= 1.0e-12 {
+ return None;
+ }
+ let orientation = area.signum();
+ let delta = [second[0] - first[0], second[1] - first[1]];
+ let mut lower: f64 = 0.0;
+ let mut upper: f64 = 1.0;
+ for edge in 0..3 {
+ let edge_first = vertices[edge];
+ let edge_second = vertices[(edge + 1) % 3];
+ let start_distance = orientation * cross2(edge_first, edge_second, first);
+ let end = [first[0] + delta[0], first[1] + delta[1]];
+ let end_distance = orientation * cross2(edge_first, edge_second, end);
+ let slope = end_distance - start_distance;
+ if slope.abs() <= 1.0e-15 {
+ if start_distance < -LINE_EPSILON {
+ return None;
+ }
+ continue;
+ }
+ // Tolerance decides whether a nearly outside interval is accepted;
+ // the actual boundary sample remains on the geometric edge (distance 0).
+ let crossing = -start_distance / slope;
+ if slope > 0.0 {
+ lower = lower.max(crossing);
+ } else {
+ upper = upper.min(crossing);
+ }
+ if lower > upper {
+ return None;
+ }
+ }
+ (upper >= 0.0 && lower <= 1.0).then_some((lower.clamp(0.0, 1.0), upper.clamp(0.0, 1.0)))
+}
+
+fn lift_to_face(point: [f64; 2], triangle: [[f32; 3]; 3]) -> Option<[f32; 3]> {
+ let vertices = triangle.map(|vertex| vertex.map(f64::from));
+ let denominator = cross2(
+ [vertices[0][0], vertices[0][1]],
+ [vertices[1][0], vertices[1][1]],
+ [vertices[2][0], vertices[2][1]],
+ );
+ if denominator.abs() <= 1.0e-12 {
+ return None;
+ }
+ let weight_b = cross2(
+ [vertices[0][0], vertices[0][1]],
+ point,
+ [vertices[2][0], vertices[2][1]],
+ ) / denominator;
+ let weight_c = cross2(
+ [vertices[0][0], vertices[0][1]],
+ [vertices[1][0], vertices[1][1]],
+ point,
+ ) / denominator;
+ let weight_a = 1.0 - weight_b - weight_c;
+ let z = weight_a * vertices[0][2] + weight_b * vertices[1][2] + weight_c * vertices[2][2];
+ let result = [point[0] as f32, point[1] as f32, z as f32];
+ result
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some(result)
+}
+
+fn crossings_as_ring(
+ crossings: &[[f32; 3]],
+ closed: bool,
+) -> Result<Vec<[f32; 3]>, BasementFoundationError> {
+ let length = crossings.len().saturating_sub(usize::from(closed));
+ let ring = crossings.iter().take(length).copied().collect::<Vec<_>>();
+ (ring.len() >= 3)
+ .then_some(ring)
+ .ok_or(BasementFoundationError::InvalidContour)
+}
+
+fn inner_ring_without_closure(ring: &[[f32; 3]]) -> Vec<[f32; 3]> {
+ let mut result = ring.to_vec();
+ if result.len() > 1 && same_xy(result[0], *result.last().expect("nonempty")) {
+ result.pop();
+ }
+ result
+}
+
+fn triangulate_annulus(
+ outer: &[[f32; 3]],
+ inner: Vec<[f32; 3]>,
+) -> Result<Vec<[[f32; 3]; 3]>, BasementFoundationError> {
+ let mut outer = remove_consecutive_duplicates(outer);
+ let mut inner = remove_consecutive_duplicates(&inner);
+ if outer.len() < 3 || inner.len() < 3 || !nested_rings_3d(&outer, &inner) {
+ return Err(BasementFoundationError::InvalidContour);
+ }
+ if signed_area_3d(&outer) < 0.0 {
+ outer.reverse();
+ }
+ if signed_area_3d(&inner) > 0.0 {
+ inner.reverse();
+ }
+ let polygon = bridge_hole(&outer, &inner)?;
+ let output =
+ legalize_constrained_edges(ear_clip_weakly_simple_polygon(&polygon)?, &outer, &inner)?;
+ if output.iter().any(|triangle| {
+ !triangle.iter().flatten().all(|value| value.is_finite())
+ || cross2(xy(triangle[0]), xy(triangle[1]), xy(triangle[2])).abs() <= 1.0e-9
+ }) {
+ return Err(BasementFoundationError::InvalidContour);
+ }
+ Ok(output)
+}
+
+fn bridge_hole(
+ outer: &[[f32; 3]],
+ inner: &[[f32; 3]],
+) -> Result<Vec<[f32; 3]>, BasementFoundationError> {
+ let hole_index = (0..inner.len())
+ .max_by(|left, right| {
+ inner[*left][0]
+ .total_cmp(&inner[*right][0])
+ .then_with(|| inner[*right][1].total_cmp(&inner[*left][1]))
+ })
+ .ok_or(BasementFoundationError::InvalidContour)?;
+ let hole = inner[hole_index];
+ let mut candidates = (0..outer.len())
+ .map(|index| (distance_squared(xy(hole), xy(outer[index])), index))
+ .collect::<Vec<_>>();
+ candidates.sort_by(|left, right| {
+ left.0
+ .total_cmp(&right.0)
+ .then_with(|| outer[right.1][0].total_cmp(&outer[left.1][0]))
+ });
+ let mut bridged_outer = None;
+ for (_, candidate_index) in candidates {
+ let candidate = outer[candidate_index];
+ if visible_bridge(xy(hole), xy(candidate), outer, inner) {
+ bridged_outer = Some((outer.to_vec(), candidate_index));
+ break;
+ }
+ }
+ let (outer, outer_index) = bridged_outer.ok_or(BasementFoundationError::InvalidContour)?;
+ let mut polygon = Vec::with_capacity(outer.len() + inner.len() + 2);
+ for step in 0..outer.len() {
+ let index = (outer_index + step) % outer.len();
+ polygon.push(outer[index]);
+ if step == 0 {
+ polygon.push(inner[hole_index]);
+ for hole_step in 1..inner.len() {
+ polygon.push(inner[(hole_index + hole_step) % inner.len()]);
+ }
+ polygon.push(inner[hole_index]);
+ polygon.push(outer[outer_index]);
+ }
+ }
+ Ok(polygon)
+}
+
+fn visible_bridge(
+ hole: [f64; 2],
+ outer: [f64; 2],
+ outer_ring: &[[f32; 3]],
+ inner_ring: &[[f32; 3]],
+) -> bool {
+ let midpoint = [(hole[0] + outer[0]) * 0.5, (hole[1] + outer[1]) * 0.5];
+ let outer_xy = outer_ring.iter().copied().map(xy).collect::<Vec<_>>();
+ let inner_xy = inner_ring.iter().copied().map(xy).collect::<Vec<_>>();
+ if !point_in_ring(midpoint, &outer_xy) || point_in_ring(midpoint, &inner_xy) {
+ return false;
+ }
+ if (0..outer_xy.len()).any(|index| {
+ segment_crosses_open_edge(
+ hole,
+ outer,
+ outer_xy[index],
+ outer_xy[(index + 1) % outer_xy.len()],
+ )
+ }) {
+ return false;
+ }
+ !(0..inner_xy.len()).any(|index| {
+ segment_crosses_open_edge(
+ hole,
+ outer,
+ inner_xy[index],
+ inner_xy[(index + 1) % inner_xy.len()],
+ )
+ })
+}
+
+fn segment_crosses_open_edge(
+ first: [f64; 2],
+ second: [f64; 2],
+ edge_first: [f64; 2],
+ edge_second: [f64; 2],
+) -> bool {
+ if same_point(first, edge_first)
+ || same_point(first, edge_second)
+ || same_point(second, edge_first)
+ || same_point(second, edge_second)
+ {
+ return false;
+ }
+ segments_intersect(first, second, edge_first, edge_second)
+}
+
+fn same_point(first: [f64; 2], second: [f64; 2]) -> bool {
+ distance_squared(first, second) <= POINT_EPSILON_SQUARED
+}
+
+fn ear_clip_weakly_simple_polygon(
+ polygon: &[[f32; 3]],
+) -> Result<Vec<[[f32; 3]; 3]>, BasementFoundationError> {
+ let mut remaining = (0..polygon.len()).collect::<Vec<_>>();
+ let mut triangles = Vec::with_capacity(polygon.len().saturating_sub(2));
+ while remaining.len() > 3 {
+ let mut ear = None;
+ for slot in 0..remaining.len() {
+ let previous = remaining[(slot + remaining.len() - 1) % remaining.len()];
+ let current = remaining[slot];
+ let next = remaining[(slot + 1) % remaining.len()];
+ let a = xy(polygon[previous]);
+ let b = xy(polygon[current]);
+ let c = xy(polygon[next]);
+ if cross2(a, b, c) <= 1.0e-9 || !diagonal_is_clear(previous, next, &remaining, polygon)
+ {
+ continue;
+ }
+ let contains_point = remaining.iter().copied().any(|candidate| {
+ candidate != previous
+ && candidate != current
+ && candidate != next
+ && !same_xy(polygon[candidate], polygon[previous])
+ && !same_xy(polygon[candidate], polygon[current])
+ && !same_xy(polygon[candidate], polygon[next])
+ && point_in_or_on_triangle(xy(polygon[candidate]), a, b, c)
+ });
+ if !contains_point {
+ ear = Some((slot, [polygon[previous], polygon[current], polygon[next]]));
+ break;
+ }
+ }
+ if let Some((slot, triangle)) = ear {
+ triangles.push(triangle);
+ remaining.remove(slot);
+ } else {
+ return Err(BasementFoundationError::InvalidContour);
+ }
+ }
+ if remaining.len() == 3 {
+ let triangle = [
+ polygon[remaining[0]],
+ polygon[remaining[1]],
+ polygon[remaining[2]],
+ ];
+ if cross2(xy(triangle[0]), xy(triangle[1]), xy(triangle[2])) > 1.0e-9 {
+ triangles.push(triangle);
+ }
+ }
+ (!triangles.is_empty())
+ .then_some(triangles)
+ .ok_or(BasementFoundationError::InvalidContour)
+}
+
+fn diagonal_is_clear(
+ first_index: usize,
+ second_index: usize,
+ polygon_indices: &[usize],
+ polygon: &[[f32; 3]],
+) -> bool {
+ let first = xy(polygon[first_index]);
+ let second = xy(polygon[second_index]);
+ for edge in 0..polygon_indices.len() {
+ let edge_first_index = polygon_indices[edge];
+ let edge_second_index = polygon_indices[(edge + 1) % polygon_indices.len()];
+ if edge_first_index == first_index
+ || edge_second_index == first_index
+ || edge_first_index == second_index
+ || edge_second_index == second_index
+ {
+ continue;
+ }
+ if segment_crosses_open_edge(
+ first,
+ second,
+ xy(polygon[edge_first_index]),
+ xy(polygon[edge_second_index]),
+ ) {
+ return false;
+ }
+ }
+ true
+}
+
+fn point_in_or_on_triangle(point: [f64; 2], a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> bool {
+ let epsilon = 1.0e-9;
+ cross2(a, b, point) >= -epsilon
+ && cross2(b, c, point) >= -epsilon
+ && cross2(c, a, point) >= -epsilon
+}
+
+fn nested_rings(outer: &[[f64; 2]], inner: &[[f64; 2]]) -> bool {
+ if !simple_ring(outer) || !simple_ring(inner) {
+ return false;
+ }
+ if rings_intersect(outer, inner) {
+ return false;
+ }
+ point_in_ring(inner[0], outer)
+}
+
+fn nested_rings_3d(outer: &[[f32; 3]], inner: &[[f32; 3]]) -> bool {
+ let outer_xy = outer.iter().copied().map(xy).collect::<Vec<_>>();
+ let inner_xy = inner.iter().copied().map(xy).collect::<Vec<_>>();
+ nested_rings(&outer_xy, &inner_xy)
+}
+
+fn remove_consecutive_duplicates(ring: &[[f32; 3]]) -> Vec<[f32; 3]> {
+ let mut result = Vec::with_capacity(ring.len());
+ for point in ring {
+ if result
+ .last()
+ .is_none_or(|previous| !same_xy(*previous, *point))
+ {
+ result.push(*point);
+ }
+ }
+ if result.len() > 1 && same_xy(result[0], *result.last().expect("nonempty")) {
+ result.pop();
+ }
+ result
+}
+
+fn simple_ring(ring: &[[f64; 2]]) -> bool {
+ if ring.len() < 3 || ring.iter().flatten().any(|value| !value.is_finite()) {
+ return false;
+ }
+ let area = ring_area(ring);
+ if !area.is_finite() || area.abs() <= 1.0e-10 {
+ return false;
+ }
+ for first in 0..ring.len() {
+ let first_next = (first + 1) % ring.len();
+ if distance_squared(ring[first], ring[first_next]) <= POINT_EPSILON_SQUARED {
+ return false;
+ }
+ for second in (first + 1)..ring.len() {
+ let second_next = (second + 1) % ring.len();
+ if first == second || first_next == second || second_next == first {
+ continue;
+ }
+ if segments_intersect(
+ ring[first],
+ ring[first_next],
+ ring[second],
+ ring[second_next],
+ ) {
+ return false;
+ }
+ }
+ }
+ true
+}
+
+fn rings_intersect(first: &[[f64; 2]], second: &[[f64; 2]]) -> bool {
+ (0..first.len()).any(|first_index| {
+ (0..second.len()).any(|second_index| {
+ segments_intersect(
+ first[first_index],
+ first[(first_index + 1) % first.len()],
+ second[second_index],
+ second[(second_index + 1) % second.len()],
+ )
+ })
+ })
+}
+
+fn rings_overlap(first: &[[f64; 2]], second: &[[f64; 2]]) -> bool {
+ rings_intersect(first, second)
+ || point_in_ring(first[0], second)
+ || point_in_ring(second[0], first)
+}
+
+fn point_in_ring(point: [f64; 2], ring: &[[f64; 2]]) -> bool {
+ let mut inside = false;
+ for index in 0..ring.len() {
+ let first = ring[index];
+ let second = ring[(index + 1) % ring.len()];
+ if point_on_segment(point, first, second) {
+ return true;
+ }
+ if (first[1] > point[1]) != (second[1] > point[1]) {
+ let intersection_x =
+ first[0] + (point[1] - first[1]) * (second[0] - first[0]) / (second[1] - first[1]);
+ if point[0] < intersection_x {
+ inside = !inside;
+ }
+ }
+ }
+ inside
+}
+
+fn point_on_segment(point: [f64; 2], first: [f64; 2], second: [f64; 2]) -> bool {
+ let dx = second[0] - first[0];
+ let dy = second[1] - first[1];
+ let length = (dx * dx + dy * dy).sqrt();
+ cross2(first, second, point).abs() <= LINE_EPSILON * length
+ && point[0] >= first[0].min(second[0]) - LINE_EPSILON
+ && point[0] <= first[0].max(second[0]) + LINE_EPSILON
+ && point[1] >= first[1].min(second[1]) - LINE_EPSILON
+ && point[1] <= first[1].max(second[1]) + LINE_EPSILON
+}
+
+fn segments_intersect(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool {
+ let ab_c = cross2(a, b, c);
+ let ab_d = cross2(a, b, d);
+ let cd_a = cross2(c, d, a);
+ let cd_b = cross2(c, d, b);
+ let epsilon = 1.0e-8;
+ (ab_c.abs() <= epsilon && on_segment(c, a, b, epsilon))
+ || (ab_d.abs() <= epsilon && on_segment(d, a, b, epsilon))
+ || (cd_a.abs() <= epsilon && on_segment(a, c, d, epsilon))
+ || (cd_b.abs() <= epsilon && on_segment(b, c, d, epsilon))
+ || (ab_c * ab_d < 0.0 && cd_a * cd_b < 0.0)
+}
+
+fn on_segment(point: [f64; 2], first: [f64; 2], second: [f64; 2], epsilon: f64) -> bool {
+ point[0] >= first[0].min(second[0]) - epsilon
+ && point[0] <= first[0].max(second[0]) + epsilon
+ && point[1] >= first[1].min(second[1]) - epsilon
+ && point[1] <= first[1].max(second[1]) + epsilon
+}
+
+fn ring_area(ring: &[[f64; 2]]) -> f64 {
+ (0..ring.len())
+ .map(|index| {
+ let first = ring[index];
+ let second = ring[(index + 1) % ring.len()];
+ first[0] * second[1] - second[0] * first[1]
+ })
+ .sum::<f64>()
+ * 0.5
+}
+
+fn signed_area_3d(ring: &[[f32; 3]]) -> f64 {
+ ring_area(&ring.iter().copied().map(xy).collect::<Vec<_>>())
+}
+
+fn xy(position: [f32; 3]) -> [f64; 2] {
+ [f64::from(position[0]), f64::from(position[1])]
+}
+
+fn same_xy(first: [f32; 3], second: [f32; 3]) -> bool {
+ distance_squared(xy(first), xy(second)) <= POINT_EPSILON_SQUARED
+}
+
+fn cross2(first: [f64; 2], second: [f64; 2], third: [f64; 2]) -> f64 {
+ (second[0] - first[0]) * (third[1] - first[1]) - (second[1] - first[1]) * (third[0] - first[0])
+}
+
+fn distance_squared(first: [f64; 2], second: [f64; 2]) -> f64 {
+ let dx = first[0] - second[0];
+ let dy = first[1] - second[1];
+ dx * dx + dy * dy
+}
+
+pub(crate) fn basement_surface_mask(positions: [[f32; 3]; 3]) -> FullSurfaceMask {
+ let first = positions[1];
+ let origin = positions[0];
+ let second = positions[2];
+ let normal_z = (first[0] - origin[0]) * (second[1] - origin[1])
+ - (first[1] - origin[1]) * (second[0] - origin[0]);
+ FullSurfaceMask(if normal_z < 0.0 { 0x0300 } else { 0x8300 })
+}
+
+fn legalize_constrained_edges(
+ mut triangles: Vec<[[f32; 3]; 3]>,
+ outer: &[[f32; 3]],
+ inner: &[[f32; 3]],
+) -> Result<Vec<[[f32; 3]; 3]>, BasementFoundationError> {
+ let max_flips = triangles
+ .len()
+ .saturating_mul(triangles.len())
+ .saturating_mul(4)
+ .max(1);
+ for _ in 0..max_flips {
+ let mut flipped = false;
+ 'pairs: for first in 0..triangles.len() {
+ for second in (first + 1)..triangles.len() {
+ let Some((a, b, c, d)) = shared_edge(triangles[first], triangles[second]) else {
+ continue;
+ };
+ if ring_has_edge(outer, a, b) || ring_has_edge(inner, a, b) {
+ continue;
+ }
+ if !convex_quadrilateral(a, b, c, d) || in_circle(a, b, c, d) <= 1.0e-8 {
+ continue;
+ }
+ let first_triangle = orient_ccw([c, d, b]);
+ let second_triangle = orient_ccw([d, c, a]);
+ if cross2(
+ xy(first_triangle[0]),
+ xy(first_triangle[1]),
+ xy(first_triangle[2]),
+ ) <= 1.0e-9
+ || cross2(
+ xy(second_triangle[0]),
+ xy(second_triangle[1]),
+ xy(second_triangle[2]),
+ ) <= 1.0e-9
+ {
+ return Err(BasementFoundationError::InvalidContour);
+ }
+ triangles[first] = first_triangle;
+ triangles[second] = second_triangle;
+ flipped = true;
+ break 'pairs;
+ }
+ }
+ if !flipped {
+ return Ok(triangles);
+ }
+ }
+ Err(BasementFoundationError::InvalidContour)
+}
+
+fn shared_edge(
+ first: [[f32; 3]; 3],
+ second: [[f32; 3]; 3],
+) -> Option<([f32; 3], [f32; 3], [f32; 3], [f32; 3])> {
+ for edge in 0..3 {
+ let a = first[edge];
+ let b = first[(edge + 1) % 3];
+ if let Some(opposite) = second
+ .iter()
+ .copied()
+ .find(|point| !same_xy(*point, a) && !same_xy(*point, b))
+ {
+ let has_a = second.iter().any(|point| same_xy(*point, a));
+ let has_b = second.iter().any(|point| same_xy(*point, b));
+ if has_a && has_b {
+ let first_opposite = first[(edge + 2) % 3];
+ return Some((a, b, first_opposite, opposite));
+ }
+ }
+ }
+ None
+}
+
+fn ring_has_edge(ring: &[[f32; 3]], a: [f32; 3], b: [f32; 3]) -> bool {
+ (0..ring.len()).any(|index| {
+ let first = ring[index];
+ let second = ring[(index + 1) % ring.len()];
+ (same_xy(first, a) && same_xy(second, b)) || (same_xy(first, b) && same_xy(second, a))
+ })
+}
+
+fn convex_quadrilateral(a: [f32; 3], b: [f32; 3], c: [f32; 3], d: [f32; 3]) -> bool {
+ let epsilon = 1.0e-9;
+ let c_side = cross2(xy(a), xy(b), xy(c));
+ let d_side = cross2(xy(a), xy(b), xy(d));
+ let a_side = cross2(xy(c), xy(d), xy(a));
+ let b_side = cross2(xy(c), xy(d), xy(b));
+ c_side * d_side < -epsilon && a_side * b_side < -epsilon
+}
+
+fn in_circle(a: [f32; 3], b: [f32; 3], c: [f32; 3], d: [f32; 3]) -> f64 {
+ let a = [
+ f64::from(a[0]) - f64::from(d[0]),
+ f64::from(a[1]) - f64::from(d[1]),
+ ];
+ let b = [
+ f64::from(b[0]) - f64::from(d[0]),
+ f64::from(b[1]) - f64::from(d[1]),
+ ];
+ let c = [
+ f64::from(c[0]) - f64::from(d[0]),
+ f64::from(c[1]) - f64::from(d[1]),
+ ];
+ (a[0] * a[0] + a[1] * a[1]) * (b[0] * c[1] - b[1] * c[0])
+ - (b[0] * b[0] + b[1] * b[1]) * (a[0] * c[1] - a[1] * c[0])
+ + (c[0] * c[0] + c[1] * c[1]) * (a[0] * b[1] - a[1] * b[0])
+}
+
+fn orient_ccw(mut triangle: [[f32; 3]; 3]) -> [[f32; 3]; 3] {
+ if cross2(xy(triangle[0]), xy(triangle[1]), xy(triangle[2])) < 0.0 {
+ triangle.swap(1, 2);
+ }
+ triangle
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_terrain_format::{TerrainFace28, TerrainSlotTable};
+
+ #[test]
+ fn foundation_samples_lod0_planes_and_flattens_or_preserves_authored_inner_z() {
+ let mesh = two_lod_mesh();
+ let outer = [[0.4, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0], [0.4, 0.0]];
+ let inner = [
+ [1.0, 1.0, 10.0],
+ [3.0, 1.0, 11.0],
+ [3.0, 3.0, 12.0],
+ [1.0, 3.0, 13.0],
+ [1.0, 1.0, 10.0],
+ ];
+ let (open_outer, _) = normalize_ring(&outer).expect("outer ring");
+ let snapped = normalize_outer_points(&open_outer, &[0, 1, 2], &mesh)
+ .expect("outer points lie within LOD0");
+ assert_eq!(snapped.first(), Some(&[0.0, 0.0]));
+ assert!(!snapped.contains(&[2.0, 0.0]));
+ assert!(native_segment_inserts_vertex(
+ [0.0, 0.0],
+ [4.0, 0.0],
+ [3.96875, 0.4991, 12.0]
+ ));
+ assert!(!native_segment_inserts_vertex(
+ [0.0, 0.0],
+ [4.0, 0.0],
+ [2.0, 0.0, 12.0]
+ ));
+ let terrain = [
+ (
+ 0,
+ mesh.faces[0].flags,
+ terrain_face_positions(&mesh, 0).unwrap(),
+ ),
+ (
+ 1,
+ mesh.faces[1].flags,
+ terrain_face_positions(&mesh, 1).unwrap(),
+ ),
+ (
+ 2,
+ mesh.faces[2].flags,
+ terrain_face_positions(&mesh, 2).unwrap(),
+ ),
+ ];
+ let crossings = contour_crossings(&snapped, &terrain).expect("LOD0 crossings");
+ assert!(crossings.len() > 4);
+ assert_eq!(crossings.first().unwrap()[2], 0.0);
+ assert!(crossings
+ .iter()
+ .any(|crossing| same_xy(*crossing, [2.0, 0.0, 2.0])));
+ let expected_mean =
+ crossings.iter().map(|crossing| crossing[2]).sum::<f32>() / crossings.len() as f32;
+
+ let flat = build_basement_foundations(
+ &mesh,
+ &[BasementFootprint {
+ outer: &outer,
+ inner: &inner,
+ flatten_inner_to_mean_outer_z: true,
+ }],
+ )
+ .expect("flat foundation");
+ let placement = flat.placements[0];
+ assert!((placement.placement_delta - (10.0 - expected_mean)).abs() < 1.0e-6);
+ let flat_band = flat
+ .fragments
+ .iter()
+ .filter(|fragment| fragment.source_face.is_none())
+ .collect::<Vec<_>>();
+ assert!(flat
+ .fragments
+ .iter()
+ .filter_map(|fragment| fragment.source_face)
+ .all(|face| face < 3));
+ assert!(!flat_band.is_empty());
+ assert!(flat_band
+ .iter()
+ .all(|fragment| fragment.surface_mask == FullSurfaceMask(0x8300)));
+ for point in &inner[..4] {
+ assert!(flat_band
+ .iter()
+ .flat_map(|fragment| fragment.positions)
+ .any(|vertex| {
+ same_xy(vertex, *point) && (vertex[2] - expected_mean).abs() < 1.0e-6
+ }));
+ }
+ let flat_area = flat_band
+ .iter()
+ .map(|fragment| {
+ let [a, b, c] = fragment.positions;
+ cross2(xy(a), xy(b), xy(c)).abs() * 0.5
+ })
+ .sum::<f64>();
+ assert!((flat_area - 12.0).abs() < 1.0e-5);
+
+ let authored = build_basement_foundations(
+ &mesh,
+ &[BasementFootprint {
+ outer: &outer,
+ inner: &inner,
+ flatten_inner_to_mean_outer_z: false,
+ }],
+ )
+ .expect("authored-height foundation");
+ let authored_band = authored
+ .fragments
+ .iter()
+ .filter(|fragment| fragment.source_face.is_none())
+ .collect::<Vec<_>>();
+ for point in &inner[..4] {
+ assert!(authored_band
+ .iter()
+ .flat_map(|fragment| fragment.positions)
+ .any(|vertex| {
+ same_xy(vertex, *point) && (vertex[2] - point[2]).abs() < 1.0e-6
+ }));
+ }
+ }
+
+ #[test]
+ fn foundation_rejects_outer_contours_outside_or_across_uncovered_terrain() {
+ let mesh = two_lod_mesh();
+ let outside = [[5.0, 1.0], [5.5, 1.0], [5.0, 1.5]];
+ let inner = [[5.1, 1.1, 0.0], [5.3, 1.1, 0.0], [5.1, 1.3, 0.0]];
+ assert_eq!(
+ build_basement_foundations(
+ &mesh,
+ &[BasementFootprint {
+ outer: &outside,
+ inner: &inner,
+ flatten_inner_to_mean_outer_z: true,
+ }],
+ ),
+ Err(BasementFoundationError::OuterPointOutsideLod0 { point: 0 })
+ );
+
+ let mut islands = two_lod_mesh();
+ islands.positions = vec![
+ [0.0, 0.0, 0.0],
+ [1.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0],
+ [2.0, 2.0, 0.0],
+ [3.0, 2.0, 0.0],
+ [2.0, 3.0, 0.0],
+ ];
+ islands.faces.truncate(2);
+ islands.faces[0].vertices = [0, 1, 2];
+ islands.faces[1].vertices = [3, 4, 5];
+ islands.slots.slots_raw[0][2..4].copy_from_slice(&2_u16.to_le_bytes());
+ islands.slots.slots_raw[1][0..2].copy_from_slice(&2_u16.to_le_bytes());
+ islands.slots.slots_raw[1][2..4].copy_from_slice(&0_u16.to_le_bytes());
+ let outer = [[0.1, 0.1], [0.7, 0.1], [2.1, 2.1]];
+ let inner = [[0.2, 0.2, 0.0], [0.4, 0.2, 0.0], [0.2, 0.4, 0.0]];
+ assert_eq!(
+ build_basement_foundations(
+ &islands,
+ &[BasementFootprint {
+ outer: &outer,
+ inner: &inner,
+ flatten_inner_to_mean_outer_z: true,
+ }],
+ ),
+ Err(BasementFoundationError::UncoveredOuterSegment { segment: 1 })
+ );
+ }
+
+ #[test]
+ fn shared_diagonal_crossing_is_exact_and_counted_once() {
+ let first = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 1.0]];
+ let second = [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [0.0, 1.0, 2.0]];
+ let edge_start = [0.8, 0.5];
+ let edge_end = [0.5, 0.8];
+ let first_interval =
+ segment_triangle_interval(edge_start, edge_end, first).expect("first face interval");
+ let second_interval =
+ segment_triangle_interval(edge_start, edge_end, second).expect("second face interval");
+ assert!((first_interval.1 - 0.5).abs() < 1.0e-9);
+ assert!((second_interval.0 - 0.5).abs() < 1.0e-9);
+
+ let ring = [[0.5, 0.2], [0.8, 0.5], [0.5, 0.8], [0.2, 0.5]];
+ let crossings = contour_crossings(
+ &ring,
+ &[
+ (0, FullSurfaceMask(1), first),
+ (1, FullSurfaceMask(2), second),
+ ],
+ )
+ .expect("shared-edge crossings");
+ let shared = crossings
+ .iter()
+ .filter(|crossing| {
+ distance_squared(
+ [f64::from(crossing[0]), f64::from(crossing[1])],
+ [0.65, 0.65],
+ ) <= POINT_EPSILON_SQUARED
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(
+ shared.len(),
+ 1,
+ "shared-edge crossing duplicated: {shared:?}"
+ );
+ assert!((shared[0][2] - 0.65).abs() < 1.0e-6);
+ }
+
+ #[test]
+ fn annulus_preserves_collinear_outer_height_breakpoints_and_is_constrained_delaunay() {
+ let outer = [
+ [0.0, 0.0, 0.0],
+ [2.0, 0.0, 3.0],
+ [4.0, 0.0, 10.0],
+ [4.0, 4.0, 12.0],
+ [0.0, 4.0, 0.0],
+ ];
+ let inner = vec![
+ [1.0, 1.0, 5.0],
+ [3.0, 1.0, 5.0],
+ [3.0, 3.0, 5.0],
+ [1.0, 3.0, 5.0],
+ ];
+ let triangles = triangulate_annulus(&outer, inner.clone()).expect("annulus");
+ for edge in outer.windows(2) {
+ assert!(
+ triangles
+ .iter()
+ .any(|triangle| has_xy_edge(*triangle, edge[0], edge[1])),
+ "missing outer boundary segment {:?} -> {:?}",
+ edge[0],
+ edge[1]
+ );
+ }
+ for point in outer {
+ assert!(
+ triangles
+ .iter()
+ .flat_map(|triangle| triangle.iter())
+ .any(|vertex| {
+ same_xy(*vertex, point) && (vertex[2] - point[2]).abs() < 1.0e-6
+ }),
+ "lost outer breakpoint {point:?}"
+ );
+ }
+ assert_locally_delaunay(&triangles, &outer, &inner);
+ }
+
+ #[test]
+ fn constrained_annulus_handles_concave_rings_with_different_vertex_counts() {
+ let outer = [
+ [0.0, 0.0, 1.0],
+ [10.0, 0.0, 2.0],
+ [10.0, 3.0, 3.0],
+ [4.0, 3.0, 4.0],
+ [4.0, 10.0, 5.0],
+ [0.0, 10.0, 6.0],
+ ];
+ let inner = vec![
+ [1.0, 1.0, 3.0],
+ [3.0, 1.0, 3.0],
+ [3.0, 1.8, 3.0],
+ [2.2, 1.8, 3.0],
+ [2.2, 2.4, 3.0],
+ [3.0, 2.4, 3.0],
+ [3.0, 3.0, 3.0],
+ [1.0, 3.0, 3.0],
+ ];
+ let band = triangulate_annulus(&outer, inner.clone()).expect("constrained annulus");
+ let area = band
+ .iter()
+ .map(|triangle| cross2(xy(triangle[0]), xy(triangle[1]), xy(triangle[2])).abs() * 0.5)
+ .sum::<f64>();
+ let expected = ring_area(&outer.iter().copied().map(xy).collect::<Vec<_>>()).abs()
+ - ring_area(&inner.iter().copied().map(xy).collect::<Vec<_>>()).abs();
+ assert!(
+ (area - expected).abs() < 1.0e-5,
+ "area {area}, expected {expected}"
+ );
+
+ let outer_xy = outer.iter().copied().map(xy).collect::<Vec<_>>();
+ let inner_xy = inner.iter().copied().map(xy).collect::<Vec<_>>();
+ for triangle in &band {
+ let centroid = triangle_centroid(*triangle);
+ assert!(point_in_ring(centroid, &outer_xy));
+ assert!(!point_in_ring(centroid, &inner_xy));
+ }
+ for first in 0..band.len() {
+ for second in (first + 1)..band.len() {
+ assert!(!positive_area_overlap(band[first], band[second]));
+ }
+ }
+ assert_locally_delaunay(&band, &outer, &inner);
+ }
+
+ #[test]
+ fn multiple_disjoint_footprints_clip_canonical_outside_once() {
+ let mesh = two_lod_mesh();
+ let first_outer = [[0.6, 0.6], [1.7, 0.6], [1.7, 1.7], [0.6, 1.7]];
+ let first_inner = [
+ [0.9, 0.9, 5.0],
+ [1.4, 0.9, 5.0],
+ [1.4, 1.4, 5.0],
+ [0.9, 1.4, 5.0],
+ ];
+ let second_outer = [[2.3, 2.3], [3.4, 2.3], [3.4, 3.4], [2.3, 3.4]];
+ let second_inner = [
+ [2.6, 2.6, 7.0],
+ [3.1, 2.6, 7.0],
+ [3.1, 3.1, 7.0],
+ [2.6, 3.1, 7.0],
+ ];
+ let output = build_basement_foundations(
+ &mesh,
+ &[
+ BasementFootprint {
+ outer: &first_outer,
+ inner: &first_inner,
+ flatten_inner_to_mean_outer_z: true,
+ },
+ BasementFootprint {
+ outer: &second_outer,
+ inner: &second_inner,
+ flatten_inner_to_mean_outer_z: true,
+ },
+ ],
+ )
+ .expect("two disjoint foundations");
+ assert_eq!(output.placements.len(), 2);
+ let terrain = output
+ .fragments
+ .iter()
+ .filter(|fragment| fragment.source_face.is_some())
+ .collect::<Vec<_>>();
+ let bands = output
+ .fragments
+ .iter()
+ .filter(|fragment| fragment.source_face.is_none())
+ .collect::<Vec<_>>();
+ assert!(!terrain.is_empty());
+ assert!(!bands.is_empty());
+ for fragment in &terrain {
+ let centroid = triangle_centroid(fragment.positions);
+ assert!(!point_strictly_in_ring(centroid, &ring_f64(&first_outer)));
+ assert!(!point_strictly_in_ring(centroid, &ring_f64(&second_outer)));
+ }
+ for first in 0..terrain.len() {
+ for second in (first + 1)..terrain.len() {
+ assert!(!positive_area_overlap(
+ terrain[first].positions,
+ terrain[second].positions
+ ));
+ }
+ }
+ for band in bands {
+ let centroid = triangle_centroid(band.positions);
+ assert!(
+ point_in_ring(centroid, &ring_f64(&first_outer))
+ || point_in_ring(centroid, &ring_f64(&second_outer))
+ );
+ assert!(!point_in_ring(
+ centroid,
+ &ring_f64(&first_inner.iter().map(|p| [p[0], p[1]]).collect::<Vec<_>>())
+ ));
+ assert!(!point_in_ring(
+ centroid,
+ &ring_f64(
+ &second_inner
+ .iter()
+ .map(|p| [p[0], p[1]])
+ .collect::<Vec<_>>()
+ )
+ ));
+ }
+ }
+
+ #[test]
+ fn overlapping_footprints_are_explicitly_rejected() {
+ let mesh = two_lod_mesh();
+ let outer_a = [[0.5, 0.5], [2.5, 0.5], [2.5, 2.5], [0.5, 2.5]];
+ let inner_a = [
+ [1.0, 1.0, 3.0],
+ [2.0, 1.0, 3.0],
+ [2.0, 2.0, 3.0],
+ [1.0, 2.0, 3.0],
+ ];
+ let outer_b = [[1.5, 1.5], [3.5, 1.5], [3.5, 3.5], [1.5, 3.5]];
+ let inner_b = [
+ [2.0, 2.0, 3.0],
+ [3.0, 2.0, 3.0],
+ [3.0, 3.0, 3.0],
+ [2.0, 3.0, 3.0],
+ ];
+ assert_eq!(
+ build_basement_foundations(
+ &mesh,
+ &[
+ BasementFootprint {
+ outer: &outer_a,
+ inner: &inner_a,
+ flatten_inner_to_mean_outer_z: true
+ },
+ BasementFootprint {
+ outer: &outer_b,
+ inner: &inner_b,
+ flatten_inner_to_mean_outer_z: true
+ },
+ ],
+ ),
+ Err(BasementFoundationError::OverlappingFootprints)
+ );
+ }
+
+ #[test]
+ fn generated_flags_follow_native_normal_z_branch() {
+ let positive = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
+ let negative = [positive[0], positive[2], positive[1]];
+ assert_eq!(basement_surface_mask(positive), FullSurfaceMask(0x8300));
+ assert_eq!(basement_surface_mask(negative), FullSurfaceMask(0x0300));
+ }
+
+ #[test]
+ fn foundation_rejects_inner_ring_that_is_not_nested() {
+ let mesh = two_lod_mesh();
+ let outer = [[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]];
+ let inner = [
+ [3.0, 1.0, 0.0],
+ [5.0, 1.0, 0.0],
+ [5.0, 3.0, 0.0],
+ [3.0, 3.0, 0.0],
+ ];
+ assert_eq!(
+ build_basement_foundations(
+ &mesh,
+ &[BasementFootprint {
+ outer: &outer,
+ inner: &inner,
+ flatten_inner_to_mean_outer_z: true
+ }],
+ ),
+ Err(BasementFoundationError::InvalidContour)
+ );
+ }
+
+ fn assert_locally_delaunay(
+ triangles: &[[[f32; 3]; 3]],
+ outer: &[[f32; 3]],
+ inner: &[[f32; 3]],
+ ) {
+ for first in 0..triangles.len() {
+ for second in (first + 1)..triangles.len() {
+ let Some((a, b, c, d)) = shared_edge(triangles[first], triangles[second]) else {
+ continue;
+ };
+ if ring_has_edge(outer, a, b)
+ || ring_has_edge(inner, a, b)
+ || !convex_quadrilateral(a, b, c, d)
+ {
+ continue;
+ }
+ assert!(
+ in_circle(a, b, c, d) <= 1.0e-8,
+ "illegal diagonal {a:?}->{b:?}"
+ );
+ }
+ }
+ }
+
+ fn has_xy_edge(triangle: [[f32; 3]; 3], first: [f32; 3], second: [f32; 3]) -> bool {
+ (0..3).any(|index| {
+ (same_xy(triangle[index], first) && same_xy(triangle[(index + 1) % 3], second))
+ || (same_xy(triangle[index], second) && same_xy(triangle[(index + 1) % 3], first))
+ })
+ }
+
+ fn triangle_centroid(triangle: [[f32; 3]; 3]) -> [f64; 2] {
+ [
+ triangle
+ .iter()
+ .map(|point| f64::from(point[0]))
+ .sum::<f64>()
+ / 3.0,
+ triangle
+ .iter()
+ .map(|point| f64::from(point[1]))
+ .sum::<f64>()
+ / 3.0,
+ ]
+ }
+
+ fn point_strictly_in_ring(point: [f64; 2], ring: &[[f64; 2]]) -> bool {
+ !((0..ring.len())
+ .any(|index| point_on_segment(point, ring[index], ring[(index + 1) % ring.len()])))
+ && point_in_ring(point, ring)
+ }
+
+ fn ring_f64(ring: &[[f32; 2]]) -> Vec<[f64; 2]> {
+ ring.iter()
+ .map(|point| [f64::from(point[0]), f64::from(point[1])])
+ .collect()
+ }
+
+ fn two_lod_mesh() -> LandMeshDocument {
+ let positions = vec![
+ [0.0, 0.0, 0.0],
+ [2.0, 0.0, 2.0],
+ [4.0, 0.0, 4.0],
+ [0.0, 4.0, 4.0],
+ [4.0, 4.0, 8.0],
+ [0.0, 0.0, 100.0],
+ [4.0, 0.0, 100.0],
+ [0.0, 4.0, 100.0],
+ ];
+ let face = |flags, vertices| TerrainFace28 {
+ flags: FullSurfaceMask(flags),
+ material_tag: 0,
+ aux_tag: 0,
+ vertices,
+ neighbors: [None; 3],
+ tail_raw: [0; 8],
+ raw: [0; 28],
+ };
+ let faces = vec![
+ face(1, [0, 1, 3]),
+ face(2, [1, 4, 3]),
+ face(4, [1, 2, 4]),
+ face(8, [5, 6, 7]),
+ ];
+ let mut nodes_raw = vec![u8::MAX; 38];
+ nodes_raw[8..10].copy_from_slice(&0_u16.to_le_bytes());
+ nodes_raw[10..12].copy_from_slice(&1_u16.to_le_bytes());
+ let mut slots_raw = vec![[0_u8; 68]; 2];
+ slots_raw[0][2..4].copy_from_slice(&3_u16.to_le_bytes());
+ slots_raw[1][0..2].copy_from_slice(&3_u16.to_le_bytes());
+ slots_raw[1][2..4].copy_from_slice(&1_u16.to_le_bytes());
+ LandMeshDocument {
+ streams: Vec::new(),
+ nodes_raw,
+ slots: TerrainSlotTable {
+ header_raw: Vec::new(),
+ slots_raw,
+ },
+ positions,
+ normals: Vec::new(),
+ uv0: Vec::new(),
+ accelerator: Vec::new(),
+ aux14: Vec::new(),
+ aux18: Vec::new(),
+ faces,
+ }
+ }
+
+ fn positive_area_overlap(first: [[f32; 3]; 3], second: [[f32; 3]; 3]) -> bool {
+ for triangle in [first, second] {
+ for edge in 0..3 {
+ let a = xy(triangle[edge]);
+ let b = xy(triangle[(edge + 1) % 3]);
+ let axis = [b[1] - a[1], a[0] - b[0]];
+ let project = |vertices: [[f32; 3]; 3]| {
+ vertices.map(|vertex| {
+ let point = xy(vertex);
+ point[0] * axis[0] + point[1] * axis[1]
+ })
+ };
+ let first_projection = project(first);
+ let second_projection = project(second);
+ let first_min = first_projection
+ .iter()
+ .copied()
+ .fold(f64::INFINITY, f64::min);
+ let first_max = first_projection
+ .iter()
+ .copied()
+ .fold(f64::NEG_INFINITY, f64::max);
+ let second_min = second_projection
+ .iter()
+ .copied()
+ .fold(f64::INFINITY, f64::min);
+ let second_max = second_projection
+ .iter()
+ .copied()
+ .fold(f64::NEG_INFINITY, f64::max);
+ if first_max <= second_min + 1.0e-8 || second_max <= first_min + 1.0e-8 {
+ return false;
+ }
+ }
+ }
+ true
+ }
+}
diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs
index a014b61..54946e7 100644
--- a/crates/fparkan-terrain/src/lib.rs
+++ b/crates/fparkan-terrain/src/lib.rs
@@ -4,6 +4,15 @@
use fparkan_terrain_format::{LandMapDocument, LandMeshDocument};
use std::collections::VecDeque;
+mod contour;
+mod foundation;
+
+use foundation::basement_surface_mask;
+pub use foundation::{
+ build_basement_foundations, BasementFootprint, BasementFoundation, BasementFoundationError,
+ BasementFoundationFragment, BasementPlacement,
+};
+
/// Terrain material-selector contract preserved from the decoded map mesh.
///
/// Applications access this semantic terrain API through this terrain crate
@@ -21,6 +30,7 @@ pub struct TerrainWorld {
surfaces: Vec<RuntimeTriangle>,
surface_bvh: SurfaceBvh,
source_mesh: Option<LandMeshDocument>,
+ basement_foundation: Option<BasementFoundation>,
}
/// Surface hit.
@@ -91,6 +101,13 @@ pub enum TerrainError {
/// Position count.
position_count: usize,
},
+ /// A basement fragment does not preserve a valid LOD0 source face and mask.
+ InvalidFoundationFragment {
+ /// Fragment index.
+ fragment: usize,
+ },
+ /// Foundation fragments require a retained source Land.msh document.
+ FoundationRequiresMesh,
/// Terrain Node38/Slot68 records do not assign every face to one LOD.
InvalidLodMapping(TerrainLodError),
}
@@ -119,6 +136,12 @@ impl std::fmt::Display for TerrainError {
f,
"terrain face {face} references vertex {vertex} outside {position_count} positions"
),
+ Self::InvalidFoundationFragment { fragment } => {
+ write!(f, "invalid basement foundation fragment {fragment}")
+ }
+ Self::FoundationRequiresMesh => {
+ write!(f, "basement foundation requires a source Land.msh")
+ }
Self::InvalidLodMapping(error) => write!(f, "invalid terrain LOD mapping: {error}"),
}
}
@@ -132,7 +155,9 @@ impl std::error::Error for TerrainError {
| Self::TooManyAreals { .. }
| Self::InvalidGridReference { .. }
| Self::InvalidArealReference { .. }
- | Self::InvalidSurfaceVertex { .. } => None,
+ | Self::InvalidSurfaceVertex { .. }
+ | Self::InvalidFoundationFragment { .. }
+ | Self::FoundationRequiresMesh => None,
}
}
}
@@ -254,6 +279,7 @@ impl TerrainWorld {
surfaces: Vec::new(),
surface_bvh: SurfaceBvh::default(),
source_mesh: None,
+ basement_foundation: None,
})
}
@@ -290,6 +316,87 @@ impl TerrainWorld {
Ok(world)
}
+ /// Returns a new world whose surface queries use exactly the canonical
+ /// source and generated triangles from a basement foundation.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`TerrainError`] if this world has no source mesh or any
+ /// fragment does not preserve valid LOD0 identity, flags, and geometry.
+ pub fn with_basement_foundation(
+ mut self,
+ foundation: BasementFoundation,
+ ) -> Result<Self, TerrainError> {
+ let mesh = self
+ .source_mesh
+ .as_ref()
+ .ok_or(TerrainError::FoundationRequiresMesh)?;
+ let assignments = mesh
+ .face_lod_assignments()
+ .map_err(TerrainError::InvalidLodMapping)?;
+ let is_lod0 = assignments
+ .iter()
+ .map(|assignment| assignment.lod == TerrainLod::Lod0)
+ .collect::<Vec<_>>();
+ 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, fragment.source_face.is_none()) {
+ return Err(TerrainError::InvalidFoundationFragment {
+ fragment: fragment_index,
+ });
+ }
+ let face = if let Some(source_face) = fragment.source_face {
+ let Some(source) = mesh.faces.get(source_face) else {
+ return Err(TerrainError::InvalidFoundationFragment {
+ fragment: fragment_index,
+ });
+ };
+ if !is_lod0.get(source_face).copied().unwrap_or(false)
+ || source.flags != fragment.surface_mask
+ {
+ return Err(TerrainError::InvalidFoundationFragment {
+ fragment: fragment_index,
+ });
+ }
+ source_face
+ } else {
+ if basement_surface_mask(fragment.positions) != fragment.surface_mask {
+ return Err(TerrainError::InvalidFoundationFragment {
+ fragment: fragment_index,
+ });
+ }
+ let face = mesh.faces.len().checked_add(generated).ok_or(
+ TerrainError::InvalidFoundationFragment {
+ fragment: fragment_index,
+ },
+ )?;
+ generated =
+ generated
+ .checked_add(1)
+ .ok_or(TerrainError::InvalidFoundationFragment {
+ fragment: fragment_index,
+ })?;
+ face
+ };
+ surfaces.push(RuntimeTriangle {
+ face,
+ mask: fragment.surface_mask,
+ vertices: fragment.positions,
+ });
+ }
+ self.surfaces = surfaces;
+ self.surface_bvh = SurfaceBvh::from_surfaces(&self.surfaces);
+ self.basement_foundation = Some(foundation);
+ Ok(self)
+ }
+
+ /// Returns the canonical basement geometry used by the surface query index.
+ #[must_use]
+ pub fn basement_foundation(&self) -> Option<&BasementFoundation> {
+ self.basement_foundation.as_ref()
+ }
+
/// Returns the number of navigation areas.
#[must_use]
pub fn areal_count(&self) -> usize {
@@ -740,26 +847,31 @@ impl RuntimeTriangle {
}
fn raycast(&self, origin: [f32; 3], direction: [f32; 3]) -> Option<f32> {
- let edge1 = sub3(self.vertices[1], self.vertices[0]);
- let edge2 = sub3(self.vertices[2], self.vertices[0]);
- let pvec = cross3(direction, edge2);
- let det = dot3(edge1, pvec);
- if det.abs() <= 1.0e-6 {
+ let edge1 = sub3_f64(self.vertices[1], self.vertices[0]);
+ let edge2 = sub3_f64(self.vertices[2], self.vertices[0]);
+ let direction = direction.map(f64::from);
+ let pvec = cross3_f64(direction, edge2);
+ let det = dot3_f64(edge1, pvec);
+ if det == 0.0 || !det.is_finite() {
return None;
}
let inv_det = 1.0 / det;
- let tvec = sub3(origin, self.vertices[0]);
- let u = dot3(tvec, pvec) * inv_det;
+ let tvec = sub3_f64(origin, self.vertices[0]);
+ let u = dot3_f64(tvec, pvec) * inv_det;
if !(-1.0e-5..=1.00001).contains(&u) {
return None;
}
- let qvec = cross3(tvec, edge1);
- let v = dot3(direction, qvec) * inv_det;
+ let qvec = cross3_f64(tvec, edge1);
+ let v = dot3_f64(direction, qvec) * inv_det;
if v < -1.0e-5 || u + v > 1.00001 {
return None;
}
- let distance = dot3(edge2, qvec) * inv_det;
- (distance >= 0.0).then_some(distance)
+ let distance = dot3_f64(edge2, qvec) * inv_det;
+ if distance >= 0.0 && distance.is_finite() && distance <= f64::from(f32::MAX) {
+ Some(distance as f32)
+ } else {
+ None
+ }
}
}
@@ -919,6 +1031,23 @@ fn build_surfaces(mesh: &LandMeshDocument) -> Result<Vec<RuntimeTriangle>, Terra
Ok(triangles)
}
+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;
+ }
+ let first = [
+ f64::from(vertices[1][0]) - f64::from(vertices[0][0]),
+ f64::from(vertices[1][1]) - f64::from(vertices[0][1]),
+ ];
+ let second = [
+ f64::from(vertices[2][0]) - f64::from(vertices[0][0]),
+ f64::from(vertices[2][1]) - f64::from(vertices[0][1]),
+ ];
+ 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(
mesh: &LandMeshDocument,
face: usize,
@@ -940,25 +1069,41 @@ fn barycentric_2d(
second: [f32; 2],
third: [f32; 2],
) -> Option<[f32; 3]> {
- let edge_second = [second[0] - first[0], second[1] - first[1]];
- let edge_third = [third[0] - first[0], third[1] - first[1]];
- let point_delta = [point[0] - first[0], point[1] - first[1]];
+ let edge_second = [
+ f64::from(second[0]) - f64::from(first[0]),
+ f64::from(second[1]) - f64::from(first[1]),
+ ];
+ let edge_third = [
+ f64::from(third[0]) - f64::from(first[0]),
+ f64::from(third[1]) - f64::from(first[1]),
+ ];
+ let point_delta = [
+ f64::from(point[0]) - f64::from(first[0]),
+ f64::from(point[1]) - f64::from(first[1]),
+ ];
let denom = edge_second[0] * edge_third[1] - edge_third[0] * edge_second[1];
- if denom.abs() <= 1.0e-6 {
+ if denom == 0.0 {
return None;
}
- let inv = 1.0 / denom;
- let second_weight = (point_delta[0] * edge_third[1] - edge_third[0] * point_delta[1]) * inv;
- let third_weight = (edge_second[0] * point_delta[1] - point_delta[0] * edge_second[1]) * inv;
+ let second_weight = (point_delta[0] * edge_third[1] - edge_third[0] * point_delta[1]) / denom;
+ let third_weight = (edge_second[0] * point_delta[1] - point_delta[0] * edge_second[1]) / denom;
let first_weight = 1.0 - second_weight - third_weight;
- Some([first_weight, second_weight, third_weight])
+ let weights = [first_weight, second_weight, third_weight];
+ if weights.iter().any(|weight| !weight.is_finite()) {
+ return None;
+ }
+ Some(weights.map(|weight| weight as f32))
}
-fn sub3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
- [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
+fn sub3_f64(a: [f32; 3], b: [f32; 3]) -> [f64; 3] {
+ [
+ f64::from(a[0]) - f64::from(b[0]),
+ f64::from(a[1]) - f64::from(b[1]),
+ f64::from(a[2]) - f64::from(b[2]),
+ ]
}
-fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
+fn cross3_f64(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
@@ -966,7 +1111,7 @@ fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
]
}
-fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
+fn dot3_f64(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
@@ -1127,6 +1272,273 @@ 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};
+
+ let mesh = synthetic_land_mesh();
+ let outer = [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]];
+ let inner = [
+ [0.4, 0.4, 1.0],
+ [0.6, 0.4, 1.0],
+ [0.6, 0.6, 1.0],
+ [0.4, 0.6, 1.0],
+ ];
+ let foundation = build_basement_foundations(
+ &mesh,
+ &[BasementFootprint {
+ outer: &outer,
+ inner: &inner,
+ flatten_inner_to_mean_outer_z: true,
+ }],
+ )
+ .expect("foundation");
+ let expected_fragments = foundation.fragments.clone();
+ let expected_fragment_count = foundation.fragments.len();
+ let world = TerrainWorld::from_land_msh(&mesh)
+ .expect("source world")
+ .with_basement_foundation(foundation)
+ .expect("foundation world");
+
+ assert_eq!(world.surface_count(), expected_fragment_count);
+ assert_eq!(
+ world
+ .basement_foundation()
+ .map(|value| value.fragments.as_slice()),
+ Some(expected_fragments.as_slice())
+ );
+ assert!(world
+ .height_at([0.05, 0.05])
+ .expect("band height")
+ .is_some());
+ assert!(world.height_at([0.3, 0.3]).expect("band height").is_some());
+ assert_eq!(world.height_at([0.5, 0.5]).expect("hole height"), None);
+ assert_eq!(
+ world
+ .raycast([0.5, 0.5, 4.0], [0.0, 0.0, -1.0], FullSurfaceMask(0),)
+ .expect("hole ray"),
+ None
+ );
+
+ let mut generated_face = mesh.faces.len();
+ for (runtime, fragment) in world.surfaces.iter().zip(&expected_fragments) {
+ assert_eq!(runtime.mask, fragment.surface_mask);
+ assert_eq!(runtime.vertices, fragment.positions);
+ if let Some(source_face) = fragment.source_face {
+ assert_eq!(runtime.face, source_face);
+ assert_eq!(runtime.mask, mesh.faces[source_face].flags);
+ } else {
+ assert_eq!(runtime.face, generated_face);
+ generated_face += 1;
+ }
+ }
+ assert!(generated_face > mesh.faces.len());
+ }
+
+ #[test]
+ fn foundation_install_accepts_thin_nonzero_fragments_for_surface_queries() {
+ let mesh = synthetic_land_mesh();
+ let fragment = BasementFoundationFragment {
+ source_face: Some(0),
+ surface_mask: mesh.faces[0].flags,
+ positions: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.000_000_5, 0.0]],
+ };
+ let foundation = BasementFoundation {
+ fragments: vec![fragment],
+ placements: Vec::new(),
+ };
+ let world = TerrainWorld::from_land_msh(&mesh)
+ .expect("source world")
+ .with_basement_foundation(foundation)
+ .expect("thin but non-degenerate triangle");
+
+ assert_eq!(world.surface_count(), 1);
+ assert_eq!(
+ world.height_at([0.25, 0.000_000_125]).expect("height"),
+ Some(0.0)
+ );
+ assert!(world
+ .raycast(
+ [0.25, 0.000_000_125, 1.0],
+ [0.0, 0.0, -1.0],
+ FullSurfaceMask(0),
+ )
+ .expect("raycast")
+ .is_some());
+ }
+
+ #[test]
+ 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]];
+ let invalid = [
+ BasementFoundationFragment {
+ source_face: Some(mesh.faces.len()),
+ surface_mask: mesh.faces[0].flags,
+ positions: source_triangle,
+ },
+ BasementFoundationFragment {
+ source_face: Some(0),
+ surface_mask: FullSurfaceMask(mesh.faces[0].flags.0 ^ 1),
+ positions: source_triangle,
+ },
+ BasementFoundationFragment {
+ source_face: None,
+ 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() {
+ let foundation = BasementFoundation {
+ fragments: vec![fragment],
+ placements: Vec::new(),
+ };
+ assert!(
+ matches!(
+ TerrainWorld::from_land_msh(&mesh)
+ .expect("source world")
+ .with_basement_foundation(foundation),
+ Err(TerrainError::InvalidFoundationFragment { fragment: 0 })
+ ),
+ "invalid fragment case {index}"
+ );
+ }
+ }
+
+ #[test]
+ fn foundation_install_rejects_lod1_source_faces() {
+ use fparkan_terrain_format::TerrainLod;
+
+ let mut mesh = synthetic_land_mesh();
+ mesh.positions.push([0.0, 0.0, 2.0]);
+ mesh.positions.push([1.0, 0.0, 2.0]);
+ mesh.positions.push([0.0, 1.0, 2.0]);
+ mesh.faces
+ .push(terrain_face(FullSurfaceMask(0x0000_0003), [4, 5, 6]));
+ mesh.nodes_raw[10..12].copy_from_slice(&1_u16.to_le_bytes());
+ let mut lod1_slot = [0_u8; 68];
+ lod1_slot[0..2].copy_from_slice(&2_u16.to_le_bytes());
+ lod1_slot[2..4].copy_from_slice(&1_u16.to_le_bytes());
+ mesh.slots.slots_raw.push(lod1_slot);
+ assert_eq!(
+ mesh.face_lod_assignments().expect("LOD assignment")[2].lod,
+ TerrainLod::Lod1
+ );
+
+ let foundation = BasementFoundation {
+ fragments: vec![BasementFoundationFragment {
+ source_face: Some(2),
+ surface_mask: mesh.faces[2].flags,
+ positions: [[0.0, 0.0, 2.0], [1.0, 0.0, 2.0], [0.0, 1.0, 2.0]],
+ }],
+ placements: Vec::new(),
+ };
+ assert!(matches!(
+ TerrainWorld::from_land_msh(&mesh)
+ .expect("source world")
+ .with_basement_foundation(foundation),
+ Err(TerrainError::InvalidFoundationFragment { fragment: 0 })
+ ));
+ }
+
+ #[test]
fn surface_bvh_reduces_candidates_without_changing_surface_math() {
let surfaces: Vec<_> = (0_u16..16)
.map(|face| {
@@ -1231,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 = [
@@ -1288,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],