diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/fparkan-assets/src/lib.rs | 1463 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/environment.rs | 402 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/smoke.rs | 1360 | ||||
| -rw-r--r-- | crates/fparkan-prototype/src/lib.rs | 543 | ||||
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 110 | ||||
| -rw-r--r-- | crates/fparkan-terrain-format/src/lib.rs | 34 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/contour.rs | 809 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/foundation.rs | 1806 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/lib.rs | 515 |
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(¶ms, 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(¤t_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( + ¤t_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(¤t_anchor, stored_anchor, velocity, due_interval)?; + if in_window { + spawn_particle( + ¶ms, + 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, ¶ms.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, ¤t, &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], |
