aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-fx
diff options
context:
space:
mode:
Diffstat (limited to 'crates/fparkan-fx')
-rw-r--r--crates/fparkan-fx/src/ambient_op3.rs218
-rw-r--r--crates/fparkan-fx/src/ambient_sound.rs288
-rw-r--r--crates/fparkan-fx/src/environment.rs74
-rw-r--r--crates/fparkan-fx/src/lib.rs1
-rw-r--r--crates/fparkan-fx/src/sky.rs67
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 {