From 853d9b115aa3a7e6790c79c17418e0669d322023 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 31 Oct 2026 23:13:22 +0400 Subject: fix(capture): replay the projection used by world geometry --- apps/fparkan-game/src/camera_capture.rs | 251 +++++++++++++++++++++++ apps/fparkan-game/src/main.rs | 166 +-------------- docs/appendices/knowledge-boundaries.md | 9 +- docs/reference/render-frame.md | 11 + docs/tomes/05-render.md | 6 + tools/native-frame-capture/NativeFrameCapture.cs | 134 +++++++++++- tools/native-frame-capture/README.md | 2 + 7 files changed, 409 insertions(+), 170 deletions(-) create mode 100644 apps/fparkan-game/src/camera_capture.rs diff --git a/apps/fparkan-game/src/camera_capture.rs b/apps/fparkan-game/src/camera_capture.rs new file mode 100644 index 0000000..9862546 --- /dev/null +++ b/apps/fparkan-game/src/camera_capture.rs @@ -0,0 +1,251 @@ +use super::{CapturedViewport, PreviewCameraFrame}; +use fparkan_render::{LegacyD3d7Projection, RawCameraTransform}; +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub(super) struct LegacyCameraCapture { + pub(super) schema: String, + pub(super) render_input_usable: Option, + pub(super) selector0_words: [u32; 16], + pub(super) parented_camera: Option, + pub(super) effective_world_matrix_space: Option, + pub(super) effective_world_matrix_words: Option<[u32; 16]>, + pub(super) viewport: [i32; 4], + pub(super) near_plane: f32, + pub(super) far_plane: f32, + pub(super) field_of_view_radians: f32, + pub(super) atmosphere_seconds: Option, + pub(super) geometry_projection: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub(super) struct GeometryProjection { + verified: bool, + viewport: [i32; 4], + near_plane: f32, + far_plane: f32, + field_of_view_radians: f32, +} + +pub(super) fn selected_atmosphere_seconds( + command_line: Option, + capture: Option<&LegacyCameraCapture>, +) -> Option { + command_line.or_else(|| capture.and_then(|capture| capture.atmosphere_seconds)) +} + +impl LegacyCameraCapture { + pub(super) fn preview_matrix_words(&self) -> Result<[u32; 16], String> { + match ( + self.effective_world_matrix_space.as_deref(), + self.effective_world_matrix_words, + ) { + (Some("world"), Some(words)) => Ok(words), + (Some(_), _) => Err( + "legacy camera effective_world_matrix_space must be 'world'".to_string(), + ), + (None, Some(_)) => Err( + "legacy camera effective_world_matrix_words requires an explicit world-space marker" + .to_string(), + ), + (None, None) if self.parented_camera == Some(true) => Err( + "parented legacy camera requires an effective world-space matrix".to_string(), + ), + (None, None) => Ok(self.selector0_words), + } + } + + pub(super) fn captured_viewport(&self) -> Result { + let width = self.viewport[2] + .checked_sub(self.viewport[0]) + .filter(|width| *width > 0) + .ok_or_else(|| "legacy camera capture has an invalid viewport width".to_string())?; + let height = self.viewport[3] + .checked_sub(self.viewport[1]) + .filter(|height| *height > 0) + .ok_or_else(|| "legacy camera capture has an invalid viewport height".to_string())?; + Ok(CapturedViewport { + rect: self.viewport, + extent: [ + u32::try_from(width) + .map_err(|_| "legacy camera viewport width exceeds u32".to_string())?, + u32::try_from(height) + .map_err(|_| "legacy camera viewport height exceeds u32".to_string())?, + ], + }) + } + + pub(super) fn preview_camera_frame(&self) -> Result { + let projection = if let Some(geometry) = &self.geometry_projection { + if !geometry.verified || geometry.viewport != self.viewport { + return Err( + "legacy camera geometry projection is unverified or has a different viewport" + .to_string(), + ); + } + LegacyD3d7Projection { + viewport: geometry.viewport, + near_plane: geometry.near_plane, + far_plane: geometry.far_plane, + field_of_view_radians: geometry.field_of_view_radians, + } + } else { + LegacyD3d7Projection { + viewport: self.viewport, + near_plane: self.near_plane, + far_plane: self.far_plane, + field_of_view_radians: self.field_of_view_radians, + } + }; + let frame = PreviewCameraFrame::from_legacy_d3d7( + RawCameraTransform { + words: self.preview_matrix_words()?, + }, + projection, + ) + .ok_or_else(|| "legacy camera capture contains an invalid D3D7 camera".to_string())?; + self.captured_viewport()?; + Ok(frame) + } +} + +pub(super) fn load_legacy_camera_capture( + path: &std::path::Path, +) -> Result { + let bytes = std::fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?; + let capture = + parse_legacy_camera_capture(&bytes).map_err(|err| format!("{}: {err}", path.display()))?; + if capture.geometry_projection.is_none() { + eprintln!("render_coverage captured_projection_unverified=1 (capture has no geometry_projection; using the recorded camera boundary, which can clip nearby surfaces)"); + } + Ok(capture) +} + +pub(super) fn parse_legacy_camera_capture(bytes: &[u8]) -> Result { + // Windows PowerShell 5.1 writes redirected text as UTF-16LE with a BOM. + // `capture-original-camera.ps1` intentionally emits plain JSON, so accept + // that normal hand-off format as well as UTF-8 without making the caller + // re-encode a read-only capture file. + let json = decode_legacy_camera_capture_json(bytes)?; + let capture: LegacyCameraCapture = + serde_json::from_str(&json).map_err(|err| format!("invalid legacy camera JSON: {err}"))?; + if capture.schema != "fparkan-legacy-camera-v1" { + return Err("unsupported legacy camera capture schema".to_string()); + } + if capture.effective_world_matrix_space.is_some() + != capture.effective_world_matrix_words.is_some() + { + return Err( + "effective_world_matrix_space and effective_world_matrix_words must be supplied together" + .to_string(), + ); + } + if capture.render_input_usable == Some(false) { + return Err("legacy camera capture is marked render_input_usable=false".to_string()); + } + if capture + .atmosphere_seconds + .is_some_and(|seconds| !seconds.is_finite() || seconds < 0.0) + { + return Err( + "legacy camera capture atmosphere_seconds must be finite and non-negative".to_string(), + ); + } + capture.preview_camera_frame()?; + Ok(capture) +} + +fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result { + let decode_utf16 = |words: Vec| { + String::from_utf16(&words) + .map_err(|err| format!("invalid UTF-16 legacy camera JSON: {err}")) + }; + match bytes { + [0xff, 0xfe, rest @ ..] => { + let chunks = rest.chunks_exact(2); + if !chunks.remainder().is_empty() { + return Err("invalid UTF-16LE legacy camera JSON length".to_string()); + } + decode_utf16( + chunks + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect(), + ) + } + [0xfe, 0xff, rest @ ..] => { + let chunks = rest.chunks_exact(2); + if !chunks.remainder().is_empty() { + return Err("invalid UTF-16BE legacy camera JSON length".to_string()); + } + decode_utf16( + chunks + .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]])) + .collect(), + ) + } + [0xef, 0xbb, 0xbf, rest @ ..] => std::str::from_utf8(rest) + .map(str::to_owned) + .map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")), + _ => std::str::from_utf8(bytes) + .map(str::to_owned) + .map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn json() -> serde_json::Value { + let words = [ + 1.0_f32, 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, + ] + .map(f32::to_bits); + serde_json::json!({ + "schema": "fparkan-legacy-camera-v1", "selector0_words": words, + "viewport": [0, 0, 1280, 1024], "near_plane": 5.0, "far_plane": 700.0, + "field_of_view_radians": 1.3, + "geometry_projection": { + "verified": true, "viewport": [0, 0, 1280, 1024], + "near_plane": 0.5, "far_plane": 700.0, "field_of_view_radians": 1.3, + } + }) + } + + #[test] + fn geometry_projection_preserves_surfaces_closer_than_restored_camera_near() { + let mut json = json(); + let captured = parse_legacy_camera_capture(&serde_json::to_vec(&json).unwrap()).unwrap(); + let camera = captured.preview_camera_frame().unwrap(); + let point = [1.0, 0.0, 0.0, 1.0]; + let depth = |matrix: [f32; 16]| { + (0..4) + .map(|row| point[row] * matrix[row * 4 + 2]) + .sum::() + }; + assert_eq!(camera.near_plane, 0.5); + assert!(depth(camera.clip_from_world) > 0.0); + json.as_object_mut().unwrap().remove("geometry_projection"); + let old = parse_legacy_camera_capture(&serde_json::to_vec(&json).unwrap()).unwrap(); + let old_camera = old.preview_camera_frame().unwrap(); + assert_eq!(old_camera.near_plane, 5.0); + assert!(depth(old_camera.clip_from_world) < 0.0); + } + + #[test] + fn geometry_projection_rejects_unverified_mismatched_or_invalid_input() { + for (field, value) in [ + ("verified", serde_json::json!(false)), + ("viewport", serde_json::json!([0, 0, 640, 480])), + ("near_plane", serde_json::json!(0.0)), + ("far_plane", serde_json::json!(0.25)), + ] { + let mut json = json(); + json["geometry_projection"][field] = value; + assert!( + parse_legacy_camera_capture(&serde_json::to_vec(&json).unwrap()).is_err(), + "{field}" + ); + } + } +} diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 2c63a29..f3211af 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -2,6 +2,13 @@ //! `FParkan` render-planning composition root. +mod camera_capture; + +#[cfg(test)] +use camera_capture::parse_legacy_camera_capture; +use camera_capture::{ + load_legacy_camera_capture, selected_atmosphere_seconds, LegacyCameraCapture, +}; use fparkan_animation::{AnimationTime, NodePoseBuffer}; use fparkan_assets::{ load_material_archive_name_texture_rgba8_and_phase_with_document_from_root, @@ -79,7 +86,6 @@ use fparkan_terrain::{ FullSurfaceMask, SurfaceQuery, TerrainLod, TerrainMaterialLayers, TerrainWorld, }; use fparkan_vfs::DirectoryVfs; -use serde::Deserialize; use std::collections::{HashMap, HashSet}; use std::io::Write; use std::num::NonZeroUsize; @@ -11097,163 +11103,6 @@ impl Args { } } -#[derive(Clone, Debug, Deserialize, PartialEq)] -struct LegacyCameraCapture { - schema: String, - render_input_usable: Option, - selector0_words: [u32; 16], - parented_camera: Option, - effective_world_matrix_space: Option, - effective_world_matrix_words: Option<[u32; 16]>, - viewport: [i32; 4], - near_plane: f32, - far_plane: f32, - field_of_view_radians: f32, - atmosphere_seconds: Option, -} - -fn selected_atmosphere_seconds( - command_line: Option, - capture: Option<&LegacyCameraCapture>, -) -> Option { - command_line.or_else(|| capture.and_then(|capture| capture.atmosphere_seconds)) -} - -impl LegacyCameraCapture { - fn preview_matrix_words(&self) -> Result<[u32; 16], String> { - match ( - self.effective_world_matrix_space.as_deref(), - self.effective_world_matrix_words, - ) { - (Some("world"), Some(words)) => Ok(words), - (Some(_), _) => Err( - "legacy camera effective_world_matrix_space must be 'world'".to_string(), - ), - (None, Some(_)) => Err( - "legacy camera effective_world_matrix_words requires an explicit world-space marker" - .to_string(), - ), - (None, None) if self.parented_camera == Some(true) => Err( - "parented legacy camera requires an effective world-space matrix".to_string(), - ), - (None, None) => Ok(self.selector0_words), - } - } - - fn captured_viewport(&self) -> Result { - let width = self.viewport[2] - .checked_sub(self.viewport[0]) - .filter(|width| *width > 0) - .ok_or_else(|| "legacy camera capture has an invalid viewport width".to_string())?; - let height = self.viewport[3] - .checked_sub(self.viewport[1]) - .filter(|height| *height > 0) - .ok_or_else(|| "legacy camera capture has an invalid viewport height".to_string())?; - Ok(CapturedViewport { - rect: self.viewport, - extent: [ - u32::try_from(width) - .map_err(|_| "legacy camera viewport width exceeds u32".to_string())?, - u32::try_from(height) - .map_err(|_| "legacy camera viewport height exceeds u32".to_string())?, - ], - }) - } - - fn preview_camera_frame(&self) -> Result { - let frame = PreviewCameraFrame::from_legacy_d3d7( - RawCameraTransform { - words: self.preview_matrix_words()?, - }, - LegacyD3d7Projection { - viewport: self.viewport, - near_plane: self.near_plane, - far_plane: self.far_plane, - field_of_view_radians: self.field_of_view_radians, - }, - ) - .ok_or_else(|| "legacy camera capture contains an invalid D3D7 camera".to_string())?; - self.captured_viewport()?; - Ok(frame) - } -} - -fn load_legacy_camera_capture(path: &std::path::Path) -> Result { - let bytes = std::fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?; - parse_legacy_camera_capture(&bytes).map_err(|err| format!("{}: {err}", path.display())) -} - -fn parse_legacy_camera_capture(bytes: &[u8]) -> Result { - // Windows PowerShell 5.1 writes redirected text as UTF-16LE with a BOM. - // `capture-original-camera.ps1` intentionally emits plain JSON, so accept - // that normal hand-off format as well as UTF-8 without making the caller - // re-encode a read-only capture file. - let json = decode_legacy_camera_capture_json(bytes)?; - let capture: LegacyCameraCapture = - serde_json::from_str(&json).map_err(|err| format!("invalid legacy camera JSON: {err}"))?; - if capture.schema != "fparkan-legacy-camera-v1" { - return Err("unsupported legacy camera capture schema".to_string()); - } - if capture.effective_world_matrix_space.is_some() - != capture.effective_world_matrix_words.is_some() - { - return Err( - "effective_world_matrix_space and effective_world_matrix_words must be supplied together" - .to_string(), - ); - } - if capture.render_input_usable == Some(false) { - return Err("legacy camera capture is marked render_input_usable=false".to_string()); - } - if capture - .atmosphere_seconds - .is_some_and(|seconds| !seconds.is_finite() || seconds < 0.0) - { - return Err( - "legacy camera capture atmosphere_seconds must be finite and non-negative".to_string(), - ); - } - capture.preview_camera_frame()?; - Ok(capture) -} - -fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result { - let decode_utf16 = |words: Vec| { - String::from_utf16(&words) - .map_err(|err| format!("invalid UTF-16 legacy camera JSON: {err}")) - }; - match bytes { - [0xff, 0xfe, rest @ ..] => { - let chunks = rest.chunks_exact(2); - if !chunks.remainder().is_empty() { - return Err("invalid UTF-16LE legacy camera JSON length".to_string()); - } - decode_utf16( - chunks - .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) - .collect(), - ) - } - [0xfe, 0xff, rest @ ..] => { - let chunks = rest.chunks_exact(2); - if !chunks.remainder().is_empty() { - return Err("invalid UTF-16BE legacy camera JSON length".to_string()); - } - decode_utf16( - chunks - .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]])) - .collect(), - ) - } - [0xef, 0xbb, 0xbf, rest @ ..] => std::str::from_utf8(rest) - .map(str::to_owned) - .map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")), - _ => std::str::from_utf8(bytes) - .map(str::to_owned) - .map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")), - } -} - fn usage() -> String { "usage: fparkan-game --root --mission [--frames ] [--atmosphere-seconds ] [--control-clock-ms ] [--validation] [--preview-roots ] [--legacy-camera-capture ] [--static-animation-frame ] [--static-material-phase ] [--readback-out ] [--readback-sequence-out ]\nstatic preview uses saved per-component CTLD defaults, with uncontrolled nodes at frame 0; --static-animation-frame overrides all components globally. --control-clock-ms replays one shared MSH/FX clock from zero to the selected time over the requested frames; --readback-sequence-out captures each submitted frame and requires that clock.".to_string() } @@ -15766,6 +15615,7 @@ mod tests { far_plane: 1.0, field_of_view_radians: 1.0, atmosphere_seconds: Some(56.227), + geometry_projection: None, }; assert_eq!( diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index a575baa..7224a48 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -57,8 +57,13 @@ MSH-запрос со свободной preview-камерой; это не п Данные type 16 ещё не декодированы. Внутренний ракурс «Конструктора» после настоящего входа игрока подтверждает исчезновение чёрной поверхности в проходе; наружные окна обеих миссий сверены на расстояниях около 60/85/110, а ближние -окна — с внутренней стороны. Внутренние дальние ракурсы и clipping на краю -кадра ещё не приняты; общей готовности R2 эти пары не доказывают. +окна — с внутренней стороны. Окно «Конструктора» на правом краю кадра при +дистанции около 85 совпадает; внутренние дальние ракурсы и остальные случаи +отсечения ещё не приняты. Общей готовности R2 эти пары не доказывают. + +Старые захваты без `geometry_projection` используют состояние камеры после +обхода мира, которое может отсекать близкие поверхности. Это отмечается +`captured_projection_unverified`; для приёмки таких поверхностей нужен новый захват. Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md index 19ccf3e..df3f248 100644 --- a/docs/reference/render-frame.md +++ b/docs/reference/render-frame.md @@ -225,6 +225,17 @@ RVA `0x4EBE0` использует соглашение о вызовах `__std `right - left` на `bottom - top`. Размер окна превью закреплён: если при запуске или после ненулевого изменения окна физический размер не совпадает с заданным, приложение завершает работу, а не растягивает изображение. + +Проекция во время рисования может отличаться от восстановленного состояния +камеры после обхода мира. В GOG у `CStridedPrimitive::RenderVB` (Terrain RVA +`0x1D110`) renderer содержит `near = 0.5`, а на границе World3D RVA `0x13CE4` +уже `near = 5`. Поэтому захват отдельно сохраняет `geometry_projection` при +первом вызове геометрии с той же мировой матрицей камеры. Поток, поколение, +renderer, viewport, far и FOV сверяются с последующей границей; только +подтверждённая запись используется при воспроизведении. Иначе пол в трёх +единицах от камеры может исчезнуть из-за чужой ближней плоскости. Старые JSON +без этой записи сохраняют прежнее поведение с явным счётчиком +`captured_projection_unverified`; их нужно переснять для проверки близких поверхностей. Сравнивайте результат с фрагментом исходного PNG по прямоугольнику `[left, top, right, bottom]`. В этом режиме `FreeFlightCamera` не создаётся, но при повторной отрисовке обновляются окружение и проецируемые тени для diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 55d3a09..610491b 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1009,6 +1009,12 @@ sun-ray queries поэтому используют исходные коорд она не равна одному лишь обратному преобразованию камеры. В проверенном GOG контексте Ngi32 использовались `n=0.5`, `z=700`, `f=1.3`. +Ближняя плоскость относится к конкретному проходу. После рисования движок +может вернуть прежние настройки камеры: захват только в конце мира видел +`n=5` и при воспроизведении вырезал пол перед игроком. Формат захвата поэтому +хранит проверенную проекцию вызова геометрии отдельно от состояния камеры; +подробности приведены в [описании кадра](../reference/render-frame.md#предпросмотр-по-захвату-камеры-d3d7). + У GOG `Terrain.dll + 0x7355c` хранит указатель на активную внешнюю камеру. Её selector 0 возвращает 16 слов начиная с `+0x20`, перенос находится в словах 3, 7, 11. Интерфейс `LoadCamera` расположен отдельно, по `+0x134` объекта. diff --git a/tools/native-frame-capture/NativeFrameCapture.cs b/tools/native-frame-capture/NativeFrameCapture.cs index f41fb42..9636a03 100644 --- a/tools/native-frame-capture/NativeFrameCapture.cs +++ b/tools/native-frame-capture/NativeFrameCapture.cs @@ -49,6 +49,7 @@ internal static partial class NativeFrameCapture private const string Iron3dName = "iron3d.dll"; private const uint RenderGameRva = 0x13BD0; private const uint ProjectionSnapshotRva = 0x13CE4; + private const uint GeometryProjectionRva = 0x1D110; private const uint RenderReturnRva = 0x13D7B; private const uint AtmospherePhaseRva = 0x421DC; private const uint FogConsumerRva = 0x2F230; @@ -101,6 +102,9 @@ internal static partial class NativeFrameCapture public uint ProjectionGeneration; public uint ProjectionThreadId; public ProjectionReadback Projection; + public ProjectionReadback GeometryProjection; + public uint GeometryProjectionGeneration; + public uint GeometryProjectionThreadId; public uint CameraWorldGameTimeWord; public bool CameraWorldGameTimeWordReadable; public uint WorldGameTimeWord; @@ -738,6 +742,44 @@ internal static partial class NativeFrameCapture return match; } + private static bool GeometryProjectionMatchesFrame(FrameSnapshot frame) + { + ProjectionReadback geometry = frame.GeometryProjection; + ProjectionReadback boundary = frame.Projection; + if (geometry == null || boundary == null || !geometry.Verified || !boundary.Verified || geometry.Mode == 0 + || frame.GeometryProjectionGeneration == 0 + || frame.GeometryProjectionGeneration != frame.CameraGeneration + || frame.GeometryProjectionGeneration != frame.ProjectionGeneration + || frame.GeometryProjectionThreadId != frame.CameraThreadId + || frame.GeometryProjectionThreadId != frame.ProjectionThreadId + || geometry.Renderer != boundary.Renderer || geometry.Far != boundary.Far + || geometry.Fov != boundary.Fov) return false; + for (int i = 0; i < 4; i++) + if (geometry.Viewport[i] != boundary.Viewport[i]) return false; + return true; + } + + private static void AppendGeometryProjectionJson(StringBuilder json, FrameSnapshot frame) + { + if (!GeometryProjectionMatchesFrame(frame)) + { + json.AppendLine(" \"geometry_projection\": null,"); + return; + } + ProjectionReadback p = frame.GeometryProjection; + json.AppendLine(" \"geometry_projection\": {"); + json.AppendLine(" \"verified\": true,"); + json.AppendLine(" \"sample_boundary\": \"Terrain+0x1D110, before indexed geometry draw\","); + json.AppendLine(" \"terrain_module_sha256\": " + JsonString(ExpectedSha256[TerrainName]) + ","); + json.AppendLine(" \"frame_generation\": " + frame.GeometryProjectionGeneration + ","); + json.AppendLine(" \"thread_id\": " + frame.GeometryProjectionThreadId + ","); + json.AppendLine(" \"viewport\": [" + String.Join(", ", p.Viewport) + "],"); + json.AppendLine(" \"near_plane\": " + p.Near.ToString("R", CultureInfo.InvariantCulture) + ","); + json.AppendLine(" \"far_plane\": " + p.Far.ToString("R", CultureInfo.InvariantCulture) + ","); + json.AppendLine(" \"field_of_view_radians\": " + p.Fov.ToString("R", CultureInfo.InvariantCulture)); + json.AppendLine(" },"); + } + private static bool VerifyAtmospherePhaseBoundary(IntPtr process, uint terrainBase, out byte firstByte, out string evidence) { @@ -1888,6 +1930,7 @@ internal static partial class NativeFrameCapture json.AppendLine(" \"far_plane\": " + projection.Far.ToString("R", CultureInfo.InvariantCulture) + ","); json.AppendLine(" \"field_of_view_radians\": " + projection.Fov.ToString("R", CultureInfo.InvariantCulture) + ","); json.AppendLine(" \"projection_mode_byte\": " + projection.Mode.ToString(CultureInfo.InvariantCulture) + ","); + AppendGeometryProjectionJson(json, frame); if (frame.SelectedCamera == null) json.AppendLine(" \"selected_camera\": null,"); else { @@ -1996,6 +2039,7 @@ internal static partial class NativeFrameCapture if (IntPtr.Size != 4) throw new InvalidOperationException("This helper must be compiled and run as x86."); SelfCheckCameraInput(); SelfCheckSelectedCameraWarmup(); + SelfCheckGeometryProjection(); SelfCheckNativeFogPairing(); SelfCheckRenderInvocationGates(); SelfCheckDebugEventHandleOwnership(); @@ -2306,6 +2350,34 @@ internal static partial class NativeFrameCapture return SelfCheckReadback(); } + private static void SelfCheckGeometryProjection() + { + FrameSnapshot frame = new FrameSnapshot { + CameraGeneration = 7, ProjectionGeneration = 7, GeometryProjectionGeneration = 7, + CameraThreadId = 11, ProjectionThreadId = 11, GeometryProjectionThreadId = 11, + Projection = new ProjectionReadback { Renderer = 100, Viewport = new int[] { 0, 0, 1280, 1024 }, + Near = 5, Far = 700, Fov = 1.3f, Mode = 1, Verified = true }, + GeometryProjection = new ProjectionReadback { Renderer = 100, Viewport = new int[] { 0, 0, 1280, 1024 }, + Near = 0.5f, Far = 700, Fov = 1.3f, Mode = 1, Verified = true } + }; + StringBuilder json = new StringBuilder("{\n"); + AppendGeometryProjectionJson(json, frame); + json.Append("\"end\":true}"); + Dictionary parsed = new System.Web.Script.Serialization.JavaScriptSerializer() + .Deserialize>(json.ToString()); + Dictionary projection = (Dictionary)parsed["geometry_projection"]; + if (Convert.ToSingle(projection["near_plane"]) != 0.5f) + throw new InvalidOperationException("Geometry projection must preserve draw near, not restored camera near."); + frame.GeometryProjectionThreadId++; + if (GeometryProjectionMatchesFrame(frame)) throw new InvalidOperationException("Geometry projection thread mismatch accepted."); + frame.GeometryProjectionThreadId--; + frame.GeometryProjectionGeneration--; + if (GeometryProjectionMatchesFrame(frame)) throw new InvalidOperationException("Geometry projection generation mismatch accepted."); + frame.GeometryProjectionGeneration++; + frame.GeometryProjection.Viewport[2]--; + if (GeometryProjectionMatchesFrame(frame)) throw new InvalidOperationException("Geometry projection viewport mismatch accepted."); + } + private static void SelfCheckNativeFogPairing() { if (FogConsumerRva != 0x2F230 || FogConsumerVtableRva != 0x6527C @@ -2945,6 +3017,7 @@ internal static partial class NativeFrameCapture string terrainModuleSha256 = null; BreakpointInfo cameraBreakpoint = null; BreakpointInfo projectionBreakpoint = null; + BreakpointInfo geometryProjectionBreakpoint = null; BreakpointInfo returnBreakpoint = null; BreakpointInfo atmosphereBreakpoint = null; BreakpointInfo beginSceneBreakpoint = null; @@ -3128,6 +3201,13 @@ internal static partial class NativeFrameCapture throw new InvalidOperationException("Loaded Terrain hash mismatch: " + hash); terrainBase = imageBase; terrainModuleSha256 = hash; + byte[] geometryPrefix = new byte[] { 0x81, 0xEC, 0xBC, 0, 0, 0, 0x53, 0x55, 0x8B, 0xE9 }; + byte[] geometryCode = new byte[geometryPrefix.Length]; + if (!ReadExact(process, unchecked(terrainBase + GeometryProjectionRva), geometryCode) + || !ByteArraysEqual(geometryCode, geometryPrefix)) + throw new InvalidOperationException("Terrain geometry projection boundary signature failed."); + geometryProjectionBreakpoint = ArmBreakpoint(process, "Terrain.geometry-projection", + unchecked(terrainBase + GeometryProjectionRva), geometryPrefix[0]); byte atmosphereByte; string atmosphereEvidence; if (!VerifyAtmospherePhaseBoundary(process, terrainBase, out atmosphereByte, out atmosphereEvidence)) @@ -3593,6 +3673,9 @@ internal static partial class NativeFrameCapture hitBreakpoint = cameraBreakpoint; else if (projectionBreakpoint != null && projectionBreakpoint.Armed && exceptionAddress == projectionBreakpoint.Address) hitBreakpoint = projectionBreakpoint; + else if (geometryProjectionBreakpoint != null && geometryProjectionBreakpoint.Armed + && exceptionAddress == geometryProjectionBreakpoint.Address) + hitBreakpoint = geometryProjectionBreakpoint; else if (returnBreakpoint != null && returnBreakpoint.Armed && exceptionAddress == returnBreakpoint.Address) hitBreakpoint = returnBreakpoint; else if (atmosphereBreakpoint != null && atmosphereBreakpoint.Armed && exceptionAddress == atmosphereBreakpoint.Address) @@ -3666,6 +3749,12 @@ internal static partial class NativeFrameCapture && !RestoreBreakpointWhileStopped(process, fogConsumerBreakpoint)) throw new InvalidOperationException("Could not disarm the previous generation's native fog boundary."); frame.CameraGeneration++; + frame.GeometryProjection = null; + frame.GeometryProjectionGeneration = 0; + frame.GeometryProjectionThreadId = 0; + if (geometryProjectionBreakpoint != null && !geometryProjectionBreakpoint.Armed) + geometryProjectionBreakpoint = ArmBreakpoint(process, "Terrain.geometry-projection", + geometryProjectionBreakpoint.Address, geometryProjectionBreakpoint.OriginalByte); ResetNativeFogForGeneration(frame); frame.CameraThreadId = eventTid; frame.CameraWorldGameTimeWordReadable = false; @@ -3754,6 +3843,25 @@ internal static partial class NativeFrameCapture frame, eventTid, ref fogConsumerBreakpoint); } } + else if (hitBreakpoint == geometryProjectionBreakpoint) + { + uint esp = BitConverter.ToUInt32(stoppedContext, ContextEspOffset); + if (frame.Camera != null && frame.CameraThreadId == eventTid && ngiBase != 0 + && ReadU32Exact(process, esp + 8) == frame.Camera.Camera + 0x60 + && ReadU32Exact(process, esp + 4) == ReadU32Exact(process, ngiBase + NgiRendererGlobalRva)) + { + ProjectionReadback p = ReadProjection(process, ngiBase); + if (p.Verified && p.Mode != 0) + { + frame.GeometryProjection = p; + frame.GeometryProjectionGeneration = frame.CameraGeneration; + frame.GeometryProjectionThreadId = eventTid; + Log("GEOMETRY_PROJECTION generation=" + frame.CameraGeneration + " tid=" + eventTid + + " near=" + p.Near.ToString("R", CultureInfo.InvariantCulture) + + " far=" + p.Far.ToString("R", CultureInfo.InvariantCulture)); + } + } + } else if (hitBreakpoint == projectionBreakpoint) { string candidateProjectionEvidence = "candidate preconditions not met"; @@ -3978,7 +4086,7 @@ internal static partial class NativeFrameCapture { remote = StartRemoteSurfaceProbe(process, eventTid, ngiBase, returnBreakpoint, frame, - new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, + new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, fogConsumerBreakpoint, endSceneBreakpoint, presentBreakpoint, @@ -4126,7 +4234,7 @@ internal static partial class NativeFrameCapture { remote = StartRemoteSurfaceProbe(process, eventTid, ngiBase, endSceneBreakpoint, frame, - new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, + new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint, @@ -4174,7 +4282,7 @@ internal static partial class NativeFrameCapture { remote = StartRemoteBoundaryReadback(process, eventTid, ngiBase, endSceneBreakpoint, frame, outputPath, - new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, + new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint, @@ -4392,6 +4500,8 @@ internal static partial class NativeFrameCapture if (hitBreakpoint == fogConsumerBreakpoint) rearmAfterStep = !frame.NativeFogSamplingStopped && !frame.NativeFogSampleLimitExceeded; + if (hitBreakpoint == geometryProjectionBreakpoint) + rearmAfterStep = frame.GeometryProjection == null; BeginSingleStep(process, eventTid, hitBreakpoint, stoppedContext, out pendingStep, rearmAfterStep); Log("SINGLE_STEP_STARTED boundary=" + hitBreakpoint.Name + " tid=" + eventTid); @@ -4464,7 +4574,7 @@ internal static partial class NativeFrameCapture captureFailure = captureFailure ?? "No matching native camera/projection/present frame completed before the bounded timeout."; Log("NO_FRAME_CAPTURE bounded timeout at stopped debug event"); if (!RestoreArmedBreakpointsWhileStopped(process, - cameraBreakpoint, projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, + cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint)) @@ -4484,6 +4594,7 @@ internal static partial class NativeFrameCapture && !fatal && !processExited && (cameraBreakpoint != null && cameraBreakpoint.Armed || projectionBreakpoint != null && projectionBreakpoint.Armed + || geometryProjectionBreakpoint != null && geometryProjectionBreakpoint.Armed || returnBreakpoint != null && returnBreakpoint.Armed || atmosphereBreakpoint != null && atmosphereBreakpoint.Armed || beginSceneBreakpoint != null && beginSceneBreakpoint.Armed @@ -4494,7 +4605,7 @@ internal static partial class NativeFrameCapture || missionBreakpoint != null && missionBreakpoint.Armed)) { if (!RestoreArmedBreakpointsWhileStopped(process, - cameraBreakpoint, projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, + cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint)) @@ -4543,6 +4654,7 @@ internal static partial class NativeFrameCapture || cameraMutationUnrestored || eventOutstanding || cameraBreakpoint != null && cameraBreakpoint.Armed || projectionBreakpoint != null && projectionBreakpoint.Armed + || geometryProjectionBreakpoint != null && geometryProjectionBreakpoint.Armed || returnBreakpoint != null && returnBreakpoint.Armed || atmosphereBreakpoint != null && atmosphereBreakpoint.Armed || beginSceneBreakpoint != null && beginSceneBreakpoint.Armed @@ -4572,7 +4684,7 @@ internal static partial class NativeFrameCapture if (DrainFrameTerminationEvents(process, eventBuffer, processId, ref eventOutstanding, ref outstandingPid, ref outstandingTid, ref outstandingCode, ref outstandingContinueStatus, ref outstandingFileHandleClosed, - new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, returnBreakpoint, + new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint }, out drainEvidence)) @@ -4641,7 +4753,7 @@ internal static partial class NativeFrameCapture if (DrainFrameTerminationEvents(process, eventBuffer, processId, ref eventOutstanding, ref outstandingPid, ref outstandingTid, ref outstandingCode, ref outstandingContinueStatus, ref outstandingFileHandleClosed, - new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, returnBreakpoint, + new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint }, out drainEvidence)) @@ -4679,7 +4791,7 @@ internal static partial class NativeFrameCapture else Log("PROCESS_STATE_AFTER_DETACH unreadable error=" + Marshal.GetLastWin32Error()); } breakpointsRestored = processExited || AreBreakpointsRestored(cameraBreakpoint, - projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, + projectionBreakpoint, geometryProjectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint); Log("FINAL_BREAKPOINT_STATE restored=" + breakpointsRestored + " processExited=" + processExited); @@ -4811,7 +4923,7 @@ internal static partial class NativeFrameCapture } private static bool RestoreArmedBreakpointsWhileStopped(IntPtr process, - BreakpointInfo cameraBreakpoint, BreakpointInfo projectionBreakpoint, BreakpointInfo returnBreakpoint, + BreakpointInfo cameraBreakpoint, BreakpointInfo projectionBreakpoint, BreakpointInfo geometryProjectionBreakpoint, BreakpointInfo returnBreakpoint, BreakpointInfo atmosphereBreakpoint, BreakpointInfo beginSceneBreakpoint, BreakpointInfo endSceneBreakpoint, BreakpointInfo fogConsumerBreakpoint, BreakpointInfo presentBreakpoint, BreakpointInfo presentCompletionBreakpoint, @@ -4819,6 +4931,7 @@ internal static partial class NativeFrameCapture { return RestoreBreakpointWhileStopped(process, cameraBreakpoint) && RestoreBreakpointWhileStopped(process, projectionBreakpoint) + && RestoreBreakpointWhileStopped(process, geometryProjectionBreakpoint) && RestoreBreakpointWhileStopped(process, returnBreakpoint) && RestoreBreakpointWhileStopped(process, atmosphereBreakpoint) && RestoreBreakpointWhileStopped(process, beginSceneBreakpoint) @@ -4830,7 +4943,7 @@ internal static partial class NativeFrameCapture } private static bool AreBreakpointsRestored(BreakpointInfo cameraBreakpoint, - BreakpointInfo projectionBreakpoint, BreakpointInfo returnBreakpoint, + BreakpointInfo projectionBreakpoint, BreakpointInfo geometryProjectionBreakpoint, BreakpointInfo returnBreakpoint, BreakpointInfo atmosphereBreakpoint, BreakpointInfo beginSceneBreakpoint, BreakpointInfo endSceneBreakpoint, BreakpointInfo fogConsumerBreakpoint, BreakpointInfo presentBreakpoint, BreakpointInfo presentCompletionBreakpoint, @@ -4838,6 +4951,7 @@ internal static partial class NativeFrameCapture { return (cameraBreakpoint == null || !cameraBreakpoint.Armed) && (projectionBreakpoint == null || !projectionBreakpoint.Armed) + && (geometryProjectionBreakpoint == null || !geometryProjectionBreakpoint.Armed) && (returnBreakpoint == null || !returnBreakpoint.Armed) && (atmosphereBreakpoint == null || !atmosphereBreakpoint.Armed) && (beginSceneBreakpoint == null || !beginSceneBreakpoint.Armed) diff --git a/tools/native-frame-capture/README.md b/tools/native-frame-capture/README.md index 232cc1c..145bb8a 100644 --- a/tools/native-frame-capture/README.md +++ b/tools/native-frame-capture/README.md @@ -2,6 +2,8 @@ Capture modes attach only to the isolated GOG-compatible scratch copy of `Parkan - Iron Strategy`. Capture writes a PNG and a `fparkan-legacy-camera-v1` JSON file; boundary-trace mode writes a log and captures no pixels. `selector0_words` remains the local camera matrix at `camera+0x20`; the capture also records `effective_world_matrix_words` from `camera+0x60` with `effective_world_matrix_space: "world"` and a `parented_camera` marker. Both local and world matrices are sampled at the World3D render entry and reread at the projection-building boundary; only a same-thread, byte-compared snapshot paired with perspective values can produce usable renderer input. A passive `iron3d.dll` callsite probe records a path only when the active call arguments and path string are verified. It also samples `Terrain+0x421DC` after verifying the GOG module hash and the `EBP - [ESI+0x144]`, remainder by `[ESI+0x150]` instruction bytes. `atmosphere_seconds` is emitted only if the sampled `EDX` phase matches that arithmetic, the raw clock matches `World3D+0x32A38`, and sample, camera, and projection share the same render thread and generation. Otherwise it is `null`. This phase does not establish a general simulation-time, weather, or RNG value. +`geometry_projection` records the renderer's perspective values at the first indexed geometry draw (`Terrain+0x1D110`) using that world's camera matrix and renderer. It is emitted only when thread, generation, renderer, viewport, far plane and FOV match the later World3D boundary. The near plane may differ: the observed world draw uses `0.5`, while the restored camera boundary contains `5`. FParkan prefers this verified object when replaying the capture. The top-level projection fields remain the camera-boundary tuple used for selected-camera preflight. Older captures without this object still load, with `render_coverage captured_projection_unverified=1`; nearby surfaces in those replays can be clipped incorrectly. Capture a fresh frame to verify them. + The JSON keeps the `fparkan-legacy-camera-v1` schema and adds `native_fog_status`, diagnostic counts, and `native_fog`. The latter is an optional passive CPU-side snapshot of the effective sink flags/RGB/range plus the paired calls to the `Terrain+0x2F230` consumer. The flags byte is raw and not a decoded fog mode. Each draw record preserves the distance argument, raw bypass dword and effective low byte, source color, output pointers, and return address. Geometry at `EBP` is included only for the verified `Terrain+0x18A6A` caller with the expected geometry vtable and slot+0x14 target; other callers have `distance_geometry: null`. The fog object is `null` unless the samples match the same render thread, camera, generation, and projection matrix and agree on sink settings. `native_fog_status` is `paired`, `conflict`, `unpaired`, or `incomplete`; `native_fog_status_reason` explains an unpaired, conflicting, or incomplete result. The counts include processed, overflow, paired, rejected, other-thread, and matrix-mismatch calls. At most 512 calls are processed per camera generation; reaching the limit stops sampling and reports an incomplete result. These fields are additive: camera-input validation continues to accept older JSON without them. This probe observes CPU consumer state; it does not set fog or change GPU state. The capture helper expects an isolated copy at `target\shadow-probe\Parkan - Iron Strategy`; the original GOG installation stays untouched. For the default local install path, create that scratch copy from the repository root with: -- cgit v1.2.3