diff options
Diffstat (limited to 'crates/fparkan-fx/src')
| -rw-r--r-- | crates/fparkan-fx/src/ambient_op3.rs | 218 | ||||
| -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 |
5 files changed, 607 insertions, 41 deletions
diff --git a/crates/fparkan-fx/src/ambient_op3.rs b/crates/fparkan-fx/src/ambient_op3.rs index 5bd2881..1ec2973 100644 --- a/crates/fparkan-fx/src/ambient_op3.rs +++ b/crates/fparkan-fx/src/ambient_op3.rs @@ -471,6 +471,7 @@ fn native_op3_type1_matrix( /// `view · axis` value to exactly ±1 before building the cross-product frame; /// neither input vector is normalized. The native path normalizes only /// `view × axis`, then computes `axis × U` without normalizing the result. +/// The authored axis is the quad's X column (Effect.dll RVA 0x97F6..0x983C). #[must_use] fn native_op3_type1_basis( view: [f32; 3], @@ -501,9 +502,9 @@ fn native_op3_type1_basis( } let w = cross3(axis, u); let result = [ - [u[0], w[0], axis[0], position[0]], - [u[1], w[1], axis[1], position[1]], - [u[2], w[2], axis[2], position[2]], + [axis[0], u[0], w[0], position[0]], + [axis[1], u[1], w[1], position[1]], + [axis[2], u[2], w[2], position[2]], [0.0, 0.0, 0.0, 1.0], ]; result @@ -837,19 +838,19 @@ mod tests { // cross-product frame instead of being normalized before the gate. let frame = native_op3_type1_basis([2.0, 1.0, 0.0], [1.0, 0.0, 0.0], [3.0, 4.0, 5.0]).unwrap(); - assert_eq!(frame[0], [0.0, 0.0, 1.0, 3.0]); - assert_eq!(frame[1], [0.0, 1.0, 0.0, 4.0]); - assert_eq!(frame[2], [-1.0, 0.0, 0.0, 5.0]); + assert_eq!(frame[0], [1.0, 0.0, 0.0, 3.0]); + assert_eq!(frame[1], [0.0, 0.0, 1.0, 4.0]); + assert_eq!(frame[2], [0.0, -1.0, 0.0, 5.0]); // The axis is not normalized: W and the axis column keep its scale. let axis_scaled = native_op3_type1_basis([2.0, 1.0, 0.0], [0.0, 0.0, 2.0], [0.0; 3]).unwrap(); let inv_sqrt_5 = 1.0 / 5.0_f32.sqrt(); - assert_eq!(axis_scaled[0][0], inv_sqrt_5); - assert_eq!(axis_scaled[1][0], -2.0 * inv_sqrt_5); - assert_eq!(axis_scaled[0][1], 4.0 * inv_sqrt_5); - assert_eq!(axis_scaled[1][1], 2.0 * inv_sqrt_5); - assert_eq!(axis_scaled[2][2], 2.0); + assert_eq!(axis_scaled[0][1], inv_sqrt_5); + assert_eq!(axis_scaled[1][1], -2.0 * inv_sqrt_5); + assert_eq!(axis_scaled[0][2], 4.0 * inv_sqrt_5); + assert_eq!(axis_scaled[1][2], 2.0 * inv_sqrt_5); + assert_eq!(axis_scaled[2][0], 2.0); assert_eq!( native_op3_type1_basis([f32::NAN, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0; 3]), @@ -907,6 +908,10 @@ mod tests { assert_eq!(frame.normalized_time, 0.5); assert_eq!(frame.color, [1.0, 1.0, 1.0, 0.5]); assert_eq!(frame.material_phase, 0.5); + let authored_axis_edge = std::array::from_fn::<_, 3, _>(|axis| { + frame.vertices[2][axis] - frame.vertices[1][axis] + }); + assert_eq!(authored_axis_edge, [0.0, 0.0, 1.0]); assert!(evaluate_op3_type1_command( &command, 4, @@ -919,6 +924,197 @@ mod tests { } #[test] + #[ignore = "requires the original GOG installation"] + fn original_deflector_quad_keeps_its_authored_axis_over_time_and_camera_motion() { + let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT") + .expect("set FPARKAN_CORPUS_PART1_ROOT to the original GOG installation"); + let bytes = std::fs::read(std::path::Path::new(&root).join("effects.rlb")) + .expect("read effects.rlb"); + let archive = fparkan_nres::decode(bytes.into(), fparkan_nres::ReadProfile::Compatible) + .expect("decode effects.rlb"); + let entry = archive.find("deflector").expect("deflector FXID"); + let document = crate::decode_fxid( + archive + .payload(entry) + .expect("FXID payload") + .to_vec() + .into(), + ) + .expect("decode deflector"); + let command = &document.commands()[0]; + let parent = [ + [12.0, 0.0, 0.0, 100.0], + [0.0, 3.0, 0.0, 200.0], + [0.0, 0.0, 3.0, 20.0], + [0.0, 0.0, 0.0, 1.0], + ]; + for elapsed in [0.0_f32, 0.125, 0.5, 1.0, 2.5] { + for camera in [ + [160.0, 230.0, 20.0], + [160.0, 200.0, 50.0], + [160.0, 170.0, 5.0], + ] { + let frame = evaluate_op3_type1_command( + command, + 0, + &parent, + elapsed, + elapsed.min(1.0), + camera, + ) + .expect("active deflector quad"); + assert!(frame.active); + assert_eq!(frame.material.name.to_ascii_lowercase(), "defl_b"); + let axis_edge = std::array::from_fn::<_, 3, _>(|axis| { + frame.vertices[2][axis] - frame.vertices[1][axis] + }); + assert_eq!(axis_edge, [12.0, 0.0, 0.0]); + let center = std::array::from_fn::<_, 3, _>(|axis| { + (frame.vertices[0][axis] + frame.vertices[2][axis]) * 0.5 + }); + assert_eq!(center, [106.0, 200.0, 20.0]); + assert_eq!(frame.material_phase, (elapsed * 7.0).fract()); + } + } + } + + #[test] + #[ignore = "requires the original GOG installation"] + fn original_tree_ray_58_matches_native_type1_geometry_samples() { + let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT") + .expect("set FPARKAN_CORPUS_PART1_ROOT to the original GOG installation"); + let bytes = std::fs::read(std::path::Path::new(&root).join("effects.rlb")) + .expect("read effects.rlb"); + let archive = fparkan_nres::decode(bytes.into(), fparkan_nres::ReadProfile::Compatible) + .expect("decode effects.rlb"); + let entry = archive.find("tree_ray_58").expect("tree_ray_58 FXID"); + let document = crate::decode_fxid( + archive + .payload(entry) + .expect("FXID payload") + .to_vec() + .into(), + ) + .expect("decode tree_ray_58"); + assert_eq!(document.header().time_mode, 2); + assert_eq!(document.header().duration_seconds, 3.0); + assert_eq!(document.commands().len(), 3); + + let parent = [ + [0.999_924_123, 0.012_319_660, 0.0, 386.673_400_879], + [-0.012_319_660, 0.999_924_123, 0.0, 603.692_321_777], + [0.0, 0.0, 1.0, 180.414_703_369], + [0.0, 0.0, 0.0, 1.0], + ]; + let center = [390.273_132, 603.647_949, 180.414_703]; + // Original GOG Effect.dll RVA 0x93D0, x87 CW 0x007F; Rust-sampled keys are supplied separately. + let native_samples = [ + ( + 0, + "defl_y_add", + [0.0, 35.0, 22.0], + [ + [386.280, 604.229_431, 179.568_024], + [386.266_876, 603.165_039, 181.261_383], + [394.266_266, 603.066_467, 181.261_383], + [394.279_4, 604.130_859, 179.568_024], + ], + ), + ( + 0, + "defl_y_add", + [20.0, -30.0, 15.0], + [ + [386.279, 604.147_4, 181.307_663], + [386.267_883, 603.247_1, 179.521_744], + [394.267_273, 603.148_5, 179.521_744], + [394.278_381, 604.048_8, 181.307_663], + ], + ), + ( + 1, + "defl_y1_add", + [0.0, 35.0, 22.0], + [ + [386.276_7, 603.963_3, 179.991_364], + [386.270_172, 603.431_152, 180.838_043], + [394.269_562, 603.332_6, 180.838_043], + [394.276_1, 603.864_746, 179.991_364], + ], + ), + ]; + for (command_index, material_name, camera_offset, expected_vertices) in native_samples { + let camera = std::array::from_fn(|axis| center[axis] + camera_offset[axis]); + let frame = evaluate_op3_type1_command( + &document.commands()[command_index], + command_index, + &parent, + 0.0, + 0.0, + camera, + ) + .expect("active tree ray quad"); + assert_eq!(frame.material.name, material_name); + for (actual, expected) in frame + .vertices + .iter() + .flatten() + .zip(expected_vertices.iter().flatten()) + { + assert!( + (actual - expected).abs() < 0.001, + "native sample vertex mismatch: {actual} vs {expected}" + ); + } + } + + for (command_index, material_name) in [(0, "defl_y_add"), (1, "defl_y1_add")] { + let command = &document.commands()[command_index]; + assert_eq!(command.opcode, FxOpcode::Op3); + assert_eq!( + read_u32(&command.raw_body, 0), + Some(TYPE1_BODY_DISCRIMINATOR) + ); + let camera_offsets = [[0.0, 35.0, 22.0], [20.0, -30.0, 15.0], [-25.0, 18.0, 10.0]]; + for elapsed in [0.0_f32, 0.375, 0.75, 1.5, 2.25, 2.999, 3.125] { + let phase = elapsed.rem_euclid(3.0) / 3.0; + for camera_offset in camera_offsets { + let camera = std::array::from_fn(|axis| center[axis] + camera_offset[axis]); + let frame = evaluate_op3_type1_command( + command, + command_index, + &parent, + elapsed, + phase, + camera, + ) + .expect("active tree ray quad"); + assert_eq!(frame.material.name, material_name); + assert!(frame.active); + let actual_center: [f32; 3] = std::array::from_fn(|axis| { + frame + .vertices + .iter() + .map(|vertex| vertex[axis]) + .sum::<f32>() + * 0.25 + }); + for axis in 0..3 { + assert!((actual_center[axis] - center[axis]).abs() < 0.001); + } + let authored_axis_edge: [f32; 3] = std::array::from_fn(|axis| { + frame.vertices[2][axis] - frame.vertices[1][axis] + }); + let native_axis_edge = [7.999_393, -0.098_557_28, 0.0]; + for axis in 0..3 { + assert!((authored_axis_edge[axis] - native_axis_edge[axis]).abs() < 0.001); + } + } + } + } + } + + #[test] fn rejects_truncated_bodies_and_non_finite_tracks_or_inputs() { let identity = [ [1.0, 0.0, 0.0, 0.0], 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 { |
