diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/fparkan-assets/src/lib.rs | 221 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/ambient_sound.rs | 288 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/environment.rs | 74 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/sky.rs | 67 | ||||
| -rw-r--r-- | crates/fparkan-render/src/lib.rs | 127 | ||||
| -rw-r--r-- | crates/fparkan-runtime/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 1819 | ||||
| -rw-r--r-- | crates/fparkan-script/src/lib.rs | 486 | ||||
| -rw-r--r-- | crates/fparkan-terrain-format/src/lib.rs | 37 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/lib.rs | 80 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/lod.rs | 301 |
12 files changed, 3377 insertions, 125 deletions
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index 9cc6b5b..a1ae4ec 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/src/lib.rs @@ -2112,6 +2112,33 @@ impl<R: ResourceRepository> AssetManager<R> { ) -> Result<MissionAssetPlan, AssetError> { build_mission_asset_plan_with_repository(&self.repository, prototypes) } + + /// Reads raw opcodes from the validated initial Control effect group. + /// + /// A complete no-effect section or an out-of-range raw initial-group + /// selector returns `Some(Vec::new())`; unsupported shapes return `None`. + /// + /// # Errors + /// + /// Returns [`AssetError`] when the Control resource is malformed. + pub fn initial_control_effect_opcodes( + &self, + source: &ResourceKey, + model_node_count: usize, + prototype_index: usize, + ) -> Result<Option<Vec<u32>>, AssetError> { + let Some(payload) = read_optional_key(&self.repository, source, Some("component control"))? + else { + return Ok(None); + }; + let (_, opcodes) = decode_component_control_payload_with_opcodes( + &payload, + source.clone(), + model_node_count, + prototype_index, + )?; + Ok(opcodes) + } } /// Produces a count-only plan from a prototype graph. @@ -3881,6 +3908,7 @@ const CONTROL_EFFECT_ROW_BYTES: usize = 100; struct DecodedComponentEffectSection { section: PreparedControlEffectSection, initial_effect_group: Option<usize>, + initial_group_opcodes: Option<Vec<u32>>, typed_records: Vec<PreparedControlTypedRecord>, } @@ -3905,6 +3933,7 @@ where } }, initial_effect_group: None, + initial_group_opcodes: (cursor == payload.len()).then(Vec::new), typed_records: Vec::new(), }); } @@ -3926,6 +3955,7 @@ where typed_record_tag: Some(tag), }, initial_effect_group: None, + initial_group_opcodes: None, typed_records, }); } @@ -4015,8 +4045,9 @@ where } if effect_group_count == 0 { + let complete = cursor == payload.len(); return Ok(DecodedComponentEffectSection { - section: if cursor == payload.len() { + section: if complete { PreparedControlEffectSection::Parsed(Vec::new()) } else { PreparedControlEffectSection::Unsupported { @@ -4025,6 +4056,7 @@ where } }, initial_effect_group: None, + initial_group_opcodes: complete.then(Vec::new), typed_records, }); } @@ -4043,6 +4075,7 @@ where .filter(|group| *group < effect_group_count); cursor = group_header_end; + let mut initial_group_opcodes = Some(Vec::new()); let mut effect_rows = Vec::new(); for table_index in 0..effect_group_count { let row_count = usize::try_from( @@ -4069,7 +4102,14 @@ where for row_index in 0..row_count { let row_start = rows_start + row_index * CONTROL_EFFECT_ROW_BYTES; let row = &payload[row_start..row_start + CONTROL_EFFECT_ROW_BYTES]; - if control_u32_at(row, 0x0C) != Some(4) { + let opcode = control_u32_at(row, 0x0C).expect("fixed effect row has opcode"); + if initial_effect_group == Some(table_index) { + initial_group_opcodes + .as_mut() + .expect("valid initial group has an opcode list") + .push(opcode); + } + if opcode != 4 { continue; } let raw_condition = [ @@ -4108,12 +4148,14 @@ where typed_record_tag: None, }, initial_effect_group: None, + initial_group_opcodes: None, typed_records, }); } Ok(DecodedComponentEffectSection { section: PreparedControlEffectSection::Parsed(effect_rows), initial_effect_group, + initial_group_opcodes, typed_records, }) } @@ -4130,6 +4172,21 @@ fn decode_component_control_payload( model_node_count: usize, prototype_index: usize, ) -> Result<PreparedComponentControl, AssetError> { + decode_component_control_payload_with_opcodes( + payload, + source, + model_node_count, + prototype_index, + ) + .map(|(control, _)| control) +} + +fn decode_component_control_payload_with_opcodes( + payload: &[u8], + source: ResourceKey, + model_node_count: usize, + prototype_index: usize, +) -> Result<(PreparedComponentControl, Option<Vec<u32>>), AssetError> { const HEADER_BYTES: usize = 5 * 4; const FIXED_SECTION_BYTES: usize = 108; const SECTION_FIXED_BYTES: usize = 156; @@ -4267,18 +4324,22 @@ fn decode_component_control_payload( &invalid, )?; - Ok(PreparedComponentControl { - source, - native_profile_words, - model_node_count, - bindings, - 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, - }) + let initial_group_opcodes = decoded_effect.initial_group_opcodes; + Ok(( + PreparedComponentControl { + source, + native_profile_words, + model_node_count, + bindings, + 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, + }, + initial_group_opcodes, + )) } fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, AssetError> { @@ -6804,6 +6865,138 @@ mod tests { } #[test] + fn initial_control_effect_opcodes_keep_raw_selected_rows_in_order() { + 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(&[]); + payload[16..20].copy_from_slice(&1_u32.to_le_bytes()); + let group_header = payload.len(); + payload.resize(group_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0); + let opcodes = [1, 4, 4, 12]; + append_effect_table( + &mut payload, + &opcodes.map(|opcode| effect_row(opcode, [0; 3], [0; 3], 0, "", "")), + ); + + let (control, decoded_opcodes) = + decode_component_control_payload_with_opcodes(&payload, source.clone(), 2, 0) + .expect("complete selected group"); + assert_eq!(decoded_opcodes, Some(opcodes.to_vec())); + let PreparedControlEffectSection::Parsed(rows) = control.effect_section else { + panic!("opcode-4 projection should remain parsed"); + }; + assert_eq!( + rows.iter().map(|row| row.row_index).collect::<Vec<_>>(), + [1, 2] + ); + + let repository = repository_with_archives(&[("controls.rlb", &[(b"parts.ctl", &payload)])]); + let manager = AssetManager::new(repository); + assert_eq!( + manager + .initial_control_effect_opcodes(&source, 2, 0) + .expect("public raw-opcode scanner"), + Some(opcodes.to_vec()) + ); + } + + #[test] + fn initial_control_effect_opcodes_distinguish_complete_empty_from_unknown_shapes() { + let source = |name: &[u8]| ResourceKey { + archive: parse_path("controls.rlb").expect("archive"), + name: resource_name(name), + type_id: None, + }; + let empty = component_control_payload(&[]); + + let mut selected_noop = component_control_payload(&[]); + selected_noop[16..20].copy_from_slice(&1_u32.to_le_bytes()); + let noop_header = selected_noop.len(); + selected_noop.resize(noop_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0); + append_effect_table(&mut selected_noop, &[]); + + let mut unsupported = component_control_payload(&[]); + unsupported[12..16].copy_from_slice(&1_u32.to_le_bytes()); + append_typed_record(&mut unsupported, 99, 0); + + let mut trailing_data = component_control_payload(&[]); + trailing_data.push(0xAA); + let repository = repository_with_archives(&[( + "controls.rlb", + &[ + (b"empty.ctl", &empty), + (b"noop.ctl", &selected_noop), + (b"unsupported.ctl", &unsupported), + (b"trailing.ctl", &trailing_data), + ], + )]); + let manager = AssetManager::new(repository); + + assert_eq!( + manager + .initial_control_effect_opcodes(&source(b"empty.ctl"), 2, 0) + .expect("complete empty CTLD"), + Some(Vec::new()) + ); + assert_eq!( + manager + .initial_control_effect_opcodes(&source(b"noop.ctl"), 2, 0) + .expect("complete no-op selected group"), + Some(Vec::new()) + ); + for name in [b"unsupported.ctl".as_slice(), b"trailing.ctl".as_slice()] { + assert_eq!( + manager + .initial_control_effect_opcodes(&source(name), 2, 0) + .expect("unsupported but bounded shape"), + None, + "{} remains unavailable", + String::from_utf8_lossy(name) + ); + } + } + + #[test] + fn initial_control_effect_opcodes_treat_out_of_range_initial_indices_as_known_empty() { + let make_payload = |raw_initial_group: u32| { + let mut payload = component_control_payload(&[]); + payload[16..20].copy_from_slice(&1_u32.to_le_bytes()); + let group_header = payload.len(); + payload.resize(group_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0); + payload[group_header..group_header + 4] + .copy_from_slice(&raw_initial_group.to_le_bytes()); + append_effect_table(&mut payload, &[effect_row(1, [0; 3], [0; 3], 0, "", "")]); + payload + }; + let sentinel = make_payload(u32::MAX); + let exact_count = make_payload(1); + let repository = repository_with_archives(&[( + "controls.rlb", + &[ + (b"sentinel.ctl", &sentinel), + (b"exact-count.ctl", &exact_count), + ], + )]); + let manager = AssetManager::new(repository); + let sources = + [b"sentinel.ctl".as_slice(), b"exact-count.ctl".as_slice()].map(|name| ResourceKey { + archive: parse_path("controls.rlb").expect("archive"), + name: resource_name(name), + type_id: None, + }); + let results = sources.map(|source| { + manager + .initial_control_effect_opcodes(&source, 2, 0) + .expect("complete CTLD frames") + }); + + assert_eq!(results, [Some(Vec::new()), Some(Vec::new())]); + } + + #[test] fn tag21_gate_provider_comes_from_validated_explicit_ndp() { let source = ResourceKey { archive: parse_path("parts.rlb").expect("CTL archive"), diff --git a/crates/fparkan-fx/src/ambient_sound.rs b/crates/fparkan-fx/src/ambient_sound.rs new file mode 100644 index 0000000..bcc1814 --- /dev/null +++ b/crates/fparkan-fx/src/ambient_sound.rs @@ -0,0 +1,288 @@ +//! Opcode-2 sound commands, independent of an audio device. + +use crate::environment::{SoundAction, SoundEvent}; +use crate::{FxCommand, FxOpcode}; + +/// A validated ordinary Op2 command. Modes 1/3 request an unverified backend +/// flag and are left to the caller's unsupported-command report. +#[derive(Clone, Debug)] +pub struct Op2SoundCommand { + looping: bool, + start: f32, + end: f32, + position: [f32; 3], + position_delta: [f32; 3], + min_distance: f32, + max_distance: f32, + frequency: f32, + frequency_delta: f32, + archive: String, + name: String, +} + +/// Playback crossing state belongs to a command instance, even when several +/// instances share the same sample. +#[derive(Clone, Debug, Default)] +pub struct Op2SoundState { + previous_phase: f32, +} + +impl Op2SoundCommand { + /// Decodes the supported native modes 0 (once) and 2 (loop). + #[must_use] + pub fn from_command(command: &FxCommand) -> Option<Self> { + let body = command.raw_body.as_ref(); + if command.opcode != FxOpcode::Op2 || body.len() != 144 { + return None; + } + let mode = u32::from_le_bytes(body[..4].try_into().ok()?); + // shortcut: backend flag 0x200 and body+76 need a native playback + // comparison before modes 1/3 or nonzero backend parameters are enabled. + if !matches!(mode, 0 | 2) || body[76..80] != [0; 4] { + return None; + } + let float = |offset| { + let value = f32::from_le_bytes(body[offset..offset + 4].try_into().ok()?); + value.is_finite().then_some(value) + }; + let vector = |offset| Some([float(offset)?, float(offset + 4)?, float(offset + 8)?]); + let start = float(4)?; + let end = float(8)?; + let position = vector(12)?; + let position_end = vector(24)?; + let min_distance = float(60)?; + let max_distance = float(64)?; + let frequency = float(68)?; + let frequency_end = float(72)?; + let position_delta = std::array::from_fn(|axis| position_end[axis] - position[axis]); + let frequency_delta = frequency_end - frequency; + if end <= start + || !(end - start).is_finite() + || !position_delta.iter().all(|value: &f32| value.is_finite()) + || !frequency_delta.is_finite() + || min_distance < 0.0 + || max_distance < min_distance + || frequency <= 0.0 + || frequency_end <= 0.0 + { + return None; + } + let text = |bytes: &[u8]| { + let end = bytes + .iter() + .position(|&byte| byte == 0) + .unwrap_or(bytes.len()); + (!bytes[..end].is_empty()).then(|| String::from_utf8_lossy(&bytes[..end]).into_owned()) + }; + Some(Self { + looping: mode == 2, + start, + end, + position, + position_delta, + min_distance, + max_distance, + frequency, + frequency_delta, + archive: text(&body[80..112])?, + name: text(&body[112..144])?, + }) + } + + /// Evaluates Effect.dll's phase crossings and current source parameters. + /// `playing` comes from the audio owner, since a one-shot can finish between + /// updates. The matrix maps local command coordinates into world space. + #[must_use] + pub fn update( + &self, + state: &mut Op2SoundState, + phase: f32, + world_from_effect: [[f32; 4]; 4], + playing: bool, + ) -> Option<SoundEvent> { + if !phase.is_finite() || !world_from_effect.iter().flatten().all(|v| v.is_finite()) { + return None; + } + let action = if self.looping { + if phase >= self.start && phase <= self.end { + if playing { + SoundAction::SetLoopVolume + } else { + SoundAction::StartLoop + } + } else if playing { + SoundAction::StopLoop + } else { + return None; + } + } else { + let crossing_phase = if phase == 1.0 { 0.0 } else { phase }; + let crossed = state.previous_phase <= crossing_phase + && state.previous_phase <= self.start + && self.start < crossing_phase; + state.previous_phase = crossing_phase; + if crossed { + SoundAction::OneShot + } else if playing { + SoundAction::SetLoopVolume + } else { + return None; + } + }; + let t = (phase - self.start) / (self.end - self.start); + let local: [f32; 3] = + std::array::from_fn(|axis| self.position[axis] + t * self.position_delta[axis]); + let position = std::array::from_fn(|axis| { + let row = world_from_effect[axis]; + row[0] * local[0] + row[1] * local[1] + row[2] * local[2] + row[3] + }); + let frequency_ratio = self.frequency + t * self.frequency_delta; + if !position.iter().all(|value| value.is_finite()) || !frequency_ratio.is_finite() { + return None; + } + Some(SoundEvent { + archive: self.archive.clone(), + name: self.name.clone(), + position, + volume: 1.0, + min_distance: self.min_distance, + max_distance: self.max_distance, + frequency_ratio, + action, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + 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(mode: u32) -> FxCommand { + let mut body = vec![0; 144]; + body[..4].copy_from_slice(&mode.to_le_bytes()); + for (offset, value) in [ + (4, 0.25_f32), + (8, 0.75), + (60, 12.0), + (64, 80.0), + (68, 1.0), + (72, 2.0), + ] { + body[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + body[80..90].copy_from_slice(b"sounds.lib"); + body[112..122].copy_from_slice(b"f_defl.wav"); + FxCommand { + word: 2, + opcode: FxOpcode::Op2, + native_flag: false, + raw_body: Arc::from(body), + resource_refs: vec![], + } + } + + #[test] + fn loop_has_inclusive_boundaries_and_updates_without_restarting() { + let command = Op2SoundCommand::from_command(&command(2)).unwrap(); + let mut state = Op2SoundState::default(); + assert!(command.update(&mut state, 0.0, IDENTITY, false).is_none()); + assert_eq!( + command + .update(&mut state, 0.25, IDENTITY, false) + .unwrap() + .action, + SoundAction::StartLoop + ); + assert_eq!( + command + .update(&mut state, 0.75, IDENTITY, true) + .unwrap() + .action, + SoundAction::SetLoopVolume + ); + assert_eq!( + command + .update(&mut state, 0.8, IDENTITY, true) + .unwrap() + .action, + SoundAction::StopLoop + ); + assert!(command.update(&mut state, 0.8, IDENTITY, false).is_none()); + } + + #[test] + fn one_shot_crossing_is_strict_and_rearms_after_phase_one() { + let command = Op2SoundCommand::from_command(&command(0)).unwrap(); + let mut state = Op2SoundState::default(); + assert!(command.update(&mut state, 0.25, IDENTITY, false).is_none()); + assert_eq!( + command + .update(&mut state, 0.3, IDENTITY, false) + .unwrap() + .action, + SoundAction::OneShot + ); + assert_eq!( + command + .update(&mut state, 0.5, IDENTITY, true) + .unwrap() + .action, + SoundAction::SetLoopVolume + ); + assert!(command.update(&mut state, 0.6, IDENTITY, false).is_none()); + assert!(command.update(&mut state, 1.0, IDENTITY, false).is_none()); + assert_eq!( + command + .update(&mut state, 0.3, IDENTITY, false) + .unwrap() + .action, + SoundAction::OneShot + ); + } + + #[test] + fn parameters_keep_native_constant_ranges_and_transform_the_position() { + let mut source = command(2); + let mut body = source.raw_body.to_vec(); + body[24..28].copy_from_slice(&4.0_f32.to_le_bytes()); + source.raw_body = Arc::from(body); + let command = Op2SoundCommand::from_command(&source).unwrap(); + let mut matrix = IDENTITY; + matrix[0] = [0.0, -1.0, 0.0, 10.0]; + matrix[1] = [2.0, 0.0, 0.0, 20.0]; + let event = command + .update(&mut Op2SoundState::default(), 0.5, matrix, false) + .unwrap(); + assert_eq!(event.position, [10.0, 24.0, 0.0]); + assert_eq!( + ( + event.min_distance, + event.max_distance, + event.frequency_ratio + ), + (12.0, 80.0, 1.5) + ); + assert_eq!( + (event.archive.as_str(), event.name.as_str()), + ("sounds.lib", "f_defl.wav") + ); + } + + #[test] + fn backend_modes_and_malformed_commands_are_explicitly_unsupported() { + assert!(Op2SoundCommand::from_command(&command(1)).is_none()); + let mut source = command(2); + let mut body = source.raw_body.to_vec(); + body[8..12].copy_from_slice(&f32::NAN.to_le_bytes()); + source.raw_body = Arc::from(body); + assert!(Op2SoundCommand::from_command(&source).is_none()); + } +} diff --git a/crates/fparkan-fx/src/environment.rs b/crates/fparkan-fx/src/environment.rs index 68a2c8f..713a3f9 100644 --- a/crates/fparkan-fx/src/environment.rs +++ b/crates/fparkan-fx/src/environment.rs @@ -590,7 +590,7 @@ impl LightningBounds { /// Effect.dll's opcode 1 handler (`1000F6E0`) updates a LightManager record. /// The A and B groups are the submitted position and direction, C is the /// four-component HDR light color, the scalar pair at body offsets 108 and -/// 112 is the light range, and body offsets 120, 124, and 128 are the native +/// 112 is the local light radius, and body offsets 120, 124, and 128 are the native /// attenuation coefficients. These values do not describe lightning mesh /// geometry or texture coordinates. #[derive(Clone, Copy, Debug, PartialEq)] @@ -615,8 +615,7 @@ pub struct PointLightFrame { pub range: f32, /// Native attenuation coefficients `(a0, a1, a2)`. pub attenuation: [f32; 3], - /// 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. + /// Radius jitter amplitude at body offset 116; retained but not sampled yet. pub effect_factor: f32, /// Whether the opcode 1 command is active at `command_time`. pub active: bool, @@ -626,8 +625,8 @@ pub struct PointLightFrame { /// 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. +/// remain with that caller. Native Op1 scales the radius by the transformed +/// direction length and replaces a non-positive result with 0.01. #[must_use] pub fn evaluate_op1_point_light_with_basis( command: &FxCommand, @@ -677,7 +676,22 @@ pub fn evaluate_op1_point_light_with_basis( { return None; } - let direction = normalize_op1_direction(transformed_direction); + let direction_length = transformed_direction + .into_iter() + .map(|component| component * component) + .sum::<f32>() + .sqrt(); + // shortcut: Op1 color/radius jitter is omitted; add native RNG sampling + // when the reported randomized Control lights are handled in phase R4. + let scaled_range = sample.range * direction_length; + if !direction_length.is_finite() || !scaled_range.is_finite() { + return None; + } + let direction = if direction_length > 0.0 { + transformed_direction.map(|component| component / direction_length) + } else { + [0.0; 3] + }; Some(PointLightFrame { effect_type: sample.effect_type, @@ -688,7 +702,11 @@ pub fn evaluate_op1_point_light_with_basis( position, direction, color: sample.color, - range: sample.range, + range: if scaled_range <= 0.0 { + 0.01 + } else { + scaled_range + }, attenuation: sample.attenuation, effect_factor: sample.effect_factor, active: true, @@ -722,19 +740,6 @@ fn sample_op1_point_light(body: &[u8], normalized_time: f32) -> Option<Op1PointL }) } -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. @@ -2964,7 +2969,7 @@ mod tests { } #[test] - fn generic_opcode1_preserves_negative_native_range() { + fn generic_opcode1_clamps_non_positive_scaled_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()); @@ -2973,7 +2978,7 @@ mod tests { 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); + assert_eq!(sample.range, 0.01); } #[test] @@ -2986,6 +2991,31 @@ mod tests { 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]); + assert_eq!(sample.range, 0.01); + } + + #[test] + fn generic_opcode1_scales_radius_like_live_factory_light() { + // Live Effect.dll Op1: authored radius 7, FastProc direction length + // 0x409b1f91, submitted LightManager radius 0x4207bb9f. + let mut command = op1_test_command(7, 0.75, 1.0); + let mut body = command.raw_body.to_vec(); + body[48..60].copy_from_slice(&[1.0_f32, 0.0, 0.0].map(f32::to_le_bytes).concat()); + body[112..116].copy_from_slice(&7.0_f32.to_le_bytes()); + command.raw_body = Arc::from(body.into_boxed_slice()); + let sample = evaluate_op1_point_light_with_basis( + &command, + 1.0, + [0.0; 3], + [ + [f32::from_bits(0x409b_1f91), 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ], + ) + .expect("live factory radius"); + assert_eq!(sample.range.to_bits(), 0x4207_bb9f); + assert_eq!(sample.direction, [1.0, 0.0, 0.0]); } #[test] diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs index 4125a68..af7408a 100644 --- a/crates/fparkan-fx/src/lib.rs +++ b/crates/fparkan-fx/src/lib.rs @@ -7,6 +7,7 @@ //! are backed by runtime-captured evidence. pub mod ambient_op3; +pub mod ambient_sound; pub mod atmosphere; pub mod environment; pub mod fx_visual; diff --git a/crates/fparkan-fx/src/sky.rs b/crates/fparkan-fx/src/sky.rs index 26e68ab..1b793f0 100644 --- a/crates/fparkan-fx/src/sky.rs +++ b/crates/fparkan-fx/src/sky.rs @@ -328,6 +328,24 @@ pub struct SkyMesh { } impl SkyMesh { + /// Updates native nebula, star and cloud scroll from the sky object's elapsed milliseconds. + pub fn update_uvs(&mut self, elapsed_ms: u32) { + let offsets = [ + [(elapsed_ms % 300_000) as f32 * 3.333_333_3e-6; 2], + [(elapsed_ms % 100_000) as f32 * 1.0e-5; 2], + [(elapsed_ms % 50_000) as f32 * 2.0e-5, 0.0], + ]; + let inverse_radius = self.radius.recip(); + for vertex in &mut self.vertices { + for stage in 0..3 { + let scale = inverse_radius * SKY_UV_SCALES[stage]; + vertex.uv[stage] = std::array::from_fn(|axis| { + scale * vertex.position[axis] + offsets[stage][axis] + }); + } + } + } + /// Number of triangles in the mesh. #[must_use] pub fn triangle_count(&self) -> usize { @@ -1143,12 +1161,11 @@ impl SunFrame { /// Converts an interpolated atmosphere sun sample to a renderer-facing frame. /// /// The original update computes `orbit_direction = [cos(theta), 0, -/// -sin(theta)]`, then applies the row-vector product of the two birth-angle +/// -sin(theta)]`, then applies the column-vector product of the two birth-angle /// matrices. With `A` and `B` in radians, the resulting direction is: /// /// ```text -/// [x*cos(A), -x*sin(A)*cos(B) + z*sin(B), -/// x*sin(A)*sin(B) + z*cos(B)] +/// [x*cos(A) + z*sin(A)*sin(B), x*sin(A) - z*cos(A)*sin(B), z*cos(B)] /// ``` /// /// where `x` and `z` are the two non-zero orbit components. @@ -1178,9 +1195,9 @@ pub fn sun_frame(sample: &SunSample, now_seconds: f32, config: SunConfig) -> Sun let x = orbit_direction[0]; let z = orbit_direction[2]; let direction = normalize3([ - x * cos_a, - -x * sin_a * cos_b + z * sin_b, - x * sin_a * sin_b + z * cos_b, + x * cos_a + z * sin_a * sin_b, + x * sin_a - z * cos_a * sin_b, + z * cos_b, ]); SunFrame { active: true, @@ -1788,6 +1805,26 @@ mod tests { } #[test] + fn scrolling_uvs_match_native_periods_and_wrapped_elapsed_clock() { + let mut mesh = SkyGeometryConfig::default().try_mesh(&sample()).unwrap(); + let topology = mesh.indices.clone(); + for elapsed in [0, 25_000, 49_999, 50_000, 100_000, 300_000, 4_051_271_258] { + mesh.update_uvs(elapsed); + let zenith = mesh.vertices[0].uv; + assert_eq!(zenith[0], [(elapsed % 300_000) as f32 * 3.333_333_3e-6; 2]); + assert_eq!(zenith[1], [(elapsed % 100_000) as f32 * 1e-5; 2]); + assert_eq!(zenith[2], [(elapsed % 50_000) as f32 * 2e-5, 0.0]); + } + // Live GOG sky render: now 265584022, object origin 509280060. + mesh.update_uvs(265_584_022_u32.wrapping_sub(509_280_060)); + assert!((mesh.vertices[0].uv[2][0] - 0.42516).abs() < 1e-7); + let once = mesh.vertices.clone(); + mesh.update_uvs(4_051_271_258); + assert_eq!(mesh.vertices, once, "updates must not accumulate drift"); + assert_eq!(mesh.indices, topology); + } + + #[test] fn geometry_matches_original_counts_positions_uv_and_winding() { let config = SkyGeometryConfig::new(10.0, std::f32::consts::FRAC_PI_2, 2.0, 4, 3); let mesh = config.try_mesh(&sample()).expect("valid mesh"); @@ -1892,16 +1929,30 @@ mod tests { let sample = sun(true); let at_start = sun_frame(&sample, 0.0, SunConfig::new(0.0, 1.0, [90.0, 0.0])); assert!((at_start.direction[0]).abs() < 1.0e-5); - assert!((at_start.direction[1] + at_start.orbit_direction[0]).abs() < 1.0e-5); + assert!((at_start.direction[1] - at_start.orbit_direction[0]).abs() < 1.0e-5); assert!((at_start.direction[2] - at_start.orbit_direction[2]).abs() < 1.0e-5); let side = sun_frame(&sample, 0.0, SunConfig::new(0.0, 1.0, [0.0, 90.0])); assert!((side.direction[0] - side.orbit_direction[0]).abs() < 1.0e-5); - assert!((side.direction[1] - side.orbit_direction[2]).abs() < 1.0e-5); + assert!((side.direction[1] + side.orbit_direction[2]).abs() < 1.0e-5); assert!(side.direction[2].abs() < 1.0e-5); } #[test] + fn sun_direction_matches_live_native_birth_matrix() { + // Terrain CSun::Update, elapsed 282184 ms, lifetime 525000 ms. + let frame = sun_frame( + &sun(true), + 282.184, + SunConfig::new(0.0, 525.0, [90.0, 30.0]), + ); + let expected = [0xbefd_7269, 0xbe10_4168, 0xbf5b_7dda].map(f32::from_bits); + for (actual, expected) in frame.direction.into_iter().zip(expected) { + assert!((actual - expected).abs() < 3.0e-6, "{actual} != {expected}"); + } + } + + #[test] fn native_birth_keeps_the_real_start_time() { assert!(!celestial_frame(&sun(false), 100.0).active); let birth = SunBirth { diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs index 08a285d..ad96204 100644 --- a/crates/fparkan-render/src/lib.rs +++ b/crates/fparkan-render/src/lib.rs @@ -361,6 +361,64 @@ pub enum LegacyCullMode { FrontFace, } +/// CPU diffuse alpha for a colourless MSH batch carrying flag `0x0008`. +/// +/// `packet_distance` is the distance to the packet's first accepted vertex, +/// after its object transform. Flag `0x0010` takes precedence over `0x0040`. +/// Returns `None` for invalid distance or projection inputs. Callers must keep +/// the material alpha for batches with a type-16 colour stream or without `0x0008`. +#[must_use] +pub fn legacy_msh_portal_alpha( + batch_flags: u16, + packet_distance: f32, + horizontal_fov: f32, +) -> Option<f32> { + if !packet_distance.is_finite() + || packet_distance < 0.0 + || !horizontal_fov.is_finite() + || horizontal_fov <= 0.0 + { + return None; + } + let (distance, near, far, slope) = if batch_flags & 0x0010 != 0 { + ( + packet_distance.sqrt(), + 2.6 / horizontal_fov, + 7.799_999_7 / horizontal_fov, + (1.0 / (7.799_999_7 - 2.6)) * horizontal_fov, + ) + } else if batch_flags & 0x0040 != 0 { + return Some(0.0); + } else { + let near = 75.0 * 1.3; + let far = 95.0 * 1.3; + ( + packet_distance, + horizontal_fov.recip() * near, + horizontal_fov.recip() * far, + (1.0 / (far - near)) * horizontal_fov, + ) + }; + Some(if distance < near { + 0.0 + } else if distance > far { + 1.0 + } else { + ((distance - near) * slope).clamp(0.0, 1.0) + }) +} + +/// Raster depth adjustment applied to one pipeline variant. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum LegacyDepthBias { + /// Preserve the interpolated depth exactly. + #[default] + Disabled, + /// Move projected decals toward the camera so they pass against their + /// coplanar receiver under a less-or-equal depth test. + ProjectedDecal, +} + /// Legacy fixed-function state that changes graphics-pipeline structure. /// /// Alpha reference is deliberately not present: it is dynamic material data, @@ -375,6 +433,8 @@ pub struct LegacyPipelineState { pub cull: LegacyCullMode, /// Whether alpha-test shader logic is enabled. pub alpha_test: bool, + /// Raster depth bias for projected decals. + pub depth_bias: LegacyDepthBias, } impl LegacyPipelineState { @@ -405,15 +465,16 @@ impl LegacyPipelineState { /// Canonical, backend-neutral key for a graphics-pipeline variant. /// /// The value is explicitly packed rather than hashed, so captures and caches -/// remain stable across processes and Rust toolchain updates. Three bits are -/// reserved for the five native material blend modes. +/// remain stable across processes and Rust toolchain updates. Bits 0..7 encode +/// blend, depth, cull and alpha-test state; bit 8 distinguishes projected +/// decal depth bias. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct PipelineKey(u8); +pub struct PipelineKey(u16); impl PipelineKey { /// Returns the canonical packed representation. #[must_use] - pub const fn packed(self) -> u8 { + pub const fn packed(self) -> u16 { self.0 } } @@ -437,7 +498,11 @@ impl From<LegacyPipelineState> for PipelineKey { LegacyCullMode::BackFace => 1, LegacyCullMode::FrontFace => 2, }; - Self(blend | (depth << 3) | (cull << 5) | (u8::from(state.alpha_test) << 7)) + let depth_bias = match state.depth_bias { + LegacyDepthBias::Disabled => 0, + LegacyDepthBias::ProjectedDecal => 1 << 8, + }; + Self(blend | (depth << 3) | (cull << 5) | (u16::from(state.alpha_test) << 7) | depth_bias) } } @@ -445,6 +510,40 @@ impl From<LegacyPipelineState> for PipelineKey { mod tests { use super::*; + #[test] + fn portal_alpha_matches_native_packet_distance_vectors() { + // GOG Terrain+1B0F0 with its real +246A0/+247B0 packet methods, + // Ngi32 RSQRTSS dispatch, x87 CW=007F, FOV=1.04. + for (distance, flags, expected) in [ + (0.499_938_96, 0x18, 0.0), + (4.999_542, 0x18, 0.0), + (9.999_084, 0x18, 0.132_426_6), + (25.000_572, 0x18, 0.500_011_44), + (59.999_084, 0x18, 1.0), + (85.012_48, 0x08, 0.0), + (109.994_125, 0x08, 0.649_765_2), + (25.000_572, 0x58, 0.500_011_44), + (110.0, 0x48, 0.0), + ] { + let actual = legacy_msh_portal_alpha(flags, distance, 1.04).unwrap(); + assert!( + (actual - expected).abs() < 0.000_002, + "{distance}: {actual}" + ); + } + assert_eq!(legacy_msh_portal_alpha(8, 60.0, 1.3), Some(0.0)); + assert!((legacy_msh_portal_alpha(8, 85.0, 1.3).unwrap() - 0.5).abs() < 0.000_001); + assert_eq!(legacy_msh_portal_alpha(8, 110.0, 1.3), Some(1.0)); + for (distance, fov) in [ + (f32::NAN, 1.3), + (-1.0, 1.3), + (1.0, 0.0), + (1.0, f32::INFINITY), + ] { + assert_eq!(legacy_msh_portal_alpha(8, distance, fov), None); + } + } + fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] { let mut result = [0.0; 16]; for row in 0..4 { @@ -614,6 +713,24 @@ mod tests { } #[test] + fn projected_decal_bias_is_a_distinct_preserved_pipeline_state() { + let ordinary = LegacyPipelineState::default(); + let decal = LegacyPipelineState { + depth_bias: LegacyDepthBias::ProjectedDecal, + ..ordinary + }; + assert_eq!(ordinary.depth_bias, LegacyDepthBias::Disabled); + assert_ne!(PipelineKey::from(ordinary), PipelineKey::from(decal)); + assert_eq!( + decal + .with_material_category(1) + .expect("native category is represented") + .depth_bias, + LegacyDepthBias::ProjectedDecal + ); + } + + #[test] fn native_material_categories_have_distinct_pipeline_keys() { let base = LegacyPipelineState { depth: LegacyDepthMode::TestWrite, diff --git a/crates/fparkan-runtime/Cargo.toml b/crates/fparkan-runtime/Cargo.toml index 8eb3e84..80e5b78 100644 --- a/crates/fparkan-runtime/Cargo.toml +++ b/crates/fparkan-runtime/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true [dependencies] fparkan-assets = { path = "../fparkan-assets" } +fparkan-fx = { path = "../fparkan-fx" } fparkan-msh = { path = "../fparkan-msh" } fparkan-path = { path = "../fparkan-path" } fparkan-platform = { path = "../fparkan-platform" } diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 553c611..5cc2d86 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -2,26 +2,28 @@ //! Runtime orchestration for mission loading and world stepping. use fparkan_assets::ClanBody; -#[cfg(test)] -use fparkan_assets::PreparedControlResourcePair; use fparkan_assets::{ decode_mission_land_path, decode_mission_payload, decode_nres_payload, derive_mission_land_paths, extend_graph_report_with_visual_dependencies, mission_script_bundle_bases, prepare_terrain_world, AssetError as AssetPreparationError, AssetId, AssetManager, BehaviorProfileRates, BehaviorProfileVarError, BuildCategory, MissionAssetPlan, MissionDocument, MissionError, MissionTerrainPaths, NresError, - PreparedComponentControl, PreparedControlEffectSection, PreparedControlNodeDataRow, - PreparedControlNodeDataTable, PreparedControlTypedRecord, PreparedVisual, ResearchError, - ResearchTrfDocument, TerrainFormatError, TerrainPreparationError, TerrainWorld, TmaProfile, + PreparedComponentControl, PreparedControlEffectRow, PreparedControlEffectSection, + PreparedControlNodeDataRow, PreparedControlNodeDataTable, PreparedControlResourcePair, + PreparedControlTypedRecord, PreparedVisual, ResearchError, ResearchTrfDocument, + TerrainFormatError, TerrainPreparationError, TerrainWorld, TmaProfile, }; +use fparkan_fx::{decode_fxid, FxOpcode, FXID_KIND}; use fparkan_msh::{node38_metadata, selected_slot, Group, Lod, NodeId}; use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy}; use fparkan_prototype::{ - build_prototype_graph_report, unit_component_tree, PrototypeGraph, PrototypeGraphFailure, - PrototypeGraphReport, UnitComponentRecord, UnitComponentTreeNode, PROTOTYPE_TYPE_EXTO, - UNIT_DAT_BAS_PLACEMENT_FLAG, + build_prototype_graph_report, resolve_prototype_all, unit_component_tree, PrototypeGraph, + PrototypeGraphFailure, PrototypeGraphReport, PrototypeSource, UnitComponentRecord, + UnitComponentTreeNode, PROTOTYPE_TYPE_EXTO, UNIT_DAT_BAS_PLACEMENT_FLAG, +}; +use fparkan_resource::{ + resource_name, CachedResourceRepository, ResourceError, ResourceKey, ResourceRepository, }; -use fparkan_resource::{resource_name, CachedResourceRepository, ResourceKey, ResourceRepository}; use fparkan_script::{ FormulaTable, Handler19InitInput, ScriptInitResolution, ScriptInitResolveError, ScriptInitWrite, ScriptInstruction, ScriptPackage, VarSet, VarSetDefault, @@ -205,6 +207,251 @@ pub struct ControlDeviceRecordProvenance { pub source: ResourceKey, } +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct Factory2StringPair { + archive: String, + name: String, +} + +impl Factory2StringPair { + fn empty() -> Self { + Self { + archive: String::new(), + name: String::new(), + } + } + + fn matches_ascii_case(&self, other: &Self) -> bool { + self.archive.eq_ignore_ascii_case(&other.archive) + && self.name.eq_ignore_ascii_case(&other.name) + } + + fn cache_key(&self) -> Self { + Self { + archive: self.archive.to_ascii_lowercase(), + name: self.name.to_ascii_lowercase(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Factory2PairOutcome { + Value(u32), + NativeMiss, + Unrepresentable, +} + +fn resolve_factory2_pair_from_assets<R: ResourceRepository>( + asset_manager: &AssetManager<R>, + vfs: &dyn Vfs, + pair: &Factory2StringPair, +) -> Result<Factory2PairOutcome, AssetPreparationError> { + if !pair.archive.eq_ignore_ascii_case("objects.rlb") + || pair.name.is_empty() + || pair + .name + .bytes() + .any(|byte| matches!(byte, b'/' | b'\\' | b'.')) + || normalize_relative(pair.name.as_bytes(), PathPolicy::StrictLegacy).is_err() + { + return Ok(Factory2PairOutcome::Unrepresentable); + } + + let root_name = resource_name(pair.name.as_bytes()); + let prototypes = + resolve_prototype_all(asset_manager.repository(), vfs, &root_name).map_err(|source| { + AssetPreparationError::InvalidPrototype(format!( + "factory-2 pair {}:{}: {source}", + pair.archive, pair.name + )) + })?; + let [prototype] = prototypes.as_slice() else { + return Ok(Factory2PairOutcome::Unrepresentable); + }; + if prototype.source != PrototypeSource::ObjectsRegistry + || prototype.source_type != Some(CONTROL_CLASS_SOURCE_BULL) + || !matches!( + prototype.geometry, + fparkan_prototype::PrototypeGeometry::Mesh(_) + ) + { + return Ok(Factory2PairOutcome::Unrepresentable); + } + + let assets = asset_manager.prepare_mission_assets(&[0..1], &prototypes)?; + let Some(controls) = assets.object_component_controls.first() else { + return Ok(Factory2PairOutcome::Unrepresentable); + }; + let [Some(control)] = controls.as_slice() else { + return Ok(Factory2PairOutcome::Unrepresentable); + }; + if !control.timeline_rows.is_empty() + || !control.typed_records.iter().all(|record| record.tag == 17) + || !matches!( + &control.effect_section, + PreparedControlEffectSection::Absent | PreparedControlEffectSection::Parsed(_) + ) + { + return Ok(Factory2PairOutcome::Unrepresentable); + } + let Some(node_data) = control.node_data.as_ref() else { + return Ok(Factory2PairOutcome::Unrepresentable); + }; + let Some(opcodes) = asset_manager.initial_control_effect_opcodes( + &control.source, + control.model_node_count, + 0, + )? + else { + return Ok(Factory2PairOutcome::Unrepresentable); + }; + if !opcodes.iter().all(|opcode| matches!(opcode, 1 | 4 | 12)) { + return Ok(Factory2PairOutcome::Unrepresentable); + } + if !factory2_selected_fx_resources_are_representable(asset_manager, control, &opcodes)? { + return Ok(Factory2PairOutcome::Unrepresentable); + } + match factory2_node_data_maximum_bits(&node_data.rows) { + Ok(bits) => Ok(Factory2PairOutcome::Value(bits)), + Err(_) => Ok(Factory2PairOutcome::Unrepresentable), + } +} + +fn factory2_selected_fx_resources_are_representable<R: ResourceRepository>( + asset_manager: &AssetManager<R>, + control: &PreparedComponentControl, + selected_opcodes: &[u32], +) -> Result<bool, AssetPreparationError> { + let selected_opcode4_count = selected_opcodes + .iter() + .filter(|opcode| **opcode == 4) + .count(); + let PreparedControlEffectSection::Parsed(rows) = &control.effect_section else { + return Ok(selected_opcode4_count == 0); + }; + let selected_rows = rows + .iter() + .filter(|row| Some(row.table_index) == control.initial_effect_group) + .collect::<Vec<_>>(); + if selected_rows.len() != selected_opcode4_count { + return Ok(false); + } + if selected_rows.is_empty() { + return Ok(true); + } + let Some(points) = control.point_list.as_ref() else { + return Ok(false); + }; + + for row in selected_rows { + if !row.point_offsets.iter().all(|offset| { + *offset != u32::MAX + && usize::try_from(*offset).is_ok_and(|index| index < points.points.len()) + }) { + return Ok(false); + } + let Some(key) = factory2_fx_resource_key(&row.resource) else { + return Ok(false); + }; + if !factory2_fx_resource_is_representable(asset_manager.repository(), &key, row)? { + return Ok(false); + } + } + Ok(true) +} + +fn factory2_fx_resource_key(resource: &PreparedControlResourcePair) -> Option<ResourceKey> { + let archive = bounded_ascii_c_string(&resource.archive)?; + let name = bounded_ascii_c_string(&resource.name)?; + if archive.is_empty() || name.is_empty() { + return None; + } + Some(ResourceKey { + archive: normalize_relative(archive.as_bytes(), PathPolicy::StrictLegacy).ok()?, + name: resource_name(name.as_bytes()), + type_id: Some(FXID_KIND), + }) +} + +fn factory2_fx_resource_is_representable<R: ResourceRepository>( + repository: &R, + key: &ResourceKey, + row: &PreparedControlEffectRow, +) -> Result<bool, AssetPreparationError> { + let context = format!( + "factory-2 selected Control effect group {} row {} FX {}:{}", + row.table_index, + row.row_index, + key.archive.as_str(), + String::from_utf8_lossy(&key.name.0) + ); + let archive = match repository.open_archive_unchanged(&key.archive) { + Ok(archive) => archive, + Err(ResourceError::MissingArchive { .. } | ResourceError::MissingEntry) => { + return Ok(false); + } + Err(source) => { + return Err(AssetPreparationError::Resource { + context, + source: Box::new(source), + }); + } + }; + let entry = + repository + .find(archive, &key.name) + .map_err(|source| AssetPreparationError::Resource { + context: context.clone(), + source: Box::new(source), + })?; + let Some(entry) = entry else { + return Ok(false); + }; + let info = repository + .entry_info(entry) + .map_err(|source| AssetPreparationError::Resource { + context: context.clone(), + source: Box::new(source), + })?; + if info.key.type_id != Some(FXID_KIND) { + return Ok(false); + } + let bytes = repository + .read(entry) + .map_err(|source| AssetPreparationError::Resource { + context: context.clone(), + source: Box::new(source), + })?; + let document = decode_fxid(Arc::from(bytes.into_owned())).map_err(|source| { + AssetPreparationError::InvalidPrototype(format!("{context}: {source}")) + })?; + Ok(document.commands().iter().all(|command| { + matches!( + command.opcode, + FxOpcode::Op2 + | FxOpcode::Op3 + | FxOpcode::Op4 + | FxOpcode::Op5 + | FxOpcode::Op7 + | FxOpcode::Op8 + | FxOpcode::Op9 + ) + })) +} + +fn factory2_node_data_maximum_bits( + rows: &[PreparedControlNodeDataRow], +) -> Result<u32, &'static str> { + let mut maximum = 0.0_f32; + for row in rows { + let value = checked_control_float(2, "factory-2 NDP Max", row.max_volume) + .map_err(|_| "NDP Max float")?; + maximum = checked_control_sum(2, "factory-2 NDP Max sum", maximum, value) + .map_err(|_| "NDP Max sum")?; + } + Ok(maximum.to_bits()) +} + /// Common fields known for one row in a host object's Control device table. /// /// The row index is its position in the containing `Vec`. This stores the @@ -225,6 +472,8 @@ pub struct EffectiveControlDevice { pub class9_selector0_scale_bits: Option<u32>, /// Factory-26 mutable resource-rate addition at native `+0xa4`. pub tag26_extra_rate_bits: Option<u32>, + /// Factory-2 value read by selector 6 at native `+0x174`. + pub device174_bits: Option<u32>, /// Six class-9 budget values at native `+0x98..+0xac`. pub class9_values_bits: Option<[u32; 6]>, /// Six accumulated class-9 changes at native `+0xb0..+0xc4`. @@ -1722,7 +1971,9 @@ fn load_mission_with_options( Vec::new() }; + let mut first_factory2_asset_error = None; let control_device_tables = if options.asset_scope == MissionAssetScope::Full { + let mut factory2_pair_cache = HashMap::new(); object_drafts .iter() .zip(&handles) @@ -1736,6 +1987,29 @@ fn load_mission_with_options( prototype_span, &resolved_prototypes, ); + let mut resolve_factory2_pair = |pair: &Factory2StringPair| { + if first_factory2_asset_error.is_some() { + return Factory2PairOutcome::Unrepresentable; + } + let cache_key = pair.cache_key(); + if let Some(outcome) = factory2_pair_cache.get(&cache_key) { + return *outcome; + } + match resolve_factory2_pair_from_assets( + &asset_manager, + vfs.as_ref(), + pair, + ) { + Ok(outcome) => { + factory2_pair_cache.insert(cache_key, outcome); + outcome + } + Err(error) => { + first_factory2_asset_error = Some(error); + Factory2PairOutcome::Unrepresentable + } + } + }; let projection = build_control_device_projection_with_class( object_index, draft, @@ -1743,6 +2017,7 @@ fn load_mission_with_options( prototype_span, &resolved_prototypes, native_control_class, + &mut resolve_factory2_pair, ); match projection { Ok(projection) => MissionControlDeviceTableState { @@ -1786,6 +2061,13 @@ fn load_mission_with_options( } else { Vec::new() }; + if let Some(source) = first_factory2_asset_error { + let _ = fparkan_world::shutdown(new_runtime_world); + return Err(EngineError::AssetPreparation { + mission: request.key.clone(), + source, + }); + } let behavior_control_groupings = if options.asset_scope == MissionAssetScope::Full { control_device_tables .iter() @@ -2309,12 +2591,17 @@ pub fn query_loaded_mission_control_resource( /// it by coefficient 0. Only current tag 26 also multiplies by `M`; current /// tag 5 ignores `M` and descriptor word 8 for this selector. Explicit Control /// update calls can change current provider or multiplier values; the mission -/// clock does not advance them automatically. +/// clock does not advance them automatically. Selector `6` first checks the +/// current descriptor tag: a non-2 tag returns `Ok(None)`. For the represented +/// factory-2 layout it reads `+0x174`, subject to the normal-or-zero float +/// range checks; an unsupported field or layout returns a typed error. This is +/// a query of the loaded CPU projection, not an FX execution or game tick. /// /// # Errors /// Returns an error for an invalid handle, unavailable mission/table/provider, /// an out-of-range device/provider key, an unsupported float range, or an -/// unsupported current/factory tag on selector `0xe00`. +/// unsupported current/factory tag on selector `0xe00`, or an unsupported +/// selector-6 factory field/layout. pub fn query_loaded_mission_control_device_selector( engine: &Engine, handle: ObjectHandle, @@ -4416,7 +4703,7 @@ fn query_control_device_table_selector( device_index: usize, selector: u32, ) -> Result<Option<f32>, ControlResourceAccessError> { - if !matches!(selector, 0x400 | 0xe00) { + if !matches!(selector, 6 | 0x400 | 0xe00) { return Err(ControlResourceAccessError::UnsupportedSelector(selector)); } let devices = match &table.status { @@ -4436,6 +4723,17 @@ fn query_control_device_table_selector( return control_device_provider_value_from_status(&table.node_provider_status, device) .map(Some); } + if selector == 6 { + if device.descriptor.tag != 2 { + return Ok(None); + } + if device.factory_tag != 2 { + return Err(ControlResourceAccessError::UnsupportedFactoryTag { + factory_tag: device.factory_tag, + }); + } + return control_device_float(device, device.device174_bits, "+0x174").map(Some); + } query_control_device_selector(device, &table.node_provider_status) } @@ -5794,7 +6092,7 @@ fn append_effective_control_device( } else { Some(descriptor.fixed_words[6]) }; - devices.push(EffectiveControlDevice { + let device = EffectiveControlDevice { factory_tag: descriptor.tag, component_id, key, @@ -5802,6 +6100,7 @@ fn append_effective_control_device( multiplier_bits, class9_selector0_scale_bits: (descriptor.tag == 9).then_some(0x3f80_0000), tag26_extra_rate_bits: (descriptor.tag == 26).then_some(0), + device174_bits: (descriptor.tag == 2).then_some(0), class9_values_bits: (descriptor.tag == 9).then_some([0x3f80_0000; 6]), class9_deltas_bits: (descriptor.tag == 9).then_some([0; 6]), native_word_50_bits, @@ -5809,7 +6108,65 @@ fn append_effective_control_device( descriptor: descriptor.clone(), factory_provenance: provenance.clone(), descriptor_provenance: provenance, - }); + }; + devices.push(device); +} + +fn bounded_ascii_c_string(raw: &[u8]) -> Option<String> { + let end = raw.iter().position(|byte| *byte == 0)?; + let bytes = &raw[..end]; + if !bytes.is_ascii() { + return None; + } + std::str::from_utf8(bytes).ok().map(str::to_owned) +} + +fn fixed_words_ascii_c_string(words: &[u32]) -> Option<String> { + let bytes = words + .iter() + .flat_map(|word| word.to_le_bytes()) + .collect::<Vec<_>>(); + bounded_ascii_c_string(&bytes) +} + +fn factory2_pair_from_record( + descriptor: &PreparedControlTypedRecord, +) -> Option<Factory2StringPair> { + Some(Factory2StringPair { + archive: fixed_words_ascii_c_string(&descriptor.fixed_words[27..35])?, + name: fixed_words_ascii_c_string(&descriptor.fixed_words[35..43])?, + }) +} + +fn apply_factory2_pair_update( + device: &mut EffectiveControlDevice, + previous_pair: Option<Factory2StringPair>, + descriptor: &PreparedControlTypedRecord, + resolve_factory2_pair: &mut dyn FnMut(&Factory2StringPair) -> Factory2PairOutcome, +) { + if device.factory_tag != 2 { + return; + } + let Some(pair) = factory2_pair_from_record(descriptor) else { + device.device174_bits = None; + return; + }; + if previous_pair + .as_ref() + .is_some_and(|previous| previous.matches_ascii_case(&pair)) + { + return; + } + let outcome = if pair.archive.is_empty() || pair.name.is_empty() { + Factory2PairOutcome::NativeMiss + } else { + resolve_factory2_pair(&pair) + }; + match outcome { + Factory2PairOutcome::Value(bits) => device.device174_bits = Some(bits), + Factory2PairOutcome::NativeMiss => {} + Factory2PairOutcome::Unrepresentable => device.device174_bits = None, + } } fn apply_effective_control_override( @@ -5902,6 +6259,7 @@ fn build_control_device_projection( ) -> Result<ControlDeviceProjection, MissionControlDeviceTableUnavailableReason> { let native_class = native_control_class_for_root(draft.unit_dat_header_flags, prototype_span, prototypes); + let mut resolve_factory2_pair = |_: &Factory2StringPair| Factory2PairOutcome::Unrepresentable; build_control_device_projection_with_class( object_index, draft, @@ -5909,6 +6267,7 @@ fn build_control_device_projection( prototype_span, prototypes, native_class, + &mut resolve_factory2_pair, ) } @@ -5919,6 +6278,7 @@ fn build_control_device_projection_with_class( prototype_span: Option<&std::ops::Range<usize>>, prototypes: &[fparkan_prototype::EffectivePrototype], native_control_class: Option<MissionControlClassProjection>, + resolve_factory2_pair: &mut dyn FnMut(&Factory2StringPair) -> Factory2PairOutcome, ) -> Result<ControlDeviceProjection, MissionControlDeviceTableUnavailableReason> { use MissionControlDeviceTableUnavailableReason as Unavailable; @@ -6227,6 +6587,12 @@ fn build_control_device_projection_with_class( control, descriptor, ); + apply_factory2_pair_update( + devices.last_mut().expect("append inserted one device"), + Some(Factory2StringPair::empty()), + descriptor, + resolve_factory2_pair, + ); initialize_control_profile_cache27(&mut profile_cache27_bits, descriptor); } continue; @@ -6267,6 +6633,12 @@ fn build_control_device_projection_with_class( control, descriptor, ); + apply_factory2_pair_update( + devices.last_mut().expect("append inserted one device"), + Some(Factory2StringPair::empty()), + descriptor, + resolve_factory2_pair, + ); initialize_control_profile_cache27(&mut profile_cache27_bits, descriptor); } } @@ -6295,6 +6667,13 @@ fn build_control_device_projection_with_class( factory_tag: device.factory_tag, }); } + let previous_pair = factory2_pair_from_record(&device.descriptor); + apply_factory2_pair_update( + device, + previous_pair, + descriptor, + resolve_factory2_pair, + ); apply_effective_control_override(device, descriptor, component_index, 0, control); update_control_profile_cache27_on_into(&mut profile_cache27_bits, descriptor); } @@ -7014,9 +7393,983 @@ fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, Engi #[cfg(test)] mod tests { use super::*; - use fparkan_script::ScriptDispatchSelector; + use fparkan_assets::PreparedControlPointList; use fparkan_vfs::{DirectoryVfs, MemoryVfs, OverlayVfs, VfsEntry, VfsMetadata}; use std::path::{Path, PathBuf}; + #[test] + fn factory2_selector6_reads_fresh_constructor_zero() { + let device = synthetic_effective_control_device(2, 2, 0, None, None); + assert!(device.descriptor.fixed_words[27..43] + .iter() + .all(|word| *word == 0)); + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + device, + ])); + + assert_eq!( + query_control_device_table_selector(&table, 0, 6), + Ok(Some(0.0)) + ); + } + + #[test] + fn factory2_selector6_returns_the_stored_float_word() { + let mut device = synthetic_effective_control_device(2, 2, 0, None, None); + device.device174_bits = Some(6.25_f32.to_bits()); + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + device, + ])); + + assert_eq!( + query_control_device_table_selector(&table, 0, 6), + Ok(Some(6.25)) + ); + } + + #[test] + fn factory2_selector6_preserves_stored_negative_zero() { + let mut device = synthetic_effective_control_device(2, 2, 0, None, None); + device.device174_bits = Some((-0.0_f32).to_bits()); + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + device, + ])); + + assert_eq!( + query_control_device_table_selector(&table, 0, 6) + .expect("negative zero is a valid native float") + .expect("tag 2 has a selector-6 value") + .to_bits(), + (-0.0_f32).to_bits() + ); + } + + #[test] + fn factory2_selector6_reports_missing_wrong_factory_nonfinite_and_subnormal_values() { + let table_for = |device| { + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![device])) + }; + + let mut missing = synthetic_effective_control_device(2, 2, 0, None, None); + missing.device174_bits = None; + assert_eq!( + query_control_device_table_selector(&table_for(missing), 0, 6), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 2, + field: "+0x174", + }) + ); + + let mut wrong_factory = synthetic_effective_control_device(5, 2, 0, None, None); + wrong_factory.device174_bits = Some(1.0_f32.to_bits()); + assert_eq!( + query_control_device_table_selector(&table_for(wrong_factory), 0, 6), + Err(ControlResourceAccessError::UnsupportedFactoryTag { factory_tag: 5 }) + ); + + let mut nonfinite = synthetic_effective_control_device(2, 2, 0, None, None); + nonfinite.device174_bits = Some(f32::INFINITY.to_bits()); + assert_eq!( + query_control_device_table_selector(&table_for(nonfinite), 0, 6), + Err(ControlResourceAccessError::NonFiniteValue { + factory_tag: 2, + field: "+0x174", + }) + ); + + let mut subnormal = synthetic_effective_control_device(2, 2, 0, None, None); + subnormal.device174_bits = Some(1); + assert_eq!( + query_control_device_table_selector(&table_for(subnormal), 0, 6), + Err(ControlResourceAccessError::SubnormalValue { + factory_tag: 2, + field: "+0x174", + }) + ); + } + + #[test] + fn factory2_selector6_returns_no_result_for_a_non_two_current_tag() { + let mut device = synthetic_effective_control_device(5, 5, 0, None, None); + device.device174_bits = None; + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + device, + ])); + + assert_eq!(query_control_device_table_selector(&table, 0, 6), Ok(None)); + } + + #[test] + fn factory2_pair_updates_keep_append_and_into_order_and_native_miss_bits() { + let mut root_record = synthetic_control_record(2, 0, 0, None); + set_factory2_pair(&mut root_record, b"objects.rlb", b"root"); + let mut same_record = synthetic_control_record(2, 0, 0, None); + set_factory2_pair(&mut same_record, b"OBJECTS.RLB", b"ROOT"); + let mut changed_record = synthetic_control_record(2, 0, 0, None); + set_factory2_pair(&mut changed_record, b"objects.rlb", b"second"); + let mut empty_record = synthetic_control_record(2, 0, 0, None); + set_factory2_pair(&mut empty_record, b"objects.rlb", b""); + let controls = [root_record, same_record, changed_record, empty_record] + .into_iter() + .enumerate() + .map(|(index, record)| { + Some(synthetic_component_control( + index, + vec![record], + PreparedControlEffectSection::Parsed(Vec::new()), + )) + }) + .collect(); + let (draft, assets, prototypes) = synthetic_control_fixture( + &[ + None, + Some(PROTOTYPE_TYPE_INTO), + Some(PROTOTYPE_TYPE_INTO), + Some(PROTOTYPE_TYPE_INTO), + ], + &[1, 1, 1, 1], + &[3, 0, 0, 0], + &[0, 0, 0, 0], + &[1, 0, 0, 0], + controls, + ); + let mut resolved = Vec::new(); + let mut resolver = |pair: &Factory2StringPair| { + resolved.push((pair.archive.clone(), pair.name.clone())); + match pair.name.as_str() { + "root" => Factory2PairOutcome::Value(6.25_f32.to_bits()), + "second" => Factory2PairOutcome::Value(9.5_f32.to_bits()), + _ => Factory2PairOutcome::Unrepresentable, + } + }; + + let projection = build_control_device_projection_with_class( + 0, + &draft, + &assets, + Some(&(0..prototypes.len())), + &prototypes, + None, + &mut resolver, + ) + .expect("factory-2 append plus three INTO updates"); + + assert_eq!( + resolved, + [ + ("objects.rlb".to_string(), "root".to_string()), + ("objects.rlb".to_string(), "second".to_string()) + ], + "same pair skips recalculation and an empty name is a native miss" + ); + assert_eq!(projection.devices.len(), 1); + assert_eq!(projection.devices[0].factory_tag, 2); + assert_eq!( + projection.devices[0].device174_bits, + Some(9.5_f32.to_bits()) + ); + assert_eq!( + projection.devices[0] + .descriptor_provenance + .unit_component_index, + 3, + "INTO retains the last raw descriptor even when selector state is preserved" + ); + } + + #[test] + fn factory2_pair_parser_validates_nul_and_ascii_before_it() { + let mut record = synthetic_control_record(2, 0, 0, None); + set_factory2_pair(&mut record, b"objects.rlb", b"name"); + assert_eq!( + factory2_pair_from_record(&record).map(|pair| (pair.archive, pair.name)), + Some(("objects.rlb".to_string(), "name".to_string())) + ); + + record.fixed_words[27..35].fill(u32::from_le_bytes(*b"ABCD")); + assert!(factory2_pair_from_record(&record).is_none(), "missing NUL"); + + record.fixed_words[27] = u32::from_le_bytes([0xff, 0, 0, 0]); + assert!( + factory2_pair_from_record(&record).is_none(), + "non-ASCII byte" + ); + + record.fixed_words[27] = u32::from_le_bytes([b'a', 0, b'b', 0]); + let first = factory2_pair_from_record(&record).expect("NUL ends the archive string"); + assert_eq!(first.archive, "a"); + let mut different_padding = record.clone(); + different_padding.fixed_words[27] = u32::from_le_bytes([b'A', 0, b'c', 0]); + let second = factory2_pair_from_record(&different_padding) + .expect("native C-string comparison ignores bytes after NUL"); + assert!(first.matches_ascii_case(&second)); + } + + #[test] + fn factory2_unrepresentable_pair_clears_only_the_available_projection() { + let mut device = synthetic_effective_control_device(2, 2, 0, None, None); + device.device174_bits = Some(6.25_f32.to_bits()); + let mut replacement = synthetic_control_record(2, 0, 0, None); + set_factory2_pair(&mut device.descriptor, b"objects.rlb", b"known"); + set_factory2_pair(&mut replacement, b"objects.rlb", b"unknown"); + let previous_pair = factory2_pair_from_record(&device.descriptor); + let mut resolver = |_: &Factory2StringPair| Factory2PairOutcome::Unrepresentable; + apply_factory2_pair_update(&mut device, previous_pair, &replacement, &mut resolver); + + assert_eq!(device.device174_bits, None); + assert_eq!( + control_device_float(&device, device.device174_bits, "+0x174"), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 2, + field: "+0x174", + }) + ); + } + + #[test] + fn factory2_pair_cache_key_folds_ascii_case() { + let upper = Factory2StringPair { + archive: "OBJECTS.RLB".to_string(), + name: "FOREIGN".to_string(), + }; + let lower = Factory2StringPair { + archive: "objects.rlb".to_string(), + name: "foreign".to_string(), + }; + + assert_eq!(upper.cache_key(), lower.cache_key()); + } + + #[test] + fn factory2_ndp_maximum_uses_ordered_f32_sum_and_accepts_empty_tables() { + let mut rows = [0.0_f32; 3].map(|max_volume| PreparedControlNodeDataRow { + raw_flags: 0, + max_volume, + mobility_per_volume: 0.0, + effect: PreparedControlResourcePair { + archive: [0; 32], + name: [0; 32], + }, + }); + rows[0].max_volume = 16_777_216.0; + rows[1].max_volume = 1.0; + rows[2].max_volume = -16_777_216.0; + + assert_eq!( + factory2_node_data_maximum_bits(&rows), + Ok(0.0_f32.to_bits()) + ); + assert_eq!(factory2_node_data_maximum_bits(&[]), Ok(0.0_f32.to_bits())); + assert_eq!( + factory2_node_data_maximum_bits(&[PreparedControlNodeDataRow { + max_volume: f32::from_bits(1), + ..rows[0].clone() + }]), + Err("NDP Max float") + ); + } + + fn factory2_test_nres(entries: &[(&[u8], u32, u32, &[u8])]) -> Vec<u8> { + let mut bytes = vec![0; 16]; + let mut offsets = Vec::with_capacity(entries.len()); + for (_, _, _, payload) in entries { + offsets.push(u32::try_from(bytes.len()).expect("payload offset fits u32")); + bytes.extend_from_slice(payload); + bytes.resize(bytes.len() + (8 - bytes.len() % 8) % 8, 0); + } + let mut sorted_indices = (0..entries.len()).collect::<Vec<_>>(); + sorted_indices.sort_by(|left, right| entries[*left].0.cmp(entries[*right].0)); + let mut order = vec![0_u32; entries.len()]; + for (rank, index) in sorted_indices.into_iter().enumerate() { + order[index] = u32::try_from(rank).expect("entry rank fits u32"); + } + for (index, (name, type_id, attr3, payload)) in entries.iter().enumerate() { + assert!(name.len() < 36, "NRes fixture name needs a bounded NUL"); + for word in [ + *type_id, + 0, + 0, + u32::try_from(payload.len()).expect("payload length fits u32"), + *attr3, + ] { + bytes.extend_from_slice(&word.to_le_bytes()); + } + let mut name_raw = [0; 36]; + name_raw[..name.len()].copy_from_slice(name); + bytes.extend_from_slice(&name_raw); + bytes.extend_from_slice(&offsets[index].to_le_bytes()); + bytes.extend_from_slice(&order[index].to_le_bytes()); + } + bytes[0..4].copy_from_slice(b"NRes"); + bytes[4..8].copy_from_slice(&0x100_u32.to_le_bytes()); + bytes[8..12].copy_from_slice( + &u32::try_from(entries.len()) + .expect("entry count fits u32") + .to_le_bytes(), + ); + let total_size = u32::try_from(bytes.len()).expect("archive size fits u32"); + bytes[12..16].copy_from_slice(&total_size.to_le_bytes()); + bytes + } + + fn factory2_test_object_refs(items: &[(&[u8], &[u8])]) -> Vec<u8> { + let mut bytes = Vec::with_capacity(items.len() * 64); + for (archive, name) in items { + assert!(archive.len() < 32 && name.len() < 32); + let mut record = [0; 64]; + record[..archive.len()].copy_from_slice(archive); + record[32..32 + name.len()].copy_from_slice(name); + bytes.extend_from_slice(&record); + } + bytes + } + + fn factory2_test_control_payload( + pair: Option<(&[u8], &[u8])>, + effect_opcode: Option<u32>, + ) -> Vec<u8> { + let mut payload = vec![0; 5 * 4 + 108]; + if let Some((archive, name)) = pair { + payload[12..16].copy_from_slice(&1_u32.to_le_bytes()); + let record_start = payload.len(); + payload.resize(record_start + 0xb0 + 8, 0); + payload[record_start..record_start + 4].copy_from_slice(&2_u32.to_le_bytes()); + let archive_start = record_start + 27 * 4; + payload[archive_start..archive_start + archive.len()].copy_from_slice(archive); + let name_start = record_start + 35 * 4; + payload[name_start..name_start + name.len()].copy_from_slice(name); + } + if let Some(opcode) = effect_opcode { + payload[16..20].copy_from_slice(&1_u32.to_le_bytes()); + let group_start = payload.len(); + payload.resize(group_start + 0x54 + 4 + 0x64, 0); + payload[group_start + 0x54..group_start + 0x58].copy_from_slice(&1_u32.to_le_bytes()); + payload[group_start + 0x58 + 0x0c..group_start + 0x58 + 0x10] + .copy_from_slice(&opcode.to_le_bytes()); + } + payload + } + + fn factory2_test_mesh_payload() -> Vec<u8> { + let res2 = vec![0; 0x8c]; + factory2_test_nres(&[ + (b"Res1", 1, 38, &[]), + (b"Res13", 13, 0, &[]), + (b"Res2", 2, 0, &res2), + (b"Res3", 3, 0, &[]), + (b"Res6", 6, 0, &[]), + ]) + } + + fn factory2_test_mat0() -> Vec<u8> { + let mut bytes = vec![0; 4 + 34]; + bytes[0..2].copy_from_slice(&1_u16.to_le_bytes()); + bytes[22..27].copy_from_slice(b"TEX_A"); + bytes + } + + fn factory2_test_texm() -> Vec<u8> { + let mut bytes = Vec::new(); + for word in [0x6d78_6554_u32, 1, 1, 1, 0, 0, 0, 565] { + bytes.extend_from_slice(&word.to_le_bytes()); + } + bytes.extend_from_slice(&0xffff_u16.to_le_bytes()); + bytes + } + + fn factory2_test_resolver_archives( + foreign_control: &[u8], + ndp_maxima: &[f32], + ) -> Vec<(&'static str, Vec<u8>)> { + let host_refs = factory2_test_object_refs(&[ + (b"static.rlb", b"model.msh"), + (b"controls.rlb", b"host.ctl"), + ]); + let foreign_refs = factory2_test_object_refs(&[ + (b"static.rlb", b"model.msh"), + (b"controls.rlb", b"foreign.ctl"), + (b"providers.rlb", b"foreign.ndp"), + ]); + let objects = factory2_test_nres(&[ + (b"foreign", CONTROL_CLASS_SOURCE_BULL, 0, &foreign_refs), + (b"host", CONTROL_CLASS_SOURCE_BULL, 0, &host_refs), + ]); + let mesh = factory2_test_mesh_payload(); + let static_archive = factory2_test_nres(&[ + (b"model.msh", 0, 0, &mesh), + (b"model.wea", 0x5241_4557, 0, b"1\n0 MAT_A\n"), + ]); + let host_control = factory2_test_control_payload(Some((b"objects.rlb", b"foreign")), None); + let control_archive = factory2_test_nres(&[ + (b"foreign.ctl", 0, 0, foreign_control), + (b"host.ctl", 0, 0, &host_control), + ]); + let mut node_data = vec![0; 4 + ndp_maxima.len() * 76]; + node_data[..4].copy_from_slice( + &u32::try_from(ndp_maxima.len()) + .expect("NDP count fits u32") + .to_le_bytes(), + ); + for (index, maximum) in ndp_maxima.iter().enumerate() { + let start = 4 + index * 76; + node_data[start + 4..start + 8].copy_from_slice(&maximum.to_bits().to_le_bytes()); + } + let node_data_archive = factory2_test_nres(&[(b"foreign.ndp", 0, 0, &node_data)]); + let mat0 = factory2_test_mat0(); + let material_archive = factory2_test_nres(&[(b"MAT_A", 0x3054_414D, 0, &mat0)]); + let texm = factory2_test_texm(); + let texture_archive = factory2_test_nres(&[(b"TEX_A", 0, 0, &texm)]); + vec![ + ("objects.rlb", objects), + ("static.rlb", static_archive), + ("controls.rlb", control_archive), + ("providers.rlb", node_data_archive), + ("material.lib", material_archive), + ("textures.lib", texture_archive), + ] + } + + fn factory2_test_resolver_vfs(foreign_control: &[u8]) -> Arc<dyn Vfs> { + factory2_test_resolver_vfs_with_maxima(foreign_control, &[]) + } + + fn factory2_test_resolver_vfs_with_maxima( + foreign_control: &[u8], + ndp_maxima: &[f32], + ) -> Arc<dyn Vfs> { + let mut files = MemoryVfs::default(); + for (path, bytes) in factory2_test_resolver_archives(foreign_control, ndp_maxima) { + files.insert( + normalize_engine_path("factory-2 resolver fixture", path).expect("archive path"), + Arc::from(bytes.into_boxed_slice()), + ); + } + Arc::new(files) + } + + fn factory2_test_fxid_payload(opcodes: &[u32]) -> Vec<u8> { + let command_size = |opcode| match opcode { + 1 => 224, + 2 => 148, + 3 => 200, + 4 => 204, + 5 => 112, + 6 => 4, + 7 | 9 | 10 => 208, + 8 => 248, + _ => panic!("fixture opcode {opcode} is not framed"), + }; + let mut payload = vec![0; 60]; + payload[..4].copy_from_slice( + &u32::try_from(opcodes.len()) + .expect("FX command count fits u32") + .to_le_bytes(), + ); + for opcode in opcodes { + let start = payload.len(); + payload.resize(start + command_size(*opcode), 0); + payload[start..start + 4].copy_from_slice(&opcode.to_le_bytes()); + } + payload + } + + fn factory2_test_fxid_manager( + entries: &[(&[u8], u32, &[u8])], + ) -> AssetManager<CachedResourceRepository> { + let archive_entries = entries + .iter() + .map(|(name, type_id, payload)| (*name, *type_id, 0, *payload)) + .collect::<Vec<_>>(); + let archive = factory2_test_nres(&archive_entries); + let mut files = MemoryVfs::default(); + files.insert( + normalize_engine_path("factory-2 FX fixture", "effects.rlb").expect("FX archive path"), + Arc::from(archive.into_boxed_slice()), + ); + let vfs: Arc<dyn Vfs> = Arc::new(files); + AssetManager::new(CachedResourceRepository::new(vfs)) + } + + fn factory2_test_effect_resource_pair( + archive: &[u8], + name: &[u8], + ) -> PreparedControlResourcePair { + fn fixed_string(raw: &[u8]) -> [u8; 32] { + assert!(raw.len() <= 32); + let mut bytes = [0; 32]; + bytes[..raw.len()].copy_from_slice(raw); + bytes + } + + PreparedControlResourcePair { + archive: fixed_string(archive), + name: fixed_string(name), + } + } + + fn factory2_test_effect_row( + table_index: usize, + row_index: usize, + archive: &[u8], + name: &[u8], + point_offsets: [u32; 3], + ) -> PreparedControlEffectRow { + PreparedControlEffectRow { + table_index, + row_index, + raw_condition: [0; 3], + point_offsets, + selector: 0, + resource: factory2_test_effect_resource_pair(archive, name), + } + } + + fn factory2_test_control_effects( + rows: Vec<PreparedControlEffectRow>, + initial_effect_group: Option<usize>, + point_count: Option<usize>, + ) -> PreparedComponentControl { + PreparedComponentControl { + source: synthetic_resource_key(940), + native_profile_words: [0; 27], + model_node_count: 3, + bindings: Vec::new(), + timeline_rows: Vec::new(), + typed_records: Vec::new(), + node_data: None, + effect_section: PreparedControlEffectSection::Parsed(rows), + initial_effect_group, + point_list: point_count.map(|count| PreparedControlPointList { + source: synthetic_resource_key(941), + points: (0..count) + .map(|_| fparkan_assets::PreparedControlPoint { + raw_flags: 0, + raw_signed_field_04: 0, + raw_signed_field_08: 0, + position: [0.0; 3], + direction: [0.0; 3], + name: [0; 32], + }) + .collect(), + }), + } + } + + fn factory2_test_full_load_vfs(foreign_control: &[u8]) -> Arc<dyn Vfs> { + factory2_test_full_load_vfs_with_maxima(foreign_control, &[]) + } + + fn factory2_test_full_load_vfs_with_maxima( + foreign_control: &[u8], + ndp_maxima: &[f32], + ) -> Arc<dyn Vfs> { + fn push_u32(bytes: &mut Vec<u8>, value: u32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + fn push_lp(bytes: &mut Vec<u8>, value: &[u8]) { + push_u32(bytes, u32::try_from(value.len()).expect("length fits u32")); + bytes.extend_from_slice(value); + } + fn push_f32(bytes: &mut Vec<u8>, value: f32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + + let mut mission = Vec::new(); + push_u32(&mut mission, 1); + push_u32(&mut mission, 0); + push_u32(&mut mission, 6); + push_u32(&mut mission, 1); + push_lp(&mut mission, b"Synthetic clan"); + push_u32(&mut mission, u32::MAX); + push_f32(&mut mission, 0.0); + push_f32(&mut mission, 0.0); + push_u32(&mut mission, 1); + push_lp(&mut mission, b"MISSIONS/SCRIPTS/shared"); + push_u32(&mut mission, 0); + push_lp(&mut mission, b""); + push_u32(&mut mission, 0); + push_u32(&mut mission, 0); + push_u32(&mut mission, 10); + push_u32(&mut mission, 1); + push_u32(&mut mission, 1); + push_u32(&mut mission, 0x8000_0002); + push_lp(&mut mission, b"UNITS/FACTORY2/host.dat"); + push_u32(&mut mission, 0); + push_u32(&mut mission, 0); + for value in [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0] { + push_f32(&mut mission, value); + } + push_lp(&mut mission, b"host"); + push_u32(&mut mission, 0); + push_u32(&mut mission, 0); + push_u32(&mut mission, 0); + push_u32(&mut mission, 1); + push_u32(&mut mission, 0); + push_lp(&mut mission, b"DATA\\MAPS\\SYNTHETIC\\land"); + push_u32(&mut mission, 0); + push_lp(&mut mission, b""); + push_u32(&mut mission, 1); + push_u32(&mut mission, 0); + + let mut unit_dat = vec![0; 8 + 112]; + unit_dat[4..8].copy_from_slice(&0_u32.to_le_bytes()); + unit_dat[8..19].copy_from_slice(b"objects.rlb"); + unit_dat[40..44].copy_from_slice(b"host"); + unit_dat[76..80].copy_from_slice(&0_i32.to_le_bytes()); + + let mut files = MemoryVfs::default(); + for (path, bytes) in [ + ("MISSIONS/TEST/data.tma", mission), + ("UNITS/FACTORY2/host.dat", unit_dat), + ("DATA/MAPS/SYNTHETIC/Land.msh", empty_land_mesh()), + ("DATA/MAPS/SYNTHETIC/Land.map", minimal_land_map()), + ( + "MISSIONS/SCRIPTS/shared.scr", + encode_script_package(&ScriptPackage { + opcode_handler_count: 73, + events: Vec::new(), + trailing_bytes: Vec::new(), + raw: Arc::from(&b""[..]), + }), + ), + ( + "MISSIONS/SCRIPTS/shared.var", + b"VAR(DWORD, value, 0)\n".to_vec(), + ), + ] { + files.insert( + normalize_engine_path("factory-2 full-load fixture", path).expect("test path"), + Arc::from(bytes.into_boxed_slice()), + ); + } + for (path, bytes) in factory2_test_resolver_archives(foreign_control, ndp_maxima) { + files.insert( + normalize_engine_path("factory-2 full-load archive", path).expect("archive path"), + Arc::from(bytes.into_boxed_slice()), + ); + } + files.insert( + normalize_engine_path("factory-2 full-load BuildDat", "BuildDat.lst") + .expect("BuildDat path"), + Arc::from(&b""[..]), + ); + Arc::new(files) + } + + #[test] + fn factory2_resolver_accepts_empty_effects_bull_and_ordered_ndp_rows() { + let control = factory2_test_control_payload(None, None); + let vfs = + factory2_test_resolver_vfs_with_maxima(&control, &[16_777_216.0, 1.0, -16_777_216.0]); + let manager = AssetManager::new(CachedResourceRepository::new(vfs.clone())); + let pair = Factory2StringPair { + archive: "OBJECTS.RLB".to_string(), + name: "FOREIGN".to_string(), + }; + assert_eq!( + resolve_factory2_pair_from_assets(&manager, vfs.as_ref(), &pair) + .expect("valid foreign BULL projection"), + Factory2PairOutcome::Value(0.0_f32.to_bits()) + ); + } + + #[test] + fn factory2_fx_guard_accepts_supported_commands_only_from_selected_group() { + let fxid = factory2_test_fxid_payload(&[2, 3, 4, 5, 7, 8, 9]); + let manager = factory2_test_fxid_manager(&[(b"selected", FXID_KIND, &fxid)]); + let mut selected = factory2_test_effect_row(0, 0, b"effects.rlb", b"selected", [0, 1, 2]); + selected.resource.archive[12] = 0xff; + selected.resource.name[9] = 0xfe; + let unselected = factory2_test_effect_row(1, 0, b"missing.rlb", b"missing", [0, 1, 2]); + let control = factory2_test_control_effects(vec![selected, unselected], Some(0), Some(3)); + + assert!( + factory2_selected_fx_resources_are_representable(&manager, &control, &[4]) + .expect("valid selected FX resource") + ); + } + + #[test] + fn factory2_fx_guard_rejects_unknown_types_missing_inputs_and_bad_points() { + for opcode in [1, 6, 10] { + let fxid = factory2_test_fxid_payload(&[opcode]); + let manager = factory2_test_fxid_manager(&[(b"effect", FXID_KIND, &fxid)]); + let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]); + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + assert!( + !factory2_selected_fx_resources_are_representable(&manager, &control, &[4]) + .expect("framed unknown FX command remains unsupported") + ); + } + + let fxid = factory2_test_fxid_payload(&[2]); + let manager = factory2_test_fxid_manager(&[(b"effect", FXID_KIND, &fxid)]); + for (point_offsets, point_count) in [ + ([0, 1, 0], Some(1)), + ([0, u32::MAX, 0], Some(1)), + ([0, 0, 0], None), + ] { + let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", point_offsets); + let control = factory2_test_control_effects(vec![row], Some(0), point_count); + assert!( + !factory2_selected_fx_resources_are_representable(&manager, &control, &[4]) + .expect("unsupported CPT selector remains unknown") + ); + } + + let missing_entry = factory2_test_fxid_manager(&[]); + let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"absent", [0, 0, 0]); + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + assert!( + !factory2_selected_fx_resources_are_representable(&missing_entry, &control, &[4]) + .expect("missing FX entry is not a decode error") + ); + + let row = factory2_test_effect_row(0, 0, b"absent.rlb", b"effect", [0, 0, 0]); + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + assert!( + !factory2_selected_fx_resources_are_representable(&manager, &control, &[4]) + .expect("missing FX archive is unsupported input") + ); + + let wrong_type = factory2_test_fxid_manager(&[(b"effect", 0, &fxid)]); + let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]); + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + assert!( + !factory2_selected_fx_resources_are_representable(&wrong_type, &control, &[4]) + .expect("wrong NRes type is unsupported, not malformed FXID") + ); + + for archive in [ + [b'x'; 32], + { + let mut raw = [0; 32]; + raw[0] = 0xff; + raw[1] = 0; + raw + }, + factory2_test_effect_resource_pair(b"../effects.rlb", b"effect").archive, + ] { + let mut row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]); + row.resource.archive = archive; + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + assert!( + !factory2_selected_fx_resources_are_representable(&manager, &control, &[4]) + .expect("invalid FX path or string remains unsupported") + ); + } + + let mut row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]); + row.resource.name = [b'x'; 32]; + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + assert!( + !factory2_selected_fx_resources_are_representable(&manager, &control, &[4]) + .expect("unterminated resource name is unsupported input") + ); + } + + #[test] + fn factory2_fx_guard_fails_on_malformed_recognized_fxid() { + let mut malformed_fxid = vec![0; 64]; + malformed_fxid[..4].copy_from_slice(&1_u32.to_le_bytes()); + malformed_fxid[60..64].copy_from_slice(&2_u32.to_le_bytes()); + let manager = factory2_test_fxid_manager(&[(b"effect", FXID_KIND, &malformed_fxid)]); + let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]); + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + + assert!(matches!( + factory2_selected_fx_resources_are_representable(&manager, &control, &[4]), + Err(AssetPreparationError::InvalidPrototype(_)) + )); + } + + #[test] + fn factory2_unsupported_ndp_numeric_projection_keeps_valid_mission_loaded() { + let control = factory2_test_control_payload(None, None); + let request = || MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode: MissionMode::SingleMission, + }; + let unsupported_maxima = [ + ("subnormal", vec![f32::from_bits(1)]), + ("nonfinite", vec![f32::NAN]), + ("ordered sum overflow", vec![f32::MAX, f32::MAX]), + ]; + + for (case, maxima) in unsupported_maxima { + let vfs = factory2_test_full_load_vfs_with_maxima(&control, &maxima); + let mut engine = create(EngineServices::new(vfs)).expect("factory-2 test engine"); + load_mission( + &mut engine, + request(), + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .unwrap_or_else(|error| { + panic!("{case} resolver input aborted a structurally valid load: {error:?}") + }); + + let table = loaded_mission_control_device_tables(&engine) + .expect("loaded Control tables") + .first() + .expect("synthetic host Control table"); + let devices = match &table.status { + MissionControlDeviceTableStatus::Available(devices) => devices, + MissionControlDeviceTableStatus::Unavailable(reason) => { + panic!("synthetic Control table unavailable for {case}: {reason:?}"); + } + }; + assert_eq!(devices.len(), 1, "{case}"); + assert_eq!( + devices[0].device174_bits, None, + "unsupported {case} must stay unknown rather than become zero or NativeMiss" + ); + assert_eq!( + query_loaded_mission_control_device_selector(&engine, table.handle, 0, 6), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 2, + field: "+0x174", + }), + "unsupported {case} remains visible as an unavailable native value" + ); + } + } + + #[test] + fn factory2_resolver_accepts_only_known_initial_effect_opcodes() { + for opcode in [1, 12] { + let control = factory2_test_control_payload(None, Some(opcode)); + let vfs = factory2_test_resolver_vfs(&control); + let manager = AssetManager::new(CachedResourceRepository::new(vfs.clone())); + let pair = Factory2StringPair { + archive: "objects.rlb".to_string(), + name: "foreign".to_string(), + }; + + assert_eq!( + resolve_factory2_pair_from_assets(&manager, vfs.as_ref(), &pair) + .expect("complete known opcode group"), + Factory2PairOutcome::Value(0.0_f32.to_bits()), + "initial opcode {opcode} is covered" + ); + } + + let control = factory2_test_control_payload(None, Some(4)); + let vfs = factory2_test_resolver_vfs(&control); + let manager = AssetManager::new(CachedResourceRepository::new(vfs.clone())); + let pair = Factory2StringPair { + archive: "objects.rlb".to_string(), + name: "foreign".to_string(), + }; + assert_eq!( + resolve_factory2_pair_from_assets(&manager, vfs.as_ref(), &pair) + .expect("opcode 4 remains representable as an unknown shape"), + Factory2PairOutcome::Unrepresentable + ); + } + + #[test] + fn factory2_resolver_keeps_unknown_and_nonbare_pairs_unrepresentable() { + let vfs = factory2_test_resolver_vfs(&factory2_test_control_payload(None, None)); + let manager = AssetManager::new(CachedResourceRepository::new(vfs.clone())); + for pair in [ + Factory2StringPair { + archive: "static.rlb".to_string(), + name: "foreign".to_string(), + }, + Factory2StringPair { + archive: "objects.rlb".to_string(), + name: "foreign.msh".to_string(), + }, + Factory2StringPair { + archive: "objects.rlb".to_string(), + name: "nested/foreign".to_string(), + }, + Factory2StringPair { + archive: "objects.rlb".to_string(), + name: "missing".to_string(), + }, + ] { + assert_eq!( + resolve_factory2_pair_from_assets(&manager, vfs.as_ref(), &pair) + .expect("unknown mapping does not become a native miss"), + Factory2PairOutcome::Unrepresentable + ); + } + } + + #[test] + fn factory2_foreign_asset_error_keeps_loaded_world_and_rng() { + let valid_vfs = factory2_test_full_load_vfs_with_maxima( + &factory2_test_control_payload(None, None), + &[6.25], + ); + let mut engine = create(EngineServices::new(valid_vfs)).expect("factory-2 test engine"); + let request = || MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode: MissionMode::SingleMission, + }; + load_mission( + &mut engine, + request(), + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .expect("initial valid full load"); + let previous_summary = loaded_mission(&engine).expect("loaded mission").clone(); + assert_ne!(engine.behavior_rng_state, INITIAL_BEHAVIOR_RNG_STATE); + let control_table = + &loaded_mission_control_device_tables(&engine).expect("Control table")[0]; + let MissionControlDeviceTableStatus::Available(devices) = &control_table.status else { + panic!("synthetic host Control table should be available"); + }; + assert_eq!(devices.len(), 1); + assert_eq!( + devices[0].device174_bits, + Some(6.25_f32.to_bits()), + "full-load builder resolves the foreign pair lazily" + ); + let handle = control_table.handle; + let retained_transform = TransformState { + position: [17.0_f32.to_bits(), 29.0_f32.to_bits(), 41.0_f32.to_bits()], + orientation: [0x3f00_0000; 3], + scale: [0x3f80_0000; 3], + }; + set_transform(&mut engine.world, handle, retained_transform).expect("set sentinel pose"); + let previous_rng = engine.behavior_rng_state; + engine.services.vfs = Some(factory2_test_full_load_vfs(&[0, 0, 0])); + + let error = load_mission( + &mut engine, + request(), + MissionStartupInput { + queue_time_ms: 100_200, + research_creation_gate: false, + }, + ) + .expect_err("malformed foreign CTLD aborts staged loading"); + + assert!(matches!( + error, + EngineError::AssetPreparation { + mission, + source: AssetPreparationError::InvalidPrototype(_), + } if mission == "MISSIONS/TEST/data.tma" + )); + assert_eq!(loaded_mission(&engine), Some(&previous_summary)); + assert_eq!(engine.behavior_rng_state, previous_rng); + assert_eq!( + transform_state(&engine.world, handle), + Ok(retained_transform) + ); + } fn basement_draft(header_flags: Option<u32>, raw_after_name: u32) -> MissionObjectDraft { MissionObjectDraft { @@ -7063,6 +8416,21 @@ mod tests { } } + fn set_factory2_pair(record: &mut PreparedControlTypedRecord, archive: &[u8], name: &[u8]) { + fn write_words(words: &mut [u32], value: &[u8]) { + assert!(value.len() < words.len() * 4, "fixed name needs a NUL"); + assert!(value.is_ascii(), "synthetic pair must be ASCII"); + let mut bytes = [0; 32]; + bytes[..value.len()].copy_from_slice(value); + for (word, bytes) in words.iter_mut().zip(bytes.chunks_exact(4)) { + *word = u32::from_le_bytes(bytes.try_into().expect("four-byte word")); + } + } + + write_words(&mut record.fixed_words[27..35], archive); + write_words(&mut record.fixed_words[35..43], name); + } + fn synthetic_component_control( index: usize, typed_records: Vec<PreparedControlTypedRecord>, @@ -7427,6 +8795,7 @@ mod tests { multiplier_bits, class9_selector0_scale_bits: class9_scale_bits, tag26_extra_rate_bits: (factory_tag == 26).then_some(0), + device174_bits: (factory_tag == 2).then_some(0), class9_values_bits: (factory_tag == 9).then_some([0x3f80_0000; 6]), class9_deltas_bits: (factory_tag == 9).then_some([0; 6]), native_word_50_bits, @@ -10943,6 +12312,8 @@ mod tests { [7.0_f32.to_bits(), 11.0_f32.to_bits(), 0, 0, 0]; draft.scale = [2.0, 3.0, 5.0]; + let mut resolve_factory2_pair = + |_: &Factory2StringPair| Factory2PairOutcome::Unrepresentable; let projection = build_control_device_projection_with_class( 0, &draft, @@ -10950,6 +12321,7 @@ mod tests { Some(&(0..prototypes.len())), &prototypes, Some(native_class), + &mut resolve_factory2_pair, ) .expect("base device and FAC0 projections"); let profile = projection @@ -13350,12 +14722,14 @@ mod tests { ( "MISSIONS/SCRIPTS/formula.var", Arc::from( - &b"VAR(DWORD, ClanBaseX, 0)\nVAR(DWORD, ClanBaseY, 0)\nVAR(DWORD, ClanID, 0)\nVAR(float, target, 0.5)\n"[..], + &b"VAR(DWORD, ClanBaseX, 0)\nVAR(DWORD, ClanBaseY, 0)\nVAR(DWORD, ClanID, 99)\nVAR(float, target, 0.5)\n"[..], ), ), ( "MISSIONS/SCRIPTS/formula.fml", - Arc::from(&b"// authored literal\n\nFUNCTION( , 0.8, )\nFUNCTION( later, target + 2, )\n"[..]), + Arc::from( + &b"// authored Init expression\n\nFUNCTION( , ClanID + 0.8, )\nFUNCTION( later, target * 2, )\n"[..], + ), ), ] { files.insert( @@ -13392,6 +14766,185 @@ mod tests { } #[test] + #[ignore = "requires licensed corpus"] + fn licensed_fml_variable_rows_resolve_from_shared_varset_defaults() { + fn formula_action(target: u32, row: u32) -> ScriptInstruction { + ScriptInstruction { + header_words: [u32::MAX, target, u32::MAX, u32::MAX, 6, 0, row], + references: Vec::new(), + } + } + + fn init_event_index(package: &ScriptPackage) -> usize { + package + .events + .iter() + .position(|event| { + event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..]) + }) + .expect("Init event") + } + + fn row_is_assigned(instructions: &[ScriptInstruction], row: u32, target: u32) -> bool { + instructions.iter().any(|instruction| { + instruction.header_words[0] == u32::MAX + && instruction.header_words[1] == target + && instruction.header_words[2] == u32::MAX + && instruction.header_words[3] == u32::MAX + && instruction.header_words[6] == row + }) + } + + let root = licensed_root("IS"); + let script_dir = root.join("MISSIONS/SCRIPTS"); + let declarations = fparkan_script::parse_varset( + &std::fs::read(script_dir.join("varset.var")).expect("read shared varset"), + ) + .expect("parse shared varset"); + let formulas3 = FormulaTable::parse( + &std::fs::read(script_dir.join("c1m3e.fml")).expect("read c1m3e.fml"), + ); + let package3 = fparkan_script::decode( + &std::fs::read(script_dir.join("c1m3e.scr")).expect("read c1m3e.scr"), + ) + .expect("decode c1m3e.scr"); + let init3_index = init_event_index(&package3); + let init3_event_word = package3.events[init3_index].event_word; + let init3 = &package3.events[init3_index].instructions; + assert!(row_is_assigned(init3, 0, 40)); + assert!(row_is_assigned(init3, 1, 41)); + assert!(row_is_assigned(init3, 2, 164)); + assert!(!row_is_assigned(init3, 19, 158)); + assert!(!row_is_assigned(init3, 22, 174)); + assert!(row_is_assigned( + &package3 + .events + .iter() + .find(|event| event.name_raw.split(|byte| *byte == 0).next() + == Some(&b"Fort_Captured"[..])) + .expect("Fort_Captured event") + .instructions, + 19, + 158, + )); + assert!(row_is_assigned( + &package3 + .events + .iter() + .find(|event| event.name_raw.split(|byte| *byte == 0).next() + == Some(&b"PBM_BUILDING_PROTECT_Start"[..])) + .expect("building-protection event") + .instructions, + 22, + 174, + )); + assert_eq!(formulas3.rows[19].expression_raw, b" 0.8 + fT"); + assert_eq!( + formulas3.rows[22].expression_raw, + b" dBuildingProtectHits - dT2" + ); + + let f_temp = u32::try_from( + declarations + .declarations + .iter() + .position(|declaration| declaration.name == "fTemp") + .expect("fTemp declaration"), + ) + .expect("fTemp index fits u32"); + let d_temp3 = u32::try_from( + declarations + .declarations + .iter() + .position(|declaration| declaration.name == "dTemp3") + .expect("dTemp3 declaration"), + ) + .expect("dTemp3 index fits u32"); + assert_eq!((f_temp, d_temp3), (158, 174)); + + let mut synthetic3 = package3.clone(); + synthetic3.events[init3_index].instructions = + vec![formula_action(f_temp, 19), formula_action(d_temp3, 22)]; + assert_eq!( + declarations.resolve_init( + &synthetic3, + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas3), + ), + Ok(ScriptInitResolution::Executed { + event_index: init3_index, + event_word: init3_event_word, + writes: vec![ + ScriptInitWrite { + index: f_temp, + value: VarSetDefault::FloatBits(0.8_f32.to_bits()), + }, + ScriptInitWrite { + index: d_temp3, + value: VarSetDefault::Dword(2_000_000), + }, + ], + }) + ); + + let formulas4 = FormulaTable::parse( + &std::fs::read(script_dir.join("c1m4e.fml")).expect("read c1m4e.fml"), + ); + let package4 = fparkan_script::decode( + &std::fs::read(script_dir.join("c1m4e.scr")).expect("read c1m4e.scr"), + ) + .expect("decode c1m4e.scr"); + let init4_index = init_event_index(&package4); + let init4_event_word = package4.events[init4_index].event_word; + let init4 = &package4.events[init4_index].instructions; + assert!(!row_is_assigned(init4, 0, f_temp)); + assert!(row_is_assigned( + &package4 + .events + .iter() + .find(|event| event.name_raw.split(|byte| *byte == 0).next() + == Some(&b"PBM_BUILDING_INF_CAPTURE_Start"[..])) + .expect("building-infantry-capture event") + .instructions, + 0, + f_temp, + )); + assert_eq!(formulas4.rows[0].expression_raw, b" fTemp + 0.00001"); + let mut synthetic4 = package4.clone(); + synthetic4.events[init4_index].instructions = + vec![formula_action(f_temp, 0), formula_action(f_temp, 0)]; + assert_eq!( + declarations.resolve_init( + &synthetic4, + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas4), + ), + Ok(ScriptInitResolution::Executed { + event_index: init4_index, + event_word: init4_event_word, + writes: vec![ + ScriptInitWrite { + index: f_temp, + value: VarSetDefault::FloatBits(0.00001_f32.to_bits()), + }, + ScriptInitWrite { + index: f_temp, + value: VarSetDefault::FloatBits((0.00001_f32 + 0.00001_f32).to_bits()), + }, + ], + }) + ); + } + + #[test] fn script_varset_prefers_bundle_adjacent_file_then_uses_shared_fallback() { let mut personal_files = MemoryVfs::default(); personal_files.insert( @@ -13558,6 +15111,8 @@ mod tests { #[test] fn handler_nineteen_preview_keeps_unsupported_package_without_partial_writes() { + let formula_table = + FormulaTable::parse(b"FUNCTION(add, x + 2, )\nFUNCTION(unsupported, x * 2, )\n"); let varset = MissionScriptVarSet { path: FALLBACK_SCRIPT_VARSET_PATH.to_string(), used_fallback: true, @@ -13578,7 +15133,11 @@ mod tests { references: vec![0, 1, 2], }, fparkan_script::ScriptInstruction { - header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0], + header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, 0], + references: Vec::new(), + }, + fparkan_script::ScriptInstruction { + header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, 1], references: Vec::new(), }, ], @@ -13591,7 +15150,7 @@ mod tests { base_path: "MISSIONS/SCRIPTS/default".to_string(), script_path: "MISSIONS/SCRIPTS/default.scr".to_string(), package: package.clone(), - formula_table: None, + formula_table: Some(formula_table.clone()), init_execution: MissionScriptInitExecution::Missing, behavior_warm_start: None, }; @@ -13609,9 +15168,10 @@ mod tests { preview_bundles[0].init_execution, MissionScriptInitExecution::Unsupported { event_index: 0, - instruction_index: 1, - error: ScriptInitResolveError::UnsupportedSelector { - found: ScriptDispatchSelector::Handler(18), + instruction_index: 2, + error: ScriptInitResolveError::UnsupportedFormulaExpression { + index: 1, + expression: b" x * 2".to_vec(), }, } ); @@ -13640,12 +15200,13 @@ mod tests { Err(EngineError::ScriptInitUnsupported { clan_index: 0, event_index: 0, - instruction_index: 1, - source: ScriptInitResolveError::UnsupportedSelector { - found: ScriptDispatchSelector::Handler(18), + instruction_index: 2, + source: ScriptInitResolveError::UnsupportedFormulaExpression { + index: 1, + expression, }, .. - }) + }) if expression == b" x * 2" )); let mut malformed_package = package.clone(); @@ -14276,6 +15837,144 @@ mod tests { } #[test] + #[ignore = "requires licensed AutoDemo corpus"] + fn licensed_autodemo_queries_selector_6_for_all_effective_factory2_pairs() { + let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new( + licensed_root("IS"), + )))) + .expect("engine"); + load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".into(), + mode: MissionMode::AutoDemo, + }, + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .unwrap_or_else(|error| { + panic!("full AutoDemo load failed before selector-6 queries: {error:?}") + }); + + let expected_vectors = [ + ("bb_m_01", 0x43f9_8000), + ("bm_m_01", 0x4422_8000), + ("bl_l_01", 0x436a_0000), + ("br_b_01", 0x44c8_0000), + ("bf_b_01", 0x443b_8000), + ("bl_m_01", 0x443b_4000), + ("bm_l_01", 0x4334_0000), + ]; + let tables = loaded_mission_control_device_tables(&engine).expect("Control tables"); + let mut observed = Vec::new(); + for table in tables { + let devices = match &table.status { + MissionControlDeviceTableStatus::Available(devices) => devices, + MissionControlDeviceTableStatus::Unavailable(reason) => { + panic!( + "Control table for object {:?} is unavailable before selector-6 census: {reason:?}", + table.original_id + ); + } + }; + for (device_index, device) in devices.iter().enumerate() { + if device.descriptor.tag != 2 || device.factory_tag != 2 { + continue; + } + let pair = factory2_pair_from_record(&device.descriptor); + let result = query_loaded_mission_control_device_selector( + &engine, + table.handle, + device_index, + 6, + ); + observed.push((table.original_id, device_index, pair, result)); + } + } + + let mut observed_pairs = Vec::new(); + let mut mismatches = Vec::new(); + for (original_id, device_index, pair, result) in &observed { + let pair_label = pair + .as_ref() + .map(|pair| format!("{}:{}", pair.archive, pair.name)) + .unwrap_or_else(|| "<unparsed>".to_string()); + match result { + Ok(Some(value)) => println!( + "selector6 object={original_id:?} device={device_index} pair={pair_label} result=Some({:08x})", + value.to_bits() + ), + Ok(None) => println!( + "selector6 object={original_id:?} device={device_index} pair={pair_label} result=None" + ), + Err(error) => println!( + "selector6 object={original_id:?} device={device_index} pair={pair_label} error={error:?}" + ), + } + + let Some(pair) = pair else { + mismatches.push(format!( + "object {original_id:?} device {device_index}: malformed factory-2 pair" + )); + continue; + }; + if !pair.archive.eq_ignore_ascii_case("objects.rlb") { + mismatches.push(format!( + "object {original_id:?} device {device_index}: unexpected archive {}", + pair.archive + )); + continue; + } + let name = pair.name.to_ascii_lowercase(); + if !observed_pairs.contains(&name) { + observed_pairs.push(name.clone()); + } + let expected = expected_vectors + .iter() + .find(|(expected_name, _)| name.as_str() == *expected_name) + .map(|(_, bits)| *bits); + match (result, expected) { + (Ok(Some(value)), Some(expected_bits)) if value.to_bits() == expected_bits => {} + (Ok(Some(value)), Some(expected_bits)) => mismatches.push(format!( + "object {original_id:?} device {device_index} pair {name}: expected {expected_bits:08x}, got {:08x}", + value.to_bits() + )), + (Ok(None), Some(_)) => mismatches.push(format!( + "object {original_id:?} device {device_index} pair {name}: native selector returned None" + )), + (Err(error), Some(_)) => mismatches.push(format!( + "object {original_id:?} device {device_index} pair {name}: {error:?}" + )), + (_, None) => mismatches.push(format!( + "object {original_id:?} device {device_index}: pair {name} is not in the frozen native vectors" + )), + } + } + + observed_pairs.sort(); + let mut expected_pairs = expected_vectors + .iter() + .map(|(name, _)| (*name).to_string()) + .collect::<Vec<_>>(); + expected_pairs.sort(); + println!( + "selector6 census: effective_devices={}, distinct_pairs={}, pairs={observed_pairs:?}", + observed.len(), + observed_pairs.len() + ); + assert_eq!( + observed_pairs, expected_pairs, + "actual effective AutoDemo factory-2 pair census differs from the frozen vector set" + ); + assert!( + mismatches.is_empty(), + "selector-6 mismatches: {mismatches:#?}" + ); + } + + #[test] #[ignore = "requires licensed corpus"] fn headless_step_runs_resource_schedule_and_rolls_back_both_error_paths() { let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new( @@ -15789,16 +17488,31 @@ mod tests { .iter() .position(|event| event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])) .expect("Init event"); - assert_eq!( - package.events[init_index].instructions[0].header_words[0], - 19 + let init = &mut package.events[init_index]; + let handler = init + .instructions + .first() + .expect("original Handler(19) instruction"); + assert_eq!(handler.header_words[0], 19); + assert_eq!(handler.references.as_slice(), &[224, 225, 226]); + let original_instruction_count = init.instructions.len(); + init.instructions.push(ScriptInstruction { + header_words: [u32::MAX, 164, u32::MAX, u32::MAX, 6, 0, 0], + references: Vec::new(), + }); + init.instructions.push(ScriptInstruction { + header_words: [u32::MAX, 158, u32::MAX, u32::MAX, 6, 0, 1], + references: Vec::new(), + }); + let replacement_script = Arc::<[u8]>::from(encode_script_package(&package)); + let replacement_formula = Arc::<[u8]>::from( + &b"FUNCTION(add, ClanBaseX + 0.8, )\nFUNCTION(unsupported, fT * 2, )\n"[..], ); - package.events[init_index].instructions[0].header_words[0] = 18; - let replacement = Arc::<[u8]>::from(encode_script_package(&package)); let vfs = Arc::new(SwappingScriptVfs { inner: DirectoryVfs::new(&root), - replacement, + replacement_script, + replacement_formula, enabled: std::sync::atomic::AtomicBool::new(false), }); let services_vfs: Arc<dyn Vfs> = vfs.clone(); @@ -15843,11 +17557,13 @@ mod tests { assert!(matches!( error, EngineError::ScriptInitUnsupported { - source: ScriptInitResolveError::UnsupportedSelector { - found: ScriptDispatchSelector::Handler(18), + instruction_index, + source: ScriptInitResolveError::UnsupportedFormulaExpression { + index: 1, + expression, }, .. - } + } if instruction_index == original_instruction_count + 1 && expression == b" fT * 2" )); assert_eq!(loaded_mission(&engine), Some(&previous_loaded)); assert_eq!( @@ -16731,24 +18447,37 @@ mod tests { struct SwappingScriptVfs { inner: DirectoryVfs, - replacement: Arc<[u8]>, + replacement_script: Arc<[u8]>, + replacement_formula: Arc<[u8]>, enabled: std::sync::atomic::AtomicBool, } impl SwappingScriptVfs { - fn use_replacement(&self, path: &NormalizedPath) -> bool { - self.enabled.load(std::sync::atomic::Ordering::SeqCst) - && path - .as_str() - .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.scr") + fn replacement(&self, path: &NormalizedPath) -> Option<&Arc<[u8]>> { + if !self.enabled.load(std::sync::atomic::Ordering::SeqCst) { + return None; + } + if path + .as_str() + .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.scr") + { + Some(&self.replacement_script) + } else if path + .as_str() + .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.fml") + { + Some(&self.replacement_formula) + } else { + None + } } } impl Vfs for SwappingScriptVfs { fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> { - if self.use_replacement(path) { + if let Some(replacement) = self.replacement(path) { return Ok(VfsMetadata { - len: self.replacement.len() as u64, + len: replacement.len() as u64, fingerprint: [0x18; 32], }); } @@ -16756,8 +18485,8 @@ mod tests { } fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> { - if self.use_replacement(path) { - return Ok(self.replacement.clone()); + if let Some(replacement) = self.replacement(path) { + return Ok(replacement.clone()); } self.inner.read(path) } diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs index 7959ce8..20821fe 100644 --- a/crates/fparkan-script/src/lib.rs +++ b/crates/fparkan-script/src/lib.rs @@ -3,7 +3,7 @@ //! //! This module preserves the layout proven by the GOG `ai.dll` reader. It //! deliberately does not implement a general bytecode VM. It contains a -//! narrow resolver for confirmed `Init` handlers and numeric FML literals. +//! narrow resolver for confirmed `Init` handlers and bounded FML expressions. use fparkan_binary::{checked_allocation_len, Cursor, DecodeError, Limits}; use std::sync::Arc; @@ -214,7 +214,7 @@ pub enum ScriptInitResolveError { /// Number of preserved FML rows. rows: usize, }, - /// The referenced FML row is not one numeric token. + /// The referenced FML row is outside the supported atom-expression subset. UnsupportedFormulaExpression { /// Zero-based FML row index. index: u32, @@ -259,7 +259,7 @@ impl std::fmt::Display for ScriptInitResolveError { ), Self::UnsupportedFormulaExpression { index, expression } => write!( formatter, - "formula {index} expression {:?} is not one numeric literal", + "formula {index} expression {:?} is outside the supported atom and binary +/− subset", String::from_utf8_lossy(expression) ), Self::DwordConversionOutOfRange { index, value_bits } => write!( @@ -736,6 +736,7 @@ impl VarSet { /// sentinel selector is accepted only for the proven formula assignment /// with control and copy-source words both equal to `0xffffffff`; the /// referenced row is evaluated only when that instruction is reached. + /// Formula atoms read defaults and earlier successful writes in this event. /// /// # Errors /// @@ -753,10 +754,24 @@ impl VarSet { return Ok(ScriptInitResolution::Missing); }; + let mut values = self + .declarations + .iter() + .map(|declaration| declaration.default_value) + .collect::<Vec<_>>(); let mut writes = Vec::new(); for (instruction_index, instruction) in event.instructions.iter().enumerate() { - match self.resolve_init_instruction(instruction, input, formulas)? { + match self.resolve_init_instruction(instruction, input, formulas, &values)? { InitInstructionResolution::Executed(instruction_writes) => { + for write in &instruction_writes { + let Some(value) = values.get_mut(write.index as usize) else { + return Err(ScriptInitResolveError::VarSetIndexOutOfBounds { + index: write.index, + declarations: self.declarations.len(), + }); + }; + *value = write.value; + } writes.extend(instruction_writes); } InitInstructionResolution::Unsupported(error) => { @@ -781,6 +796,7 @@ impl VarSet { instruction: &ScriptInstruction, input: Handler19InitInput, formulas: Option<&FormulaTable>, + values: &[VarSetDefault], ) -> Result<InitInstructionResolution, ScriptInitResolveError> { match instruction.dispatch_selector() { ScriptDispatchSelector::Handler(19) => { @@ -842,22 +858,23 @@ impl VarSet { )); }; let formula_index = instruction.header_words[6]; - let value = match formulas.evaluate_numeric_literal(formula_index) { - Ok(value) => value, - Err(FormulaLiteralError::IndexOutOfBounds { index, rows }) => { - return Err(ScriptInitResolveError::FormulaIndexOutOfBounds { - index, - rows, - }); - } - Err(FormulaLiteralError::UnsupportedExpression { index, expression }) => { - return Ok(InitInstructionResolution::Unsupported( - ScriptInitResolveError::UnsupportedFormulaExpression { - index, - expression, - }, - )); - } + let Some(formula) = formulas.rows.get(formula_index as usize) else { + return Err(ScriptInitResolveError::FormulaIndexOutOfBounds { + index: formula_index, + rows: formulas.rows.len(), + }); + }; + let Some(value) = parse_init_formula_expression( + &formula.expression_raw, + &self.declarations, + values, + ) else { + return Ok(InitInstructionResolution::Unsupported( + ScriptInitResolveError::UnsupportedFormulaExpression { + index: formula_index, + expression: formula.expression_raw.clone(), + }, + )); }; let target_index = instruction.header_words[1]; let Some(target) = self.declarations.get(target_index as usize) else { @@ -866,6 +883,20 @@ impl VarSet { declarations: self.declarations.len(), }); }; + let Some(current_target) = values.get(target_index as usize) else { + return Err(ScriptInitResolveError::VarSetIndexOutOfBounds { + index: target_index, + declarations: values.len(), + }); + }; + if !varset_value_matches_type(target.type_name, *current_target) { + return Ok(InitInstructionResolution::Unsupported( + ScriptInitResolveError::UnsupportedFormulaExpression { + index: formula_index, + expression: formula.expression_raw.clone(), + }, + )); + } let value = match target.type_name { VarSetType::Float => VarSetDefault::FloatBits(value.to_bits()), VarSetType::Dword => { @@ -1357,6 +1388,113 @@ fn native_decimal_prefix_len(token: &[u8]) -> Option<usize> { Some(index) } +fn parse_init_formula_expression( + expression: &[u8], + declarations: &[VarSetDeclaration], + values: &[VarSetDefault], +) -> Option<f32> { + let (left, left_end) = parse_init_formula_atom(expression, 0, declarations, values)?; + let operator_index = skip_native_formula_space(expression, left_end); + let Some(&operator @ (b'+' | b'-')) = expression.get(operator_index) else { + return expression[left_end..] + .iter() + .all(|byte| *byte == 0 || *byte <= 32) + .then_some(left); + }; + let (right, right_end) = + parse_init_formula_atom(expression, operator_index + 1, declarations, values)?; + if !expression[right_end..] + .iter() + .all(|byte| *byte == 0 || *byte <= 32) + { + return None; + } + Some(match operator { + b'+' => right + left, + b'-' => left - right, + _ => unreachable!(), + }) +} + +fn parse_init_formula_atom( + expression: &[u8], + start: usize, + declarations: &[VarSetDeclaration], + values: &[VarSetDefault], +) -> Option<(f32, usize)> { + let start = skip_native_formula_space(expression, start); + let remaining = expression.get(start..)?; + let first = *remaining.first()?; + let token_len = if first.is_ascii_digit() { + native_numeric_token_len(remaining)? + } else { + native_variable_token_len(remaining)? + }; + let token = &remaining[..token_len]; + if is_native_operator_atom(token) { + return None; + } + + let value = if let Some(index) = declarations + .iter() + .position(|declaration| declaration.name.as_bytes() == token) + { + let declaration = &declarations[index]; + let value = *values.get(index)?; + varset_value_as_f32(declaration.type_name, value)? + } else { + parse_native_numeric_literal(token)? + }; + Some((value, start + token_len)) +} + +fn native_variable_token_len(token: &[u8]) -> Option<usize> { + let first = *token.first()?; + if first == b'^' || !is_native_variable_byte(first) { + return None; + } + Some( + token + .iter() + .take_while(|byte| is_native_variable_byte(**byte)) + .count(), + ) +} + +fn is_native_variable_byte(byte: u8) -> bool { + byte.is_ascii() && ((byte > b'@' && byte < b'{') || byte.is_ascii_digit() || byte == b'_') +} + +fn is_native_operator_atom(token: &[u8]) -> bool { + matches!( + token, + b"+" | b"-" | b"*" | b"/" | b"^" | b"&" | b"|" | b"!" | b"A" | b"B" | b"N" | b"S" + ) +} + +fn skip_native_formula_space(expression: &[u8], start: usize) -> usize { + expression[start..] + .iter() + .position(|byte| *byte == 0 || *byte > 32) + .map_or(expression.len(), |offset| start + offset) +} + +fn varset_value_as_f32(type_name: VarSetType, value: VarSetDefault) -> Option<f32> { + match (type_name, value) { + (VarSetType::Float, VarSetDefault::FloatBits(bits)) => Some(f32::from_bits(bits)), + (VarSetType::Dword, VarSetDefault::Dword(value)) => Some(value as f32), + _ => None, + } +} + +fn varset_value_matches_type(type_name: VarSetType, value: VarSetDefault) -> bool { + matches!( + (type_name, value), + (VarSetType::Float, VarSetDefault::FloatBits(_)) + | (VarSetType::Dword, VarSetDefault::Dword(_)) + ) +} + fn formula_dword_value(value: f32) -> Option<u32> { const SIGNED_QWORD_LIMIT: f64 = 9_223_372_036_854_775_808.0; let value = f64::from(value); @@ -1822,6 +1960,49 @@ mod tests { }; use fparkan_binary::{DecodeError, Limits}; + fn init_package(instructions: Vec<ScriptInstruction>) -> ScriptPackage { + ScriptPackage { + opcode_handler_count: 73, + events: vec![ScriptEvent { + name_len: 4, + name_raw: b"Init\0".to_vec(), + event_word: 0, + instructions, + }], + trailing_bytes: Vec::new(), + raw: std::sync::Arc::from(&b""[..]), + } + } + + fn init_formula_assignment(target: u32, formula: u32) -> ScriptInstruction { + ScriptInstruction { + header_words: [u32::MAX, target, u32::MAX, u32::MAX, 6, 0, formula], + references: Vec::new(), + } + } + + fn init_handler19() -> ScriptInstruction { + ScriptInstruction { + header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX], + references: vec![0, 1, 2], + } + } + + fn init_input() -> Handler19InitInput { + Handler19InitInput { + first_x87_word: 10, + second_x87_word: 20, + third_word: 30, + } + } + + fn one_formula(expression: &[u8]) -> FormulaTable { + let mut source = b"FUNCTION(test, ".to_vec(); + source.extend_from_slice(expression); + source.extend_from_slice(b", )\n"); + FormulaTable::parse(&source) + } + fn handler_two_input( scalar_1: f32, scalar_4: f32, @@ -2625,12 +2806,265 @@ STRING( 8, ignored, ignored, ignored)\r\n"; } #[test] + fn script_init_formula_variables_read_prior_writes_and_self() { + let varset = parse_varset( + b"VAR(DWORD, source, 0)\nVAR(DWORD, y, 0)\nVAR(DWORD, id, 0)\nVAR(float, target, 0)\nVAR(DWORD, integer, 0)\n", + ) + .expect("numeric declarations"); + let formulas = FormulaTable::parse( + b"FUNCTION(literal, 7.0, )\nFUNCTION(add, source + 2.0, )\nFUNCTION(subtract, target - source, )\nFUNCTION(self, target + 1.0, )\nFUNCTION(integer, 10.75 + 0.5, )\nFUNCTION(exponent, target + 1e-2, )\n", + ); + let package = init_package(vec![ + init_handler19(), + init_formula_assignment(3, 0), + init_formula_assignment(3, 1), + init_formula_assignment(3, 2), + init_formula_assignment(3, 3), + init_formula_assignment(4, 4), + init_formula_assignment(3, 5), + ]); + + assert_eq!( + varset.resolve_init(&package, init_input(), Some(&formulas),), + Ok(ScriptInitResolution::Executed { + event_index: 0, + event_word: 0, + writes: vec![ + ScriptInitWrite { + index: 0, + value: VarSetDefault::Dword(10), + }, + ScriptInitWrite { + index: 1, + value: VarSetDefault::Dword(20), + }, + ScriptInitWrite { + index: 2, + value: VarSetDefault::Dword(30), + }, + ScriptInitWrite { + index: 3, + value: VarSetDefault::FloatBits(7.0_f32.to_bits()), + }, + ScriptInitWrite { + index: 3, + value: VarSetDefault::FloatBits(12.0_f32.to_bits()), + }, + ScriptInitWrite { + index: 3, + value: VarSetDefault::FloatBits(2.0_f32.to_bits()), + }, + ScriptInitWrite { + index: 3, + value: VarSetDefault::FloatBits(3.0_f32.to_bits()), + }, + ScriptInitWrite { + index: 4, + value: VarSetDefault::Dword(11), + }, + ScriptInitWrite { + index: 3, + value: VarSetDefault::FloatBits(3.01_f32.to_bits()), + }, + ], + }) + ); + } + + #[test] + fn script_init_formula_atoms_use_native_token_and_name_rules() { + let varset = parse_varset( + b"VAR(float, dup, 1)\nVAR(float, dup, 2)\nVAR(float, Name, 3)\nVAR(float, name, 4)\nVAR(float, 1e-2, 41)\nVAR(float, A, 8)\nVAR(float, A1, 9)\nVAR(DWORD, high, 4294967295)\nVAR(float, large, 16777216)\nVAR(float, target, 0)\nVAR(DWORD, integer, 0)\n", + ) + .expect("numeric declarations"); + let formulas = FormulaTable::parse( + b"FUNCTION(duplicate, dup + 0, )\nFUNCTION(upper, Name + 0, )\nFUNCTION(lower, name + 0, )\nFUNCTION(numeric_name, 1e-2 + 0, )\nFUNCTION(long_operator_prefix, A1 + 0, )\nFUNCTION(unsigned_dword, high + 0, )\nFUNCTION(negative_exponent, 5e-2 + 2e-2, )\nFUNCTION(positive_exponent, 1e+2 + 2e-2, )\nFUNCTION(binary32_rounding, 16777216 + 1, )\nFUNCTION(cancellation, target - 16777215, )\nFUNCTION(dword_target, 10.75 + 0.5, )\nFUNCTION(single_variable, large, )\nFUNCTION(negative_dword_wrap, 2 - 3, )\nFUNCTION(unsigned_dword_wrap, high + 0, )\n", + ); + let package = init_package( + (0..14) + .map(|formula| { + init_formula_assignment( + if matches!(formula, 10 | 12 | 13) { + 10 + } else { + 9 + }, + formula, + ) + }) + .collect(), + ); + + let resolved = varset + .resolve_init( + &package, + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas), + ) + .expect("resolve Init") + .clone(); + assert_eq!( + resolved, + ScriptInitResolution::Executed { + event_index: 0, + event_word: 0, + writes: vec![ + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(1.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(3.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(4.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(41.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(9.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(4_294_967_296.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits((0.05_f32 + 0.02_f32).to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits((100.0_f32 + 0.02_f32).to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(16_777_216.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(1.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 10, + value: VarSetDefault::Dword(11) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(16_777_216.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 10, + value: VarSetDefault::Dword(u32::MAX) + }, + ScriptInitWrite { + index: 10, + value: VarSetDefault::Dword(0) + }, + ], + } + ); + assert!(formulas.evaluate_numeric_literal(0).is_err()); + } + + #[test] + fn script_init_rejects_unsupported_formula_variable_forms() { + let varset = parse_varset( + b"VAR(float, target, 1)\nVAR(float, A, 2)\nVAR(float, A1, 3)\nVAR(float, ^name, 4)\n", + ) + .expect("numeric declarations"); + for expression in [ + &b"target * 2"[..], + b"-1", + b"target + 1 + 2", + b"missing + 1", + b"A + 1", + b"^name + 1", + b"name\xc3\xa9 + 1", + ] { + let formulas = one_formula(expression); + let package = init_package(vec![init_formula_assignment(0, 0)]); + assert!( + matches!( + varset.resolve_init( + &package, + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas), + ), + Ok(ScriptInitResolution::Unsupported { + error: ScriptInitResolveError::UnsupportedFormulaExpression { + index: 0, + .. + }, + .. + }) + ), + "accepted unsupported formula {:?}", + String::from_utf8_lossy(expression) + ); + } + + let mut inconsistent = varset; + inconsistent.declarations[0].default_value = VarSetDefault::Dword(1); + let formulas = one_formula(b"target + 1"); + let package = init_package(vec![init_formula_assignment(0, 0)]); + assert!(matches!( + inconsistent.resolve_init( + &package, + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas), + ), + Ok(ScriptInitResolution::Unsupported { + error: ScriptInitResolveError::UnsupportedFormulaExpression { index: 0, .. }, + .. + }) + )); + + let mut inconsistent_target = + parse_varset(b"VAR(float, target, 1)\n").expect("numeric declaration"); + inconsistent_target.declarations[0].default_value = VarSetDefault::Dword(1); + let formulas = one_formula(b"2.0 + 1"); + assert!(matches!( + inconsistent_target.resolve_init( + &init_package(vec![init_formula_assignment(0, 0)]), + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas), + ), + Ok(ScriptInitResolution::Unsupported { + error: ScriptInitResolveError::UnsupportedFormulaExpression { index: 0, .. }, + .. + }) + )); + } + + #[test] fn script_init_keeps_unsupported_formula_expressions_lazy_and_transactional() { - let varset = parse_varset(b"VAR(DWORD, integer, 0)\n").expect("numeric declaration"); + let varset = parse_varset(b"VAR(DWORD, target, 0)\nVAR(DWORD, integer, 0)\n") + .expect("numeric declaration"); let formulas = - FormulaTable::parse(b"FUNCTION(unsupported, target + 2, )\nFUNCTION(literal, 7.5, )\n"); + FormulaTable::parse(b"FUNCTION(unsupported, target * 2, )\nFUNCTION(literal, 7.5, )\n"); let action = |formula_index| ScriptInstruction { - header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, formula_index], + header_words: [u32::MAX, 1, u32::MAX, u32::MAX, 6, 0, formula_index], references: Vec::new(), }; let package = |formula_index| ScriptPackage { @@ -2656,7 +3090,7 @@ STRING( 8, ignored, ignored, ignored)\r\n"; event_index: 0, event_word: 0, writes: vec![ScriptInitWrite { - index: 0, + index: 1, value: VarSetDefault::Dword(7), }], }) @@ -2668,7 +3102,7 @@ STRING( 8, ignored, ignored, ignored)\r\n"; instruction_index: 0, error: ScriptInitResolveError::UnsupportedFormulaExpression { index: 0, - expression: b" target + 2".to_vec(), + expression: b" target * 2".to_vec(), }, }) ); @@ -2682,7 +3116,7 @@ STRING( 8, ignored, ignored, ignored)\r\n"; instruction_index: 1, error: ScriptInitResolveError::UnsupportedFormulaExpression { index: 0, - expression: b" target + 2".to_vec(), + expression: b" target * 2".to_vec(), }, }) ); diff --git a/crates/fparkan-terrain-format/src/lib.rs b/crates/fparkan-terrain-format/src/lib.rs index e4028f9..63d21a1 100644 --- a/crates/fparkan-terrain-format/src/lib.rs +++ b/crates/fparkan-terrain-format/src/lib.rs @@ -58,7 +58,7 @@ pub struct TerrainMaterialLayers { pub overlay_selector: Option<u8>, } -/// One `World3D` material-manager lookup key. +/// One `World3D` material-manager phase query. /// /// The original manager selects a WEAR table in the upper 16 bits and its /// positional material row in the lower 16 bits. @@ -68,6 +68,9 @@ pub struct TerrainMaterialSelection { pub table_index: u16, /// Positional material row in that WEAR table. pub material_index: u16, + /// Animation block passed separately to GetMaterialPhase. The multitexture + /// path uses block 1 for Land2, with the material sampler's block-0 fallback. + pub animation_block_index: usize, } impl TerrainMaterialSelection { @@ -99,6 +102,7 @@ impl TerrainMaterialLayers { TerrainMaterialSelection { table_index: 0, material_index: u16::from(self.base_selector), + animation_block_index: 0, } } @@ -109,27 +113,30 @@ impl TerrainMaterialLayers { .map(|material_index| TerrainMaterialSelection { table_index: 0, material_index: u16::from(material_index), + animation_block_index: 0, }) } - /// Returns the detail selection corresponding to the base row in - /// `Land2.wea`. + /// Returns the multitexture detail selection corresponding to the base + /// row in `Land2.wea`. #[must_use] pub fn detail_base_selection(self) -> TerrainMaterialSelection { TerrainMaterialSelection { table_index: 1, material_index: u16::from(self.base_selector), + animation_block_index: 1, } } - /// Returns the optional detail selection corresponding to the overlay row - /// in `Land2.wea`. + /// Returns the optional multitexture detail selection corresponding to + /// the overlay row in `Land2.wea`. #[must_use] pub fn detail_overlay_selection(self) -> Option<TerrainMaterialSelection> { self.overlay_selector .map(|material_index| TerrainMaterialSelection { table_index: 1, material_index: u16::from(material_index), + animation_block_index: 1, }) } } @@ -219,6 +226,8 @@ pub enum TerrainLodError { DuplicateFaceAssignment { face: usize }, /// A source face is absent from every supported terrain slot. MissingFaceAssignment { face: usize }, + /// Slot bounds do not form the regular XY cell grid used by terrain rendering. + InvalidCellGrid, /// A checked index calculation overflowed. IntegerOverflow, } @@ -269,6 +278,7 @@ impl std::fmt::Display for TerrainLodError { Self::MissingFaceAssignment { face } => { write!(f, "terrain face {face} is not assigned to a supported slot") } + Self::InvalidCellGrid => write!(f, "terrain slots do not form a regular XY cell grid"), Self::IntegerOverflow => write!(f, "terrain slot index overflow"), } } @@ -1948,6 +1958,7 @@ mod tests { TerrainMaterialSelection { table_index: 0, material_index: 2, + animation_block_index: 0, } ); assert_eq!( @@ -1968,11 +1979,26 @@ mod tests { assert_eq!( document.faces[0] .material_layers() + .detail_base_selection() + .animation_block_index, + 1 + ); + assert_eq!( + document.faces[0] + .material_layers() .detail_overlay_selection() .expect("detail overlay") .phase_key(), 0x0001_0001 ); + assert_eq!( + document.faces[0] + .material_layers() + .detail_overlay_selection() + .unwrap() + .animation_block_index, + 1 + ); raw_face[4..6].copy_from_slice(&0xff03_u16.to_le_bytes()); let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres"); @@ -1989,6 +2015,7 @@ mod tests { TerrainMaterialSelection { table_index: 0, material_index: 3, + animation_block_index: 0, } ); assert_eq!( diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs index 5e17a23..64e6814 100644 --- a/crates/fparkan-terrain/src/lib.rs +++ b/crates/fparkan-terrain/src/lib.rs @@ -6,6 +6,9 @@ use std::collections::VecDeque; mod contour; mod foundation; +mod lod; + +pub use lod::TerrainLodPlan; use foundation::basement_surface_mask; pub use foundation::{ @@ -1633,6 +1636,83 @@ mod tests { } #[test] + fn lod_grid_uses_authored_face_ownership_and_pins_changed_foundation_cells() { + use fparkan_terrain_format::{TerrainStream, TerrainStreamAttributes}; + let mut mesh = synthetic_land_mesh(); + mesh.faces.extend(mesh.faces.clone()); + assign_terrain_lod_slots(&mut mesh, &[[0, 1]], &[[0, 2], [2, 2]]); + mesh.slots.header_raw = vec![0; 0x8c]; + mesh.streams.push(TerrainStream { + type_id: 1, + attributes: TerrainStreamAttributes { + attr1: 1, + attr2: 1, + attr3: 0, + }, + size: 38, + }); + for slot in &mut mesh.slots.slots_raw { + slot[20..24].copy_from_slice(&1.0_f32.to_le_bytes()); + slot[24..28].copy_from_slice(&1.0_f32.to_le_bytes()); + } + let mut foundation = BasementFoundation { + placements: Vec::new(), + fragments: (0..2) + .map(|face| BasementFoundationFragment { + source_face: Some(face), + surface_mask: mesh.faces[face].flags, + positions: mesh.faces[face] + .vertices + .map(|v| mesh.positions[usize::from(v)]), + }) + .collect(), + }; + foundation.fragments[0].positions.rotate_left(1); + let plan = TerrainLodPlan::new(&mesh, Some(&foundation)).unwrap(); + assert_eq!(plan.source_face_node(3), Some(0)); + assert_eq!(plan.source_face_node(4), None); + assert_eq!( + plan.selected_lod(0, [4.0, 4.0], 1.3), + Some(TerrainLod::Lod1) + ); + foundation.fragments.pop(); + let patched = TerrainLodPlan::new(&mesh, Some(&foundation)).unwrap(); + assert_eq!( + patched.selected_lod(0, [4.0, 4.0], 1.3), + Some(TerrainLod::Lod0) + ); + mesh.slots.slots_raw[0][24..28].copy_from_slice(&f32::NAN.to_le_bytes()); + assert!(matches!( + TerrainLodPlan::new(&mesh, None), + Err(TerrainLodError::InvalidCellGrid) + )); + } + + #[test] + #[ignore = "requires licensed corpus"] + fn licensed_corpus_land_meshes_build_native_lod_grids() { + let mut files = 0; + for path in files_under(&corpus_root("IS")) { + if !path + .file_name() + .and_then(|s| s.to_str()) + .is_some_and(|s| s.eq_ignore_ascii_case("Land.msh")) + { + continue; + } + let bytes = std::fs::read(&path).unwrap(); + let nres = + fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible) + .unwrap(); + let mesh = fparkan_terrain_format::decode_land_msh(&nres).unwrap(); + let plan = TerrainLodPlan::new(&mesh, None).unwrap_or_else(|e| panic!("{path:?}: {e}")); + assert!(plan.has_far_geometry(), "{path:?}: no far slots"); + files += 1; + } + assert_eq!(files, 33, "original game LOD grid count"); + } + + #[test] #[ignore = "requires licensed corpus"] fn licensed_corpus_land_meshes_build_surface_worlds() { for (corpus, expected_files, expected_faces) in [ diff --git a/crates/fparkan-terrain/src/lod.rs b/crates/fparkan-terrain/src/lod.rs new file mode 100644 index 0000000..cf5991f --- /dev/null +++ b/crates/fparkan-terrain/src/lod.rs @@ -0,0 +1,301 @@ +use crate::{BasementFoundation, TerrainLod, TerrainLodError}; +use fparkan_terrain_format::LandMeshDocument; + +/// Camera-dependent terrain detail selection, retaining LOD0 in basement cells. +#[derive(Clone, Debug)] +pub struct TerrainLodPlan { + origin: [f32; 2], + inverse_cell_size: [f32; 2], + counts: [usize; 2], + forced_lod0: Vec<bool>, + has_lod1: Vec<bool>, + face_nodes: Vec<usize>, +} + +impl TerrainLodPlan { + /// Validates the authored grid and registers foundation patches in its cells. + /// + /// # Errors + /// Returns an error for an invalid slot mapping, grid or foundation source face. + pub fn new( + mesh: &LandMeshDocument, + foundation: Option<&BasementFoundation>, + ) -> Result<Self, TerrainLodError> { + let assignments = mesh.face_lod_assignments()?; + let bounds = mesh + .nodes_raw + .chunks_exact(38) + .map(|node| { + let slot = u16::from_le_bytes([node[8], node[9]]); + let raw = mesh.slots.slots_raw.get(usize::from(slot))?; + let coordinate = + |offset: usize| f32::from_le_bytes(raw[offset..offset + 4].try_into().unwrap()); + Some(( + [coordinate(8), coordinate(12)], + [coordinate(20), coordinate(24)], + )) + }) + .collect::<Option<Vec<_>>>() + .ok_or(TerrainLodError::InvalidCellGrid)?; + let &(first_min, first_max) = bounds.first().ok_or(TerrainLodError::InvalidCellGrid)?; + let header = &mesh.slots.header_raw; + let coordinate = |offset: usize| { + header + .get(offset..offset + 4) + .map(|bytes| f32::from_le_bytes(bytes.try_into().unwrap())) + }; + let origin = [ + coordinate(0).ok_or(TerrainLodError::InvalidCellGrid)?, + coordinate(4).ok_or(TerrainLodError::InvalidCellGrid)?, + ]; + let size = [first_max[0] - first_min[0], first_max[1] - first_min[1]]; + if !origin.iter().all(|value| value.is_finite()) + || !size.iter().all(|value| value.is_finite() && *value > 0.0) + { + return Err(TerrainLodError::InvalidCellGrid); + } + let count_x = mesh + .streams + .iter() + .find(|s| s.type_id == 1) + .map(|s| s.attributes.attr2 as usize) + .ok_or(TerrainLodError::InvalidCellGrid)?; + if count_x == 0 || !bounds.len().is_multiple_of(count_x) { + return Err(TerrainLodError::InvalidCellGrid); + } + let count_y = bounds.len() / count_x; + let counts = [count_x, count_y]; + for (node, &(min, max)) in bounds.iter().enumerate() { + let cell = [node / count_y, node % count_y]; + for axis in 0..2 { + let expected = origin[axis] + cell[axis] as f32 * size[axis]; + let tolerance = size[axis] * 1.0e-5; + if !min[axis].is_finite() + || !max[axis].is_finite() + || (min[axis] - expected).abs() > tolerance + || (max[axis] - (expected + size[axis])).abs() > tolerance + { + return Err(TerrainLodError::InvalidCellGrid); + } + } + } + let has_lod1 = mesh + .nodes_raw + .chunks_exact(38) + .map(|node| { + let slot = u16::from_le_bytes([node[10], node[11]]); + slot != 0xff + && slot != u16::MAX + && mesh + .slots + .slots_raw + .get(usize::from(slot)) + .is_some_and(|raw| u16::from_le_bytes([raw[2], raw[3]]) != 0) + }) + .collect(); + let mut result = Self { + origin, + inverse_cell_size: size.map(f32::recip), + counts, + forced_lod0: vec![false; bounds.len()], + has_lod1, + face_nodes: assignments.iter().map(|a| a.node_index).collect(), + }; + if let Some(foundation) = foundation { + let mut fragments = vec![0usize; mesh.faces.len()]; + let mut unchanged = vec![false; mesh.faces.len()]; + for fragment in &foundation.fragments { + if let Some(face_index) = fragment.source_face { + let face = mesh + .faces + .get(face_index) + .ok_or(TerrainLodError::MissingFaceAssignment { face: face_index })?; + fragments[face_index] += 1; + let original = face.vertices.map(|v| mesh.positions[usize::from(v)]); + unchanged[face_index] = fragment.positions.iter().all(|p| original.contains(p)); + } + } + for assignment in assignments.iter().filter(|a| a.lod == TerrainLod::Lod0) { + if fragments[assignment.face_index] != 1 || !unchanged[assignment.face_index] { + result.forced_lod0[assignment.node_index] = true; + } + } + // Native registers patch triangles against every cell, including shared edges. + for fragment in foundation.fragments.iter().filter(|fragment| { + fragment + .source_face + .is_none_or(|face| fragments[face] != 1 || !unchanged[face]) + }) { + for node in 0..bounds.len() { + let cell = [node / count_y, node % count_y]; + let center = std::array::from_fn(|axis| { + (cell[axis] as f32 + 0.5) * size[axis] + origin[axis] + }); + if triangle_intersects_cell( + fragment.positions, + center, + result.inverse_cell_size, + ) { + result.forced_lod0[node] = true; + } + } + } + } + Ok(result) + } + + /// Returns the cell named by the authored slot that owns a source face. + #[must_use] + pub fn source_face_node(&self, face: usize) -> Option<usize> { + self.face_nodes.get(face).copied() + } + + fn camera_cell(&self, point: [f32; 2]) -> Option<[i32; 2]> { + let cell: [f32; 2] = std::array::from_fn(|axis| { + ((point[axis] - self.origin[axis]) * self.inverse_cell_size[axis]).floor() + }); + cell.iter() + .all(|v| v.is_finite() && *v >= i32::MIN as f32 && *v < i32::MAX as f32) + .then(|| cell.map(|v| v as i32)) + } + + /// Selects LOD1 at a squared cell distance of at least nine, unless forced near. + /// Returns `None` for an invalid node, camera position or FOV. + #[must_use] + pub fn selected_lod( + &self, + node: usize, + eye: [f32; 2], + horizontal_fov: f32, + ) -> Option<TerrainLod> { + let forced = *self.forced_lod0.get(node)?; + let has_far = *self.has_lod1.get(node)?; + let camera = self.camera_cell(eye)?; + if !horizontal_fov.is_finite() || horizontal_fov <= 0.0 { + return None; + } + let dx = (node / self.counts[1]) as f64 - f64::from(camera[0]); + let dy = (node % self.counts[1]) as f64 - f64::from(camera[1]); + Some( + if !forced && has_far && horizontal_fov >= 0.5 && dx * dx + dy * dy >= 9.0 { + TerrainLod::Lod1 + } else { + TerrainLod::Lod0 + }, + ) + } + + /// Whether the map contains any usable far slots. + #[must_use] + pub fn has_far_geometry(&self) -> bool { + self.has_lod1.iter().any(|&value| value) + } +} + +fn triangle_intersects_cell( + triangle: [[f32; 3]; 3], + center: [f32; 2], + inverse_size: [f32; 2], +) -> bool { + // Normalize in f32 before testing the native [-0.5, 0.5] box. + let points = triangle.map(|p| { + std::array::from_fn::<_, 2, _>(|axis| { + f64::from((p[axis] - center[axis]) * inverse_size[axis]) + }) + }); + let edges = std::array::from_fn::<_, 3, _>(|i| { + let next = points[(i + 1) % 3]; + [next[1] - points[i][1], points[i][0] - next[0]] + }); + [[1.0, 0.0], [0.0, 1.0]] + .into_iter() + .chain(edges) + .all(|axis| { + let projections = points.map(|p| p[0] * axis[0] + p[1] * axis[1]); + let radius = 0.5 * (axis[0].abs() + axis[1].abs()); + projections.iter().copied().fold(f64::INFINITY, f64::min) <= radius + && projections + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max) + >= -radius + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn patch_registration_keeps_native_normalization_and_triangle_edges() { + let size = f32::from_bits(0x42d4_15f5); + let center = [2.5 * size, 6.5 * size]; + let edge = [ + [3.0 * size, 6.0 * size, 0.0], + [3.0 * size, 7.0 * size, 0.0], + [4.0 * size, 6.0 * size, 0.0], + ]; + // The authored boundary rounds outside this cell after native normalization. + assert!(!triangle_intersects_cell(edge, center, [size.recip(); 2])); + assert!(triangle_intersects_cell( + edge, + [3.5 * size, 6.5 * size], + [size.recip(); 2] + )); + assert!(!triangle_intersects_cell( + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 2.0, 0.0]], + [1.75, 1.75], + [2.0; 2] + )); + assert!(triangle_intersects_cell( + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 2.0, 0.0]], + [1.5, 1.0], + [2.0; 2] + )); + } + + #[test] + fn native_cell_distance_boundary_zoom_and_basement_override() { + let mut plan = TerrainLodPlan { + origin: [0.0; 2], + inverse_cell_size: [1.0; 2], + counts: [8, 8], + forced_lod0: vec![false; 64], + has_lod1: vec![true; 64], + face_nodes: Vec::new(), + }; + assert_eq!( + plan.selected_lod(2 * 8 + 2, [0.9, 0.9], 1.3), + Some(TerrainLod::Lod0) + ); + assert_eq!( + plan.selected_lod(3 * 8, [0.9, 0.9], 1.3), + Some(TerrainLod::Lod1) + ); + assert_eq!( + plan.selected_lod(3 * 8, [1.0, 0.9], 1.3), + Some(TerrainLod::Lod0) + ); + assert_eq!( + plan.selected_lod(3 * 8, [0.9, 0.9], 0.49), + Some(TerrainLod::Lod0) + ); + assert_eq!( + plan.selected_lod(3 * 8, [0.9, 0.9], 0.5), + Some(TerrainLod::Lod1) + ); + plan.forced_lod0[3 * 8] = true; + assert_eq!( + plan.selected_lod(3 * 8, [0.9, 0.9], 1.3), + Some(TerrainLod::Lod0) + ); + plan.has_lod1[7 * 8] = false; + assert_eq!( + plan.selected_lod(7 * 8, [0.9, 0.9], 1.3), + Some(TerrainLod::Lod0) + ); + assert_eq!(plan.selected_lod(64, [0.0; 2], 1.3), None); + assert_eq!(plan.selected_lod(0, [f32::NAN, 0.0], 1.3), None); + assert_eq!(plan.selected_lod(0, [0.0; 2], f32::INFINITY), None); + } +} |
