aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/fparkan-fx/src/ambient_sound.rs288
-rw-r--r--crates/fparkan-fx/src/lib.rs1
-rw-r--r--crates/fparkan-fx/src/sky.rs67
-rw-r--r--crates/fparkan-render/src/lib.rs42
-rw-r--r--crates/fparkan-render/src/water.rs108
-rw-r--r--crates/fparkan-terrain-format/src/lib.rs37
-rw-r--r--crates/fparkan-terrain/src/lib.rs80
-rw-r--r--crates/fparkan-terrain/src/lod.rs301
8 files changed, 911 insertions, 13 deletions
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/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 ad96204..db0171f 100644
--- a/crates/fparkan-render/src/lib.rs
+++ b/crates/fparkan-render/src/lib.rs
@@ -1,6 +1,8 @@
#![forbid(unsafe_code)]
//! Camera mathematics and graphics pipeline state.
+pub mod water;
+
/// A 64-byte transform block returned by the original Terrain camera ABI.
///
/// The original uses two selector-dependent transform pointers. Their exact
@@ -349,6 +351,23 @@ pub enum LegacyDepthMode {
TestReadOnly,
}
+/// Reflects world Z before a row-vector clip transform, preserving points on the plane.
+#[must_use]
+pub fn reflect_clip_from_world_about_z(matrix: [f32; 16], height: f32) -> Option<[f32; 16]> {
+ if !height.is_finite() || !matrix.iter().all(|value| value.is_finite()) {
+ return None;
+ }
+ let mut reflected = matrix;
+ for column in 0..4 {
+ reflected[8 + column] = -matrix[8 + column];
+ reflected[12 + column] = matrix[12 + column] + 2.0 * height * matrix[8 + column];
+ }
+ reflected
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some(reflected)
+}
+
/// Triangle culling behaviour represented without a graphics API type.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum LegacyCullMode {
@@ -556,6 +575,29 @@ mod tests {
result
}
+ #[test]
+ fn water_reflection_preserves_the_plane_and_reverses_height_before_projection() {
+ let matrix = [
+ 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0,
+ ];
+ let reflected = reflect_clip_from_world_about_z(matrix, 150.0).unwrap();
+ let project = |m: [f32; 16], p: [f32; 4]| -> [f32; 4] {
+ std::array::from_fn(|column| (0..4).map(|row| p[row] * m[row * 4 + column]).sum())
+ };
+ for z in [125.0, 150.0, 185.0] {
+ assert_eq!(
+ project(reflected, [7.0, 11.0, z, 1.0]),
+ project(matrix, [7.0, 11.0, 300.0 - z, 1.0])
+ );
+ }
+ assert_eq!(
+ reflect_clip_from_world_about_z(reflected, 150.0),
+ Some(matrix)
+ );
+ assert!(reflect_clip_from_world_about_z(matrix, f32::NAN).is_none());
+ assert!(reflect_clip_from_world_about_z([f32::MAX; 16], f32::MAX).is_none());
+ }
+
fn assert_matrix_approximately_identity(matrix: [f32; 16]) {
for (index, value) in matrix.into_iter().enumerate() {
let expected = if index / 4 == index % 4 { 1.0 } else { 0.0 };
diff --git a/crates/fparkan-render/src/water.rs b/crates/fparkan-render/src/water.rs
new file mode 100644
index 0000000..0c7ad2a
--- /dev/null
+++ b/crates/fparkan-render/src/water.rs
@@ -0,0 +1,108 @@
+//! The GOG Terrain EMBM wave map and its world-space animation clock.
+
+/// Side length of the native signed Du/Dv texture.
+pub const EMBM_BUMP_SIZE: u32 = 128;
+/// Native default wave tiling across the bounding rectangle of reflective faces.
+pub const EMBM_BUMP_TILE: f32 = 100.0;
+/// Native diagonal bump matrix, without luminance modulation.
+pub const EMBM_COEFFICIENTS: [f32; 2] = [0.01, 0.01];
+
+/// Generates the signed bytes written by Terrain.dll RVA 0x2BF50 with CW 0x007F.
+#[must_use]
+pub fn legacy_embm_bump_map() -> Vec<[i8; 2]> {
+ let mut texels = Vec::with_capacity((EMBM_BUMP_SIZE * EMBM_BUMP_SIZE) as usize);
+ for y in 0..EMBM_BUMP_SIZE {
+ for x in 0..EMBM_BUMP_SIZE {
+ // The native constant is slightly smaller than mathematical 4*pi.
+ let angle = ((y as f32 / EMBM_BUMP_SIZE as f32 - 0.5)
+ + (x as f32 / EMBM_BUMP_SIZE as f32 - 0.5))
+ * f32::from_bits(0x4149_0fd0);
+ texels.push([
+ (angle.cos() * 64.0).round_ties_even() as i8,
+ (angle.sin() * 64.0).round_ties_even() as i8,
+ ]);
+ }
+ }
+ texels
+}
+
+/// Queue clock modulo the native ten-second wave scroll period.
+#[must_use]
+pub fn legacy_embm_phase(clock_ms: u32) -> f32 {
+ (clock_ms % 10_000) as f32 / 10_000.0
+}
+
+/// Native reflection coordinates: U reverses world Y and V reverses world X.
+#[must_use]
+pub fn legacy_embm_uv(world_xy: [f32; 2], bounds: [f32; 4]) -> Option<[f32; 2]> {
+ let span = [bounds[2] - bounds[0], bounds[3] - bounds[1]];
+ if !world_xy
+ .into_iter()
+ .chain(bounds)
+ .chain(span)
+ .all(f32::is_finite)
+ || span.iter().any(|&value| value <= 0.0)
+ {
+ return None;
+ }
+ Some([
+ 1.0 - (world_xy[1] - bounds[1]) / span[1],
+ 1.0 - (world_xy[0] - bounds[0]) / span[0],
+ ])
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn wave_bytes_match_native_generation_and_clock_wraps_without_float_time_loss() {
+ let texels = legacy_embm_bump_map();
+ assert_eq!(texels.len(), 128 * 128);
+ assert_eq!(
+ &texels[..16],
+ &[
+ [64, 0],
+ [64, 6],
+ [63, 12],
+ [61, 19],
+ [59, 24],
+ [56, 30],
+ [53, 36],
+ [49, 41],
+ [45, 45],
+ [41, 49],
+ [36, 53],
+ [30, 56],
+ [24, 59],
+ [19, 61],
+ [12, 63],
+ [6, 64],
+ ]
+ );
+ assert_eq!(texels[16], [0, 64]);
+ assert_eq!(texels[32], [-64, 0]);
+ assert_eq!(texels[48], [0, -64]);
+ let native_hash = texels
+ .iter()
+ .flatten()
+ .fold(0xcbf29ce484222325u64, |hash, &byte| {
+ (hash ^ u64::from(byte as u8)).wrapping_mul(0x100000001b3)
+ });
+ assert_eq!(native_hash, 0x0b92dc6a9e482625);
+ assert_eq!(legacy_embm_phase(0), 0.0);
+ assert_eq!(legacy_embm_phase(5_000), 0.5);
+ assert_eq!(legacy_embm_phase(10_000), 0.0);
+ assert_eq!(legacy_embm_phase(u32::MAX), 0.7295);
+ }
+
+ #[test]
+ fn mapping_uses_the_reflective_rectangle_and_keeps_axis_order() {
+ let bounds = [100.0, 200.0, 300.0, 600.0];
+ assert_eq!(legacy_embm_uv([100.0, 200.0], bounds), Some([1.0, 1.0]));
+ assert_eq!(legacy_embm_uv([300.0, 600.0], bounds), Some([0.0, 0.0]));
+ assert_eq!(legacy_embm_uv([150.0, 500.0], bounds), Some([0.25, 0.75]));
+ assert_eq!(legacy_embm_uv([0.0; 2], [1.0; 4]), None);
+ assert_eq!(legacy_embm_uv([f32::NAN, 0.0], bounds), None);
+ }
+}
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);
+ }
+}