fix(capture): replay the projection used by world geometry
Docs Deploy / Build and Deploy MkDocs (push) Successful in 39s

This commit is contained in:
Valentin Popov committed 2026-10-31 23:13:22 +04:00
1 parent 8a2c3e9840
commit 853d9b115a
7 files changed
+409 -170

No files matched your search

+251
View File
@@ -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<bool>,
pub(super) selector0_words: [u32; 16],
pub(super) parented_camera: Option<bool>,
pub(super) effective_world_matrix_space: Option<String>,
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<f32>,
pub(super) geometry_projection: Option<GeometryProjection>,
}
#[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<f32>,
capture: Option<&LegacyCameraCapture>,
) -> Option<f32> {
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<CapturedViewport, String> {
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<PreviewCameraFrame, String> {
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<LegacyCameraCapture, String> {
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<LegacyCameraCapture, String> {
// 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<String, String> {
let decode_utf16 = |words: Vec<u16>| {
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::<f32>()
};
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}"
);
}
}
}
+8 -158
View File
@@ -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<bool>,
selector0_words: [u32; 16],
parented_camera: Option<bool>,
effective_world_matrix_space: Option<String>,
effective_world_matrix_words: Option<[u32; 16]>,
viewport: [i32; 4],
near_plane: f32,
far_plane: f32,
field_of_view_radians: f32,
atmosphere_seconds: Option<f32>,
}
fn selected_atmosphere_seconds(
command_line: Option<f32>,
capture: Option<&LegacyCameraCapture>,
) -> Option<f32> {
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<CapturedViewport, String> {
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<PreviewCameraFrame, String> {
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<LegacyCameraCapture, String> {
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<LegacyCameraCapture, String> {
// 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<String, String> {
let decode_utf16 = |words: Vec<u16>| {
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 <path> --mission <path> [--frames <n>] [--atmosphere-seconds <seconds>] [--control-clock-ms <u32>] [--validation] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>] [--readback-sequence-out <directory>]\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!(
+7 -2
View File
@@ -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
+11
View File
@@ -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` не создаётся,
но при повторной отрисовке обновляются окружение и проецируемые тени для
+6
View File
@@ -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` объекта.
+124 -10
View File
@@ -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<string, object> parsed = new System.Web.Script.Serialization.JavaScriptSerializer()
.Deserialize<Dictionary<string, object>>(json.ToString());
Dictionary<string, object> projection = (Dictionary<string, object>)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)
+2
View File
@@ -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: