diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs index 09d28f9..a481732 100644 --- a/crates/fparkan-render/src/lib.rs +++ b/crates/fparkan-render/src/lib.rs @@ -127,6 +127,123 @@ impl RawCameraTransform { 1.0, ]) } + + /// Reproduces Ngi32's `Direct3D7` view-matrix conversion for this transform. + /// + /// The legacy renderer copies selector-0 into its camera state, then maps + /// its axes and translation in this exact order before calling + /// `IDirect3DDevice7::SetTransform(D3DTRANSFORMSTATE_VIEW, ...)`. This is + /// deliberately distinct from [`Self::try_inverse_affine_row_major`]: it + /// includes the original renderer's coordinate-system conversion. + /// + /// The returned matrix is row-major D3D7 data, not yet a Vulkan view + /// matrix. A later adapter must explicitly account for clip-space and + /// shader-vector conventions. + #[must_use] + pub fn try_direct3d7_view_row_major(self) -> Option<[f32; 16]> { + let matrix = self.words.map(f32::from_bits); + if !matrix.iter().all(|value| value.is_finite()) + || matrix[12].abs() > f32::EPSILON + || matrix[13].abs() > f32::EPSILON + || matrix[14].abs() > f32::EPSILON + || (matrix[15] - 1.0).abs() > f32::EPSILON + { + return None; + } + + let [m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, _, _, _, _] = matrix; + Some([ + -m01, + m02, + m00, + 0.0, + -m11, + m12, + m10, + 0.0, + -m21, + m22, + m20, + 0.0, + m23.mul_add(m21, m13.mul_add(m11, m03 * m01)), + -(m23.mul_add(m12, m13.mul_add(m02, m03 * m10))), + -(m00.mul_add(m03, m23.mul_add(m20, m13 * m10))), + 1.0, + ]) + } +} + +/// Parameters consumed by Ngi32's `Direct3D7` projection-matrix builder. +/// +/// These are a separate legacy-renderer boundary from +/// [`RawCameraProjection`]. The latter preserves Terrain's source ABI, while +/// this type records the already-resolved Ngi32 values submitted to `Direct3D7`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LegacyD3d7Projection { + /// Viewport rectangle as `(left, top, right, bottom)`. + pub viewport: [i32; 4], + /// Positive camera near-plane distance. + pub near_plane: f32, + /// Camera far-plane distance, greater than [`Self::near_plane`]. + pub far_plane: f32, + /// Full field-of-view angle in radians. + pub field_of_view_radians: f32, +} + +impl LegacyD3d7Projection { + /// Reconstructs the exact row-major matrix passed to D3D7 projection state. + /// + /// Ngi32 uses the viewport's `height / width`, writes `cos(fov / 2)` to + /// the diagonal and `sin(fov / 2)` to the homogeneous-W term. The ratio + /// after D3D's perspective divide is therefore the expected cotangent + /// scale. This remains legacy D3D7 data rather than a Vulkan projection. + #[must_use] + pub fn try_direct3d7_projection_row_major(self) -> Option<[f32; 16]> { + let width = self.viewport[2].checked_sub(self.viewport[0])?; + let height = self.viewport[3].checked_sub(self.viewport[1])?; + if width <= 0 + || height <= 0 + || !self.near_plane.is_finite() + || !self.far_plane.is_finite() + || !self.field_of_view_radians.is_finite() + || self.near_plane <= 0.0 + || self.far_plane <= self.near_plane + || self.field_of_view_radians <= 0.0 + || self.field_of_view_radians >= std::f32::consts::PI + { + return None; + } + + // Ngi32 converts these signed viewport dimensions into single-precision + // arithmetic before building the legacy matrix. + #[allow(clippy::cast_precision_loss)] + let aspect = (height as f32) / (width as f32); + let half_fov = self.field_of_view_radians * 0.5; + let cosine = half_fov.cos(); + let sine = half_fov.sin(); + let depth_scale = 1.0 / (1.0 - self.near_plane / self.far_plane); + [aspect, cosine, sine, depth_scale] + .iter() + .all(|value| value.is_finite()) + .then_some([ + cosine, + 0.0, + 0.0, + 0.0, + 0.0, + aspect * cosine, + 0.0, + 0.0, + 0.0, + 0.0, + depth_scale, + sine, + 0.0, + 0.0, + -(depth_scale * self.near_plane), + 0.0, + ]) + } } /// Raw camera state observed through the original Terrain camera interface. @@ -951,6 +1068,71 @@ mod tests { assert_eq!(singular.try_inverse_affine_row_major(), None); } + #[test] + fn raw_camera_transform_reproduces_direct3d7_view_axis_conversion() { + let transform = RawCameraTransform { + words: [ + 0.0_f32.to_bits(), + (-1.0_f32).to_bits(), + 0.0_f32.to_bits(), + 10.0_f32.to_bits(), + 1.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 20.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), + 30.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), + ], + }; + + assert_eq!( + transform.try_direct3d7_view_row_major(), + Some([ + 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, -10.0, -10.0, -20.0, + 1.0, + ]) + ); + assert_eq!( + RawCameraTransform { words: [0_u32; 16] }.try_direct3d7_view_row_major(), + None + ); + } + + #[test] + fn legacy_d3d7_projection_matches_recovered_camera_formula() { + let projection = LegacyD3d7Projection { + viewport: [0, 0, 1024, 768], + near_plane: 0.5, + far_plane: 700.0, + field_of_view_radians: 1.3, + }; + let matrix = projection + .try_direct3d7_projection_row_major() + .expect("live Ngi32 projection parameters are valid"); + let half_fov = 0.65_f32; + let depth_scale = 700.0_f32 / 699.5; + + assert_eq!(matrix[0], half_fov.cos()); + assert_eq!(matrix[5], 0.75 * half_fov.cos()); + assert_eq!(matrix[10], depth_scale); + assert_eq!(matrix[11], half_fov.sin()); + assert_eq!(matrix[14], -(depth_scale * 0.5)); + assert_eq!( + LegacyD3d7Projection { + viewport: [0, 0, 0, 768], + ..projection + } + .try_direct3d7_projection_row_major(), + None + ); + } + fn snapshot_draw( id: u64, phase: RenderPhase, diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index f061e8e..4f86fef 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1383,6 +1383,40 @@ next recovery target is therefore the Direct3D transform consumer in `700`, `0.1`, and `0.99` values remain unlabelled until that consumer proves their roles. +### Ngi32 Direct3D7 camera matrices recovered + +The downstream renderer is now concrete. `iron3d.dll` obtains an opaque +render interface through `Ngi32!niGet3DRender`; Ngi32 RVA `0x5640` returns the +global renderer object at RVA `0x3A460`. Its constructor at RVA `0x5E10` gives +the `0x8CC`-byte object vtable `0x100315E0`. During device setup, Ngi32 RVA +`0x8E70` calls the Direct3D7 device vtable at `+0x2C` (`SetTransform`) with +state `3` for the matrix made by RVA `0x7030` and state `2` for the matrix made +by RVA `0x9450`: projection then view. + +RVA `0x7030` exactly builds the row-major D3D7 projection from renderer FOV +`f`, near `n`, far `z` and viewport width/height `w`/`h`: + +```text +[ cos(f/2), 0, 0, 0 ] +[ 0, h/w * cos(f/2), 0, 0 ] +[ 0, 0, z/(z-n), sin(f/2) ] +[ 0, 0, -n*z/(z-n), 0 ] +``` + +The sine in the homogeneous-W term is intentional: after the D3D perspective +divide the diagonal has the expected cotangent scale. RVA `0x9450` applies a +specific axis permutation/sign change and translated dot products to selector +0 before the view `SetTransform`; it is not merely the generic affine inverse. + +An elevated read-only AutoDemo probe sampled a 1024×768 renderer with +near=`0.5`, far=`700`, FOV=`1.3` radians and showed its view-source pointer +byte-identical to the active Terrain outer camera's selector-0 block. This +proves ownership and the D3D7 boundary, while keeping the earlier +`CBufferingCamera` FOV=`1.04` as a distinct upstream interface value. The +backend-neutral render crate now exposes the two recovered D3D7 matrices, but +does not yet use them as Vulkan matrices: vector and clip-space conversion is +still an explicit next task. + A fresh no-input launch of the canonical `iron_3d.exe` did create a responsive window titled `Parkan. Железная Стратегия`. A read-only probe then requested `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` and attempted to read the known