diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/fparkan-fx/src/clock.rs | 190 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/lib.rs | 17 | ||||
| -rw-r--r-- | crates/fparkan-render/src/lib.rs | 5 | ||||
| -rw-r--r-- | crates/fparkan-render/src/water.rs | 137 |
4 files changed, 347 insertions, 2 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/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..58a2817 100644 --- a/crates/fparkan-render/src/water.rs +++ b/crates/fparkan-render/src/water.rs @@ -7,6 +7,17 @@ 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]; +/// 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 +62,137 @@ 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 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); |
