aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/fparkan-fx/src/clock.rs190
-rw-r--r--crates/fparkan-fx/src/environment.rs154
-rw-r--r--crates/fparkan-fx/src/lib.rs17
-rw-r--r--crates/fparkan-render/src/lib.rs5
-rw-r--r--crates/fparkan-render/src/water.rs174
5 files changed, 534 insertions, 6 deletions
diff --git a/crates/fparkan-fx/src/clock.rs b/crates/fparkan-fx/src/clock.rs
new file mode 100644
index 0000000..9382b33
--- /dev/null
+++ b/crates/fparkan-fx/src/clock.rs
@@ -0,0 +1,190 @@
+//! Per-instance time for the owner-independent Effect.dll phase modes.
+
+/// The clock fields read by Effect.dll +0x5C60 and set by +0x7B00/+0x7B40.
+#[derive(Clone, Debug)]
+pub struct FxInstanceClock {
+ header_mode: u32,
+ mode: u32,
+ header_flags: u32,
+ duration_ms: f32,
+ origin_ms: f32,
+ end_ms: f32,
+ constant_phase: f32,
+ restart_requested: bool,
+}
+
+impl FxInstanceClock {
+ /// Creates an instance at its own origin, using the document's default mode.
+ /// Modes requiring an owner remain unsupported by this clock.
+ pub fn new(
+ mode: u32,
+ header_flags: u32,
+ duration_seconds: f32,
+ origin_ms: f32,
+ ) -> Option<Self> {
+ let duration_ms = duration_seconds * 1000.0;
+ if mode > 3
+ || !duration_ms.is_finite()
+ || duration_ms < 0.0
+ || !origin_ms.is_finite()
+ || (mode != 0 && duration_ms == 0.0)
+ || !(origin_ms + duration_ms).is_finite()
+ {
+ return None;
+ }
+ Some(Self {
+ header_mode: mode,
+ mode,
+ header_flags,
+ duration_ms,
+ origin_ms,
+ end_ms: origin_ms + duration_ms,
+ constant_phase: 0.0,
+ restart_requested: false,
+ })
+ }
+
+ /// Changes the caller-selected mode and epoch without changing the document.
+ pub fn set_time(&mut self, now_ms: f32, mode: u32) -> Option<()> {
+ if mode > 3
+ || !now_ms.is_finite()
+ || !(now_ms + self.duration_ms).is_finite()
+ || (mode != 0 && self.duration_ms == 0.0)
+ {
+ return None;
+ }
+ self.origin_ms = now_ms;
+ self.end_ms = now_ms + self.duration_ms;
+ self.mode = mode;
+ Some(())
+ }
+
+ /// Sets the value used by mode zero; command evaluation clamps it later.
+ pub fn set_constant_phase(&mut self, phase: f32) -> Option<()> {
+ if !phase.is_finite() {
+ return None;
+ }
+ self.constant_phase = phase;
+ Some(())
+ }
+
+ /// Requests the native deferred restart: the current lifetime finishes first.
+ pub fn request_restart(&mut self) {
+ self.restart_requested = true;
+ }
+
+ /// Applies the restart portion of Effect.dll +0x8120 before phase sampling.
+ /// The new lifetime starts at the update time rather than retaining overshoot.
+ pub fn update(&mut self, now_ms: f32) -> Option<()> {
+ if !now_ms.is_finite() {
+ return None;
+ }
+ if self.restart_requested && self.end_ms <= now_ms {
+ self.set_time(now_ms, self.header_mode)?;
+ self.restart_requested = false;
+ }
+ Some(())
+ }
+
+ /// Elapsed instance time for command simulation, independent of app startup.
+ pub fn elapsed_seconds(&self, now_ms: f32) -> Option<f32> {
+ now_ms
+ .is_finite()
+ .then_some((now_ms - self.origin_ms) * 0.001)
+ }
+
+ /// Samples the raw phase before command clamping and random phase jitter.
+ /// `manager_flag_a8` is the native owner bit tested by header gates 0x80/0x100.
+ pub fn phase(&self, now_ms: f32, manager_flag_a8: bool) -> Option<f32> {
+ if !now_ms.is_finite() {
+ return None;
+ }
+ if (self.header_flags & 0x80 != 0 && !manager_flag_a8)
+ || (self.header_flags & 0x100 != 0 && manager_flag_a8)
+ {
+ return Some(0.0);
+ }
+ let ratio = (now_ms - self.origin_ms) / (self.end_ms - self.origin_ms);
+ let mut phase = match self.mode {
+ 0 => self.constant_phase,
+ 1 => ratio,
+ 2 => ratio - ratio.floor(),
+ 3 => 1.0 - ratio,
+ _ => return None,
+ };
+ if self.header_flags & 0x200 != 0 {
+ phase *= ratio;
+ }
+ if self.header_flags & 0x20 != 0 {
+ if phase >= 0.5 {
+ phase = 1.0 - phase;
+ }
+ phase *= 2.0;
+ }
+ phase.is_finite().then_some(phase)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn native_epoch_modes_and_unclamped_header_transforms() {
+ // Effect.dll GOG +0x5C60, x87 CW 007F; native oracle uses these epochs.
+ for (mode, expected) in [
+ (0, [0.375, 0.375, 0.375, 0.375, 0.375]),
+ (1, [0.0, 0.25, 0.5, 1.0, 1.25]),
+ (2, [0.0, 0.25, 0.5, 0.0, 0.25]),
+ (3, [1.0, 0.75, 0.5, 0.0, -0.25]),
+ ] {
+ let mut clock = FxInstanceClock::new(mode, 0, 4.0, 135_000.0).unwrap();
+ clock.set_constant_phase(0.375).unwrap();
+ for (now, phase) in [135_000.0, 136_000.0, 137_000.0, 139_000.0, 140_000.0]
+ .into_iter()
+ .zip(expected)
+ {
+ assert_eq!(clock.phase(now, false), Some(phase));
+ }
+ }
+ for (flags, expected) in [(0x200, 2.25), (0x20, -1.0), (0x220, -2.5)] {
+ let clock = FxInstanceClock::new(1, flags, 4.0, 135_000.0).unwrap();
+ assert_eq!(clock.phase(141_000.0, false), Some(expected));
+ }
+ }
+
+ #[test]
+ fn caller_mode_and_deferred_restart_use_the_instance_origin() {
+ let mut clock = FxInstanceClock::new(2, 0, 4.0, 135_000.0).unwrap();
+ clock.set_time(136_000.0, 3).unwrap();
+ clock.request_restart();
+ clock.update(139_999.0).unwrap();
+ assert!(clock.phase(139_999.0, false).unwrap() < 0.001);
+ clock.update(140_250.0).unwrap();
+ assert_eq!(clock.phase(140_250.0, false), Some(0.0));
+ assert_eq!(clock.elapsed_seconds(140_750.0), Some(0.5));
+ assert_eq!(clock.phase(141_250.0, false), Some(0.25));
+ }
+
+ #[test]
+ fn manager_header_gates_precede_phase_transforms() {
+ for (flags, manager, expected) in [
+ (0x80, false, 0.0),
+ (0x80, true, 0.5),
+ (0x100, false, 0.5),
+ (0x100, true, 0.0),
+ ] {
+ let clock = FxInstanceClock::new(1, flags, 2.0, 20_000.0).unwrap();
+ assert_eq!(clock.phase(21_000.0, manager), Some(expected));
+ }
+ assert_eq!(
+ FxInstanceClock::new(0, 0, 0.0, 0.0)
+ .unwrap()
+ .phase(100.0, false),
+ Some(0.0)
+ );
+ assert!(FxInstanceClock::new(1, 0, 0.0, 0.0).is_none());
+ assert!(FxInstanceClock::new(0, 0, f32::MAX, 0.0).is_none());
+ assert!(FxInstanceClock::new(4, 0, 2.0, 0.0).is_none());
+ }
+}
diff --git a/crates/fparkan-fx/src/environment.rs b/crates/fparkan-fx/src/environment.rs
index 713a3f9..b0f9d83 100644
--- a/crates/fparkan-fx/src/environment.rs
+++ b/crates/fparkan-fx/src/environment.rs
@@ -615,7 +615,7 @@ pub struct PointLightFrame {
pub range: f32,
/// Native attenuation coefficients `(a0, a1, a2)`.
pub attenuation: [f32; 3],
- /// Radius jitter amplitude at body offset 116; retained but not sampled yet.
+ /// Radius jitter amplitude at body offset 116 (legacy field name).
pub effect_factor: f32,
/// Whether the opcode 1 command is active at `command_time`.
pub active: bool,
@@ -627,6 +627,8 @@ pub struct PointLightFrame {
/// `command_time` is resolved by the caller; FX header duration and lifecycle
/// remain with that caller. Native Op1 scales the radius by the transformed
/// direction length and replaces a non-positive result with 0.01.
+/// This samples the authored curves; Control FX use
+/// [`evaluate_op1_point_light_with_basis_and_jitter`] for stochastic additions.
#[must_use]
pub fn evaluate_op1_point_light_with_basis(
command: &FxCommand,
@@ -634,6 +636,51 @@ pub fn evaluate_op1_point_light_with_basis(
origin: [f32; 3],
basis: [[f32; 3]; 3],
) -> Option<PointLightFrame> {
+ evaluate_op1_point_light(command, command_time, origin, basis, None)
+}
+
+/// The two native streams used by Op1 color and radius jitter.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct Op1JitterState {
+ general: u32,
+ light: u32,
+}
+
+impl Op1JitterState {
+ #[must_use]
+ pub const fn new(general: u32, light: u32) -> Self {
+ Self { general, light }
+ }
+
+ fn sample(&mut self) -> ([u16; 4], u16) {
+ let alpha = crate::advance_native_fx_random_state(&mut self.light);
+ let blue = crate::advance_native_fx_random_state(&mut self.light);
+ let green = crate::advance_native_fx_random_state(&mut self.light);
+ let red = crate::advance_native_fx_random_state(&mut self.general);
+ let radius = crate::advance_native_fx_random_state(&mut self.light);
+ ([red, green, blue, alpha], radius)
+ }
+}
+
+/// Adds the native signed uniform jitter before transforming the light radius.
+/// Streams advance even outside the command interval and with zero amplitudes.
+pub fn evaluate_op1_point_light_with_basis_and_jitter(
+ command: &FxCommand,
+ command_time: f32,
+ origin: [f32; 3],
+ basis: [[f32; 3]; 3],
+ jitter: &mut Op1JitterState,
+) -> Option<PointLightFrame> {
+ evaluate_op1_point_light(command, command_time, origin, basis, Some(jitter))
+}
+
+fn evaluate_op1_point_light(
+ command: &FxCommand,
+ command_time: f32,
+ origin: [f32; 3],
+ basis: [[f32; 3]; 3],
+ jitter: Option<&mut Op1JitterState>,
+) -> Option<PointLightFrame> {
if command.opcode != FxOpcode::Op1
|| !command_time.is_finite()
|| !origin
@@ -653,14 +700,27 @@ pub fn evaluate_op1_point_light_with_basis(
|| FLOAT_OFFSETS
.iter()
.any(|&offset| read_f32(body, offset).is_none_or(|value| !value.is_finite()))
+ || (jitter.is_some()
+ && (92..108)
+ .step_by(4)
+ .any(|offset| read_f32(body, offset).is_none_or(|value| !value.is_finite())))
{
return None;
}
+ let jitter = jitter.map(Op1JitterState::sample);
let normalized_time = native_opcode1_interval(body, command_time)?;
if !normalized_time.is_finite() {
return None;
}
- let sample = sample_op1_point_light(body, normalized_time)?;
+ let mut sample = sample_op1_point_light(body, normalized_time)?;
+ if let Some((color, radius)) = jitter {
+ let centered =
+ |word, amplitude| f32::from(word) * (amplitude * (1.0 / 65_536.0)) - amplitude * 0.5;
+ for (axis, word) in color.into_iter().enumerate() {
+ sample.color[axis] += centered(word, read_f32(body, 92 + axis * 4)?);
+ }
+ sample.range += centered(radius, read_f32(body, 116)?);
+ }
let (light_selector, native_flags) = opcode1_light_manager_mapping(sample.effect_type)?;
if light_selector != 1 {
return None;
@@ -681,8 +741,6 @@ pub fn evaluate_op1_point_light_with_basis(
.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;
@@ -2982,6 +3040,94 @@ mod tests {
}
#[test]
+ fn opcode1_jitter_matches_native_color_words_and_advances_outside_the_interval() {
+ let mut command = op1_test_command(7, 0.0, 1.0);
+ let mut body = command.raw_body.to_vec();
+ for (start, values) in [
+ (60, [0.25_f32, 0.5, 0.75, 1.0]),
+ (76, [0.75, 1.0, 1.25, 1.5]),
+ (92, [0.5, 1.0, 2.0, 4.0]),
+ ] {
+ body[start..start + 16].copy_from_slice(&values.map(f32::to_le_bytes).concat());
+ }
+ body[48..60].copy_from_slice(&[1.0_f32, 0.0, 0.0].map(f32::to_le_bytes).concat());
+ body[116..120].copy_from_slice(&4.0_f32.to_le_bytes());
+ command.raw_body = Arc::from(body);
+ let mut state = super::Op1JitterState::new(0x12345678, 0x89abcdef);
+ // Effect.dll+F6E0, CW007F, identity owner; native FastProc length is approximate.
+ let native = [
+ (
+ [0x3ef7de00, 0x3f310300, 0x3f123400, 0x3ef50000],
+ 0x4150a0fb,
+ 0xb7debec4,
+ 0xa29eea1f,
+ ),
+ (
+ [0x3f08dc80, 0x3ec15400, 0x3fd95800, 0xbe011000],
+ 0x417a34ee,
+ 0x91b9ca56,
+ 0xa8f3b8a6,
+ ),
+ (
+ [0x3f06e480, 0x3f190600, 0x3f885b00, 0x3fcb8c00],
+ 0x4181f650,
+ 0x4dc90515,
+ 0x2fd33350,
+ ),
+ (
+ [0x3f508380, 0x3f2a5100, 0x40099880, 0x3f8d3400],
+ 0x41c31747,
+ 0x610747e3,
+ 0xf8ebfdc3,
+ ),
+ (
+ [0x3f2f2100, 0x3f56ed00, 0x3f0c9c00, 0x3f9e3000],
+ 0x417d7bd4,
+ 0xde42eec1,
+ 0xb60ffd79,
+ ),
+ ];
+ for (phase, (color, radius, general, light)) in
+ [0.25, 0.5, 0.75, 1.25, 0.5].into_iter().zip(native)
+ {
+ let frame = super::evaluate_op1_point_light_with_basis_and_jitter(
+ &command,
+ phase,
+ [0.0; 3],
+ identity_basis(),
+ &mut state,
+ );
+ if phase > 1.0 {
+ assert!(frame.is_none());
+ } else {
+ let frame = frame.unwrap();
+ assert_eq!(frame.color.map(f32::to_bits), color);
+ let native_radius = f32::from_bits(radius);
+ assert!((frame.range - native_radius).abs() < native_radius * 0.00013);
+ }
+ assert_eq!((state.general, state.light), (general, light));
+ }
+ }
+
+ #[test]
+ fn opcode1_jitter_rejects_nonfinite_amplitudes_without_consuming_random_state() {
+ let mut command = op1_test_command(7, 0.0, 1.0);
+ let mut body = command.raw_body.to_vec();
+ body[92..96].copy_from_slice(&f32::NAN.to_le_bytes());
+ command.raw_body = Arc::from(body);
+ let mut state = super::Op1JitterState::new(123, 456);
+ assert!(super::evaluate_op1_point_light_with_basis_and_jitter(
+ &command,
+ 0.5,
+ [0.0; 3],
+ identity_basis(),
+ &mut state
+ )
+ .is_none());
+ assert_eq!((state.general, state.light), (123, 456));
+ }
+
+ #[test]
fn generic_opcode1_keeps_zero_direction_zero() {
let mut command = op1_test_command(5, 0.0, 1.0);
let mut body = command.raw_body.to_vec();
diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs
index af7408a..4201ffc 100644
--- a/crates/fparkan-fx/src/lib.rs
+++ b/crates/fparkan-fx/src/lib.rs
@@ -9,6 +9,7 @@
pub mod ambient_op3;
pub mod ambient_sound;
pub mod atmosphere;
+pub mod clock;
pub mod environment;
pub mod fx_visual;
pub mod op7;
@@ -67,6 +68,12 @@ pub struct FxHeader {
pub scale: [f32; 3],
}
+/// Effect.dll RVA 0x5C60 reads an owner/provider for modes 4 through 17, including mode 14.
+#[must_use]
+pub const fn fx_time_mode_requires_owner_state(mode: u32) -> bool {
+ matches!(mode, 4..=17)
+}
+
/// FX opcode.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum FxOpcode {
@@ -624,6 +631,16 @@ mod tests {
use std::path::{Path, PathBuf};
#[test]
+ fn phase_sources_distinguish_elapsed_damage_and_progress_modes() {
+ for mode in [0, 1, 2, 3, 18, u32::MAX] {
+ assert!(!fx_time_mode_requires_owner_state(mode));
+ }
+ for mode in [4, 5, 9, 13, 14, 15, 16, 17] {
+ assert!(fx_time_mode_requires_owner_state(mode));
+ }
+ }
+
+ #[test]
fn decodes_synthetic_opcodes_and_refs() {
let mut bytes = header(2);
bytes.extend_from_slice(&command_with_ref_at(
diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs
index db0171f..80193c7 100644
--- a/crates/fparkan-render/src/lib.rs
+++ b/crates/fparkan-render/src/lib.rs
@@ -382,8 +382,9 @@ pub enum LegacyCullMode {
/// 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`.
+/// `packet_distance` is the native distance to the input packet's first indexed
+/// vertex after its object transform, even when that vertex is outside the
+/// viewport. 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]
diff --git a/crates/fparkan-render/src/water.rs b/crates/fparkan-render/src/water.rs
index 0c7ad2a..7a5ccbd 100644
--- a/crates/fparkan-render/src/water.rs
+++ b/crates/fparkan-render/src/water.rs
@@ -7,6 +7,32 @@ 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];
+/// Terrain draws ordinary textured water into its reflection with this alpha.
+pub const REFLECTED_WATER_ALPHA: f32 = 0.1;
+
+/// Native packet category 4 enables source-alpha blending without changing depth.
+#[must_use]
+pub fn legacy_reflected_water_pipeline(
+ state: crate::LegacyPipelineState,
+) -> crate::LegacyPipelineState {
+ crate::LegacyPipelineState {
+ blend: crate::LegacyBlendMode::SourceAlpha,
+ alpha_test: true,
+ ..state
+ }
+}
+
+/// Square reflection size chosen by Terrain.dll RVA 0x27FC7 from renderer dimensions.
+#[must_use]
+pub fn legacy_embm_reflection_size(extent: [u32; 2]) -> u32 {
+ let limit = extent[0].min(extent[1]).min(1024);
+ let mut side = 2;
+ while side * 2 <= limit {
+ side *= 2;
+ }
+ side
+}
+
/// Generates the signed bytes written by Terrain.dll RVA 0x2BF50 with CW 0x007F.
#[must_use]
pub fn legacy_embm_bump_map() -> Vec<[i8; 2]> {
@@ -51,11 +77,159 @@ pub fn legacy_embm_uv(world_xy: [f32; 2], bounds: [f32; 4]) -> Option<[f32; 2]>
])
}
+/// The EMBM camera looks up from the mirrored eye, with its window on the water rectangle.
+/// Terrain.dll+4EFF0 supplies `[min_y-eye_y, min_x-eye_x, span_y, span_x, eye_z-height]`.
+/// The ordinary perspective's depth mapping is retained; orthographic inputs use the caller's fallback.
+#[must_use]
+pub fn legacy_embm_reflection_clip(
+ main_clip: [f32; 16],
+ eye: [f32; 3],
+ height: f32,
+ bounds: [f32; 4],
+) -> Option<[f32; 16]> {
+ let span = [bounds[2] - bounds[0], bounds[3] - bounds[1]];
+ let distance = eye[2] - height;
+ if !main_clip
+ .into_iter()
+ .chain(eye)
+ .chain([height])
+ .chain(bounds)
+ .chain(span)
+ .all(f32::is_finite)
+ || distance <= 0.0
+ || span.into_iter().any(|v| v <= 0.0)
+ {
+ return None;
+ }
+ let w = [main_clip[3], main_clip[7], main_clip[11]];
+ let squared = w.into_iter().map(|v| v * v).sum::<f32>();
+ if !squared.is_finite() || squared <= f32::EPSILON {
+ return None;
+ }
+ let depth_a = (main_clip[2] * w[0] + main_clip[6] * w[1] + main_clip[10] * w[2]) / squared;
+ // shortcut: retain the main depth range until the native shifted depth matrix is verified.
+ let depth_b = (main_clip[14] - depth_a * main_clip[15]) / squared.sqrt();
+ if !depth_a.is_finite() || !depth_b.is_finite() || depth_a < 1.0 || depth_b >= 0.0 {
+ return None;
+ }
+ let z = 2.0 * height - eye[2];
+ let x_scale = -2.0 * distance / span[1];
+ let y_scale = -2.0 * distance / span[0];
+ let x_shift = 1.0 + 2.0 * (bounds[1] - eye[1]) / span[1];
+ let y_shift = 1.0 + 2.0 * (bounds[0] - eye[0]) / span[0];
+ let matrix = [
+ 0.0,
+ y_scale,
+ 0.0,
+ 0.0,
+ x_scale,
+ 0.0,
+ 0.0,
+ 0.0,
+ x_shift,
+ y_shift,
+ depth_a,
+ 1.0,
+ -x_scale * eye[1] - x_shift * z,
+ -y_scale * eye[0] - y_shift * z,
+ depth_b - depth_a * z,
+ -z,
+ ];
+ matrix.into_iter().all(f32::is_finite).then_some(matrix)
+}
+
#[cfg(test)]
mod tests {
use super::*;
#[test]
+ fn reflected_water_blends_material_alpha_and_preserves_depth_and_cull() {
+ use crate::{LegacyBlendMode, LegacyCullMode, LegacyDepthMode, LegacyPipelineState};
+ for depth in [
+ LegacyDepthMode::Disabled,
+ LegacyDepthMode::TestWrite,
+ LegacyDepthMode::TestReadOnly,
+ ] {
+ let original = LegacyPipelineState {
+ depth,
+ cull: LegacyCullMode::FrontFace,
+ ..LegacyPipelineState::default()
+ };
+ let fallback = legacy_reflected_water_pipeline(original);
+ assert_eq!(fallback.blend, LegacyBlendMode::SourceAlpha);
+ assert!(fallback.alpha_test);
+ assert_eq!(fallback.depth, original.depth);
+ assert_eq!(fallback.cull, original.cull);
+ }
+ assert_eq!(REFLECTED_WATER_ALPHA.to_bits(), 0x3dcc_cccd);
+ }
+
+ #[test]
+ fn reflection_size_matches_native_renderer_dimension_vectors() {
+ for (extent, expected) in [
+ ([1280, 1024], 1024),
+ ([1280, 720], 512),
+ ([800, 600], 512),
+ ([640, 480], 256),
+ ([1920, 1080], 1024),
+ ([2048, 2048], 1024),
+ ([4, 7], 4),
+ ([3, 300], 2),
+ ([1, 1], 2),
+ ([700, 700], 512),
+ ] {
+ assert_eq!(legacy_embm_reflection_size(extent), expected, "{extent:?}");
+ }
+ }
+
+ #[test]
+ fn shifted_camera_maps_native_water_corners_and_retains_perspective_depth() {
+ let projection = crate::LegacyD3d7Projection {
+ viewport: [0, 0, 1280, 1024],
+ near_plane: 0.5,
+ far_plane: 700.0,
+ field_of_view_radians: 1.3,
+ }
+ .try_direct3d7_projection_row_major()
+ .unwrap();
+ let eye = [1200.0, 800.0, 185.0];
+ let mut main = [0.0; 16];
+ main[2] = projection[10];
+ main[3] = projection[11];
+ main[14] = -eye[0] * projection[10] + projection[14];
+ main[15] = -eye[0] * projection[11];
+ let bounds = [1000.0, 500.0, 1500.0, 1200.0];
+ let matrix = legacy_embm_reflection_clip(main, eye, 150.0, bounds).unwrap();
+ let project = |point: [f32; 3]| -> [f32; 4] {
+ std::array::from_fn(|column| {
+ point[0] * matrix[column]
+ + point[1] * matrix[4 + column]
+ + point[2] * matrix[8 + column]
+ + matrix[12 + column]
+ })
+ };
+ for point in [
+ [1000.0, 500.0, 150.0],
+ [1500.0, 1200.0, 150.0],
+ [1125.0, 1025.0, 150.0],
+ ] {
+ let clip = project(point);
+ let expected = legacy_embm_uv([point[0], point[1]], bounds).unwrap();
+ for axis in 0..2 {
+ assert!((clip[axis] / clip[3] * 0.5 + 0.5 - expected[axis]).abs() < 0.00001);
+ }
+ assert_eq!(clip[3], 35.0);
+ }
+ let near = project([1200.0, 800.0, 115.5]);
+ assert!((near[2] / near[3]).abs() < 0.001);
+ let far = project([1200.0, 800.0, 815.0]);
+ assert!((far[2] / far[3] - 1.0).abs() < 0.00001);
+ assert!(legacy_embm_reflection_clip([0.0; 16], eye, 150.0, bounds).is_none());
+ assert!(legacy_embm_reflection_clip(main, eye, 185.0, bounds).is_none());
+ assert!(legacy_embm_reflection_clip(main, eye, 150.0, [1.0; 4]).is_none());
+ }
+
+ #[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);