fix(capture): replay the projection used by world geometry
Docs Deploy / Build and Deploy MkDocs (push) Successful in 39s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 39s
This commit is contained in:
7 files changed
+409
-170
No files matched your search
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
//! `FParkan` render-planning composition root.
|
//! `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_animation::{AnimationTime, NodePoseBuffer};
|
||||||
use fparkan_assets::{
|
use fparkan_assets::{
|
||||||
load_material_archive_name_texture_rgba8_and_phase_with_document_from_root,
|
load_material_archive_name_texture_rgba8_and_phase_with_document_from_root,
|
||||||
@@ -79,7 +86,6 @@ use fparkan_terrain::{
|
|||||||
FullSurfaceMask, SurfaceQuery, TerrainLod, TerrainMaterialLayers, TerrainWorld,
|
FullSurfaceMask, SurfaceQuery, TerrainLod, TerrainMaterialLayers, TerrainWorld,
|
||||||
};
|
};
|
||||||
use fparkan_vfs::DirectoryVfs;
|
use fparkan_vfs::DirectoryVfs;
|
||||||
use serde::Deserialize;
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::num::NonZeroUsize;
|
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 {
|
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()
|
"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,
|
far_plane: 1.0,
|
||||||
field_of_view_radians: 1.0,
|
field_of_view_radians: 1.0,
|
||||||
atmosphere_seconds: Some(56.227),
|
atmosphere_seconds: Some(56.227),
|
||||||
|
geometry_projection: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -57,8 +57,13 @@ MSH-запрос со свободной preview-камерой; это не п
|
|||||||
Данные type 16 ещё не декодированы. Внутренний ракурс «Конструктора» после
|
Данные type 16 ещё не декодированы. Внутренний ракурс «Конструктора» после
|
||||||
настоящего входа игрока подтверждает исчезновение чёрной поверхности в проходе;
|
настоящего входа игрока подтверждает исчезновение чёрной поверхности в проходе;
|
||||||
наружные окна обеих миссий сверены на расстояниях около 60/85/110, а ближние
|
наружные окна обеих миссий сверены на расстояниях около 60/85/110, а ближние
|
||||||
окна — с внутренней стороны. Внутренние дальние ракурсы и clipping на краю
|
окна — с внутренней стороны. Окно «Конструктора» на правом краю кадра при
|
||||||
кадра ещё не приняты; общей готовности R2 эти пары не доказывают.
|
дистанции около 85 совпадает; внутренние дальние ракурсы и остальные случаи
|
||||||
|
отсечения ещё не приняты. Общей готовности R2 эти пары не доказывают.
|
||||||
|
|
||||||
|
Старые захваты без `geometry_projection` используют состояние камеры после
|
||||||
|
обхода мира, которое может отсекать близкие поверхности. Это отмечается
|
||||||
|
`captured_projection_unverified`; для приёмки таких поверхностей нужен новый захват.
|
||||||
|
|
||||||
Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX
|
Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX
|
||||||
среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state
|
среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state
|
||||||
|
|||||||
@@ -225,6 +225,17 @@ RVA `0x4EBE0` использует соглашение о вызовах `__std
|
|||||||
`right - left` на `bottom - top`. Размер окна превью закреплён: если при
|
`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 по прямоугольнику
|
Сравнивайте результат с фрагментом исходного PNG по прямоугольнику
|
||||||
`[left, top, right, bottom]`. В этом режиме `FreeFlightCamera` не создаётся,
|
`[left, top, right, bottom]`. В этом режиме `FreeFlightCamera` не создаётся,
|
||||||
но при повторной отрисовке обновляются окружение и проецируемые тени для
|
но при повторной отрисовке обновляются окружение и проецируемые тени для
|
||||||
|
|||||||
@@ -1009,6 +1009,12 @@ sun-ray queries поэтому используют исходные коорд
|
|||||||
она не равна одному лишь обратному преобразованию камеры.
|
она не равна одному лишь обратному преобразованию камеры.
|
||||||
В проверенном GOG контексте Ngi32 использовались `n=0.5`, `z=700`, `f=1.3`.
|
В проверенном GOG контексте Ngi32 использовались `n=0.5`, `z=700`, `f=1.3`.
|
||||||
|
|
||||||
|
Ближняя плоскость относится к конкретному проходу. После рисования движок
|
||||||
|
может вернуть прежние настройки камеры: захват только в конце мира видел
|
||||||
|
`n=5` и при воспроизведении вырезал пол перед игроком. Формат захвата поэтому
|
||||||
|
хранит проверенную проекцию вызова геометрии отдельно от состояния камеры;
|
||||||
|
подробности приведены в [описании кадра](../reference/render-frame.md#предпросмотр-по-захвату-камеры-d3d7).
|
||||||
|
|
||||||
У GOG `Terrain.dll + 0x7355c` хранит указатель на активную внешнюю камеру.
|
У GOG `Terrain.dll + 0x7355c` хранит указатель на активную внешнюю камеру.
|
||||||
Её selector 0 возвращает 16 слов начиная с `+0x20`, перенос находится в словах
|
Её selector 0 возвращает 16 слов начиная с `+0x20`, перенос находится в словах
|
||||||
3, 7, 11. Интерфейс `LoadCamera` расположен отдельно, по `+0x134` объекта.
|
3, 7, 11. Интерфейс `LoadCamera` расположен отдельно, по `+0x134` объекта.
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ internal static partial class NativeFrameCapture
|
|||||||
private const string Iron3dName = "iron3d.dll";
|
private const string Iron3dName = "iron3d.dll";
|
||||||
private const uint RenderGameRva = 0x13BD0;
|
private const uint RenderGameRva = 0x13BD0;
|
||||||
private const uint ProjectionSnapshotRva = 0x13CE4;
|
private const uint ProjectionSnapshotRva = 0x13CE4;
|
||||||
|
private const uint GeometryProjectionRva = 0x1D110;
|
||||||
private const uint RenderReturnRva = 0x13D7B;
|
private const uint RenderReturnRva = 0x13D7B;
|
||||||
private const uint AtmospherePhaseRva = 0x421DC;
|
private const uint AtmospherePhaseRva = 0x421DC;
|
||||||
private const uint FogConsumerRva = 0x2F230;
|
private const uint FogConsumerRva = 0x2F230;
|
||||||
@@ -101,6 +102,9 @@ internal static partial class NativeFrameCapture
|
|||||||
public uint ProjectionGeneration;
|
public uint ProjectionGeneration;
|
||||||
public uint ProjectionThreadId;
|
public uint ProjectionThreadId;
|
||||||
public ProjectionReadback Projection;
|
public ProjectionReadback Projection;
|
||||||
|
public ProjectionReadback GeometryProjection;
|
||||||
|
public uint GeometryProjectionGeneration;
|
||||||
|
public uint GeometryProjectionThreadId;
|
||||||
public uint CameraWorldGameTimeWord;
|
public uint CameraWorldGameTimeWord;
|
||||||
public bool CameraWorldGameTimeWordReadable;
|
public bool CameraWorldGameTimeWordReadable;
|
||||||
public uint WorldGameTimeWord;
|
public uint WorldGameTimeWord;
|
||||||
@@ -738,6 +742,44 @@ internal static partial class NativeFrameCapture
|
|||||||
return match;
|
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,
|
private static bool VerifyAtmospherePhaseBoundary(IntPtr process, uint terrainBase,
|
||||||
out byte firstByte, out string evidence)
|
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(" \"far_plane\": " + projection.Far.ToString("R", CultureInfo.InvariantCulture) + ",");
|
||||||
json.AppendLine(" \"field_of_view_radians\": " + projection.Fov.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) + ",");
|
json.AppendLine(" \"projection_mode_byte\": " + projection.Mode.ToString(CultureInfo.InvariantCulture) + ",");
|
||||||
|
AppendGeometryProjectionJson(json, frame);
|
||||||
if (frame.SelectedCamera == null) json.AppendLine(" \"selected_camera\": null,");
|
if (frame.SelectedCamera == null) json.AppendLine(" \"selected_camera\": null,");
|
||||||
else
|
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.");
|
if (IntPtr.Size != 4) throw new InvalidOperationException("This helper must be compiled and run as x86.");
|
||||||
SelfCheckCameraInput();
|
SelfCheckCameraInput();
|
||||||
SelfCheckSelectedCameraWarmup();
|
SelfCheckSelectedCameraWarmup();
|
||||||
|
SelfCheckGeometryProjection();
|
||||||
SelfCheckNativeFogPairing();
|
SelfCheckNativeFogPairing();
|
||||||
SelfCheckRenderInvocationGates();
|
SelfCheckRenderInvocationGates();
|
||||||
SelfCheckDebugEventHandleOwnership();
|
SelfCheckDebugEventHandleOwnership();
|
||||||
@@ -2306,6 +2350,34 @@ internal static partial class NativeFrameCapture
|
|||||||
return SelfCheckReadback();
|
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()
|
private static void SelfCheckNativeFogPairing()
|
||||||
{
|
{
|
||||||
if (FogConsumerRva != 0x2F230 || FogConsumerVtableRva != 0x6527C
|
if (FogConsumerRva != 0x2F230 || FogConsumerVtableRva != 0x6527C
|
||||||
@@ -2945,6 +3017,7 @@ internal static partial class NativeFrameCapture
|
|||||||
string terrainModuleSha256 = null;
|
string terrainModuleSha256 = null;
|
||||||
BreakpointInfo cameraBreakpoint = null;
|
BreakpointInfo cameraBreakpoint = null;
|
||||||
BreakpointInfo projectionBreakpoint = null;
|
BreakpointInfo projectionBreakpoint = null;
|
||||||
|
BreakpointInfo geometryProjectionBreakpoint = null;
|
||||||
BreakpointInfo returnBreakpoint = null;
|
BreakpointInfo returnBreakpoint = null;
|
||||||
BreakpointInfo atmosphereBreakpoint = null;
|
BreakpointInfo atmosphereBreakpoint = null;
|
||||||
BreakpointInfo beginSceneBreakpoint = null;
|
BreakpointInfo beginSceneBreakpoint = null;
|
||||||
@@ -3128,6 +3201,13 @@ internal static partial class NativeFrameCapture
|
|||||||
throw new InvalidOperationException("Loaded Terrain hash mismatch: " + hash);
|
throw new InvalidOperationException("Loaded Terrain hash mismatch: " + hash);
|
||||||
terrainBase = imageBase;
|
terrainBase = imageBase;
|
||||||
terrainModuleSha256 = hash;
|
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;
|
byte atmosphereByte;
|
||||||
string atmosphereEvidence;
|
string atmosphereEvidence;
|
||||||
if (!VerifyAtmospherePhaseBoundary(process, terrainBase, out atmosphereByte, out atmosphereEvidence))
|
if (!VerifyAtmospherePhaseBoundary(process, terrainBase, out atmosphereByte, out atmosphereEvidence))
|
||||||
@@ -3593,6 +3673,9 @@ internal static partial class NativeFrameCapture
|
|||||||
hitBreakpoint = cameraBreakpoint;
|
hitBreakpoint = cameraBreakpoint;
|
||||||
else if (projectionBreakpoint != null && projectionBreakpoint.Armed && exceptionAddress == projectionBreakpoint.Address)
|
else if (projectionBreakpoint != null && projectionBreakpoint.Armed && exceptionAddress == projectionBreakpoint.Address)
|
||||||
hitBreakpoint = projectionBreakpoint;
|
hitBreakpoint = projectionBreakpoint;
|
||||||
|
else if (geometryProjectionBreakpoint != null && geometryProjectionBreakpoint.Armed
|
||||||
|
&& exceptionAddress == geometryProjectionBreakpoint.Address)
|
||||||
|
hitBreakpoint = geometryProjectionBreakpoint;
|
||||||
else if (returnBreakpoint != null && returnBreakpoint.Armed && exceptionAddress == returnBreakpoint.Address)
|
else if (returnBreakpoint != null && returnBreakpoint.Armed && exceptionAddress == returnBreakpoint.Address)
|
||||||
hitBreakpoint = returnBreakpoint;
|
hitBreakpoint = returnBreakpoint;
|
||||||
else if (atmosphereBreakpoint != null && atmosphereBreakpoint.Armed && exceptionAddress == atmosphereBreakpoint.Address)
|
else if (atmosphereBreakpoint != null && atmosphereBreakpoint.Armed && exceptionAddress == atmosphereBreakpoint.Address)
|
||||||
@@ -3666,6 +3749,12 @@ internal static partial class NativeFrameCapture
|
|||||||
&& !RestoreBreakpointWhileStopped(process, fogConsumerBreakpoint))
|
&& !RestoreBreakpointWhileStopped(process, fogConsumerBreakpoint))
|
||||||
throw new InvalidOperationException("Could not disarm the previous generation's native fog boundary.");
|
throw new InvalidOperationException("Could not disarm the previous generation's native fog boundary.");
|
||||||
frame.CameraGeneration++;
|
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);
|
ResetNativeFogForGeneration(frame);
|
||||||
frame.CameraThreadId = eventTid;
|
frame.CameraThreadId = eventTid;
|
||||||
frame.CameraWorldGameTimeWordReadable = false;
|
frame.CameraWorldGameTimeWordReadable = false;
|
||||||
@@ -3754,6 +3843,25 @@ internal static partial class NativeFrameCapture
|
|||||||
frame, eventTid, ref fogConsumerBreakpoint);
|
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)
|
else if (hitBreakpoint == projectionBreakpoint)
|
||||||
{
|
{
|
||||||
string candidateProjectionEvidence = "candidate preconditions not met";
|
string candidateProjectionEvidence = "candidate preconditions not met";
|
||||||
@@ -3978,7 +4086,7 @@ internal static partial class NativeFrameCapture
|
|||||||
{
|
{
|
||||||
remote = StartRemoteSurfaceProbe(process, eventTid, ngiBase,
|
remote = StartRemoteSurfaceProbe(process, eventTid, ngiBase,
|
||||||
returnBreakpoint, frame,
|
returnBreakpoint, frame,
|
||||||
new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint,
|
new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint,
|
||||||
returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
|
returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
|
||||||
fogConsumerBreakpoint,
|
fogConsumerBreakpoint,
|
||||||
endSceneBreakpoint, presentBreakpoint,
|
endSceneBreakpoint, presentBreakpoint,
|
||||||
@@ -4126,7 +4234,7 @@ internal static partial class NativeFrameCapture
|
|||||||
{
|
{
|
||||||
remote = StartRemoteSurfaceProbe(process, eventTid, ngiBase,
|
remote = StartRemoteSurfaceProbe(process, eventTid, ngiBase,
|
||||||
endSceneBreakpoint, frame,
|
endSceneBreakpoint, frame,
|
||||||
new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint,
|
new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint,
|
||||||
returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
|
returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
|
||||||
endSceneBreakpoint,
|
endSceneBreakpoint,
|
||||||
fogConsumerBreakpoint, presentBreakpoint,
|
fogConsumerBreakpoint, presentBreakpoint,
|
||||||
@@ -4174,7 +4282,7 @@ internal static partial class NativeFrameCapture
|
|||||||
{
|
{
|
||||||
remote = StartRemoteBoundaryReadback(process, eventTid, ngiBase,
|
remote = StartRemoteBoundaryReadback(process, eventTid, ngiBase,
|
||||||
endSceneBreakpoint, frame, outputPath,
|
endSceneBreakpoint, frame, outputPath,
|
||||||
new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint,
|
new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint,
|
||||||
returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
|
returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
|
||||||
endSceneBreakpoint,
|
endSceneBreakpoint,
|
||||||
fogConsumerBreakpoint, presentBreakpoint,
|
fogConsumerBreakpoint, presentBreakpoint,
|
||||||
@@ -4392,6 +4500,8 @@ internal static partial class NativeFrameCapture
|
|||||||
if (hitBreakpoint == fogConsumerBreakpoint)
|
if (hitBreakpoint == fogConsumerBreakpoint)
|
||||||
rearmAfterStep = !frame.NativeFogSamplingStopped
|
rearmAfterStep = !frame.NativeFogSamplingStopped
|
||||||
&& !frame.NativeFogSampleLimitExceeded;
|
&& !frame.NativeFogSampleLimitExceeded;
|
||||||
|
if (hitBreakpoint == geometryProjectionBreakpoint)
|
||||||
|
rearmAfterStep = frame.GeometryProjection == null;
|
||||||
BeginSingleStep(process, eventTid, hitBreakpoint, stoppedContext,
|
BeginSingleStep(process, eventTid, hitBreakpoint, stoppedContext,
|
||||||
out pendingStep, rearmAfterStep);
|
out pendingStep, rearmAfterStep);
|
||||||
Log("SINGLE_STEP_STARTED boundary=" + hitBreakpoint.Name + " tid=" + eventTid);
|
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.";
|
captureFailure = captureFailure ?? "No matching native camera/projection/present frame completed before the bounded timeout.";
|
||||||
Log("NO_FRAME_CAPTURE bounded timeout at stopped debug event");
|
Log("NO_FRAME_CAPTURE bounded timeout at stopped debug event");
|
||||||
if (!RestoreArmedBreakpointsWhileStopped(process,
|
if (!RestoreArmedBreakpointsWhileStopped(process,
|
||||||
cameraBreakpoint, projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint,
|
cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, returnBreakpoint, atmosphereBreakpoint,
|
||||||
beginSceneBreakpoint,
|
beginSceneBreakpoint,
|
||||||
endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint,
|
endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint,
|
||||||
presentCompletionBreakpoint, missionBreakpoint))
|
presentCompletionBreakpoint, missionBreakpoint))
|
||||||
@@ -4484,6 +4594,7 @@ internal static partial class NativeFrameCapture
|
|||||||
&& !fatal && !processExited
|
&& !fatal && !processExited
|
||||||
&& (cameraBreakpoint != null && cameraBreakpoint.Armed
|
&& (cameraBreakpoint != null && cameraBreakpoint.Armed
|
||||||
|| projectionBreakpoint != null && projectionBreakpoint.Armed
|
|| projectionBreakpoint != null && projectionBreakpoint.Armed
|
||||||
|
|| geometryProjectionBreakpoint != null && geometryProjectionBreakpoint.Armed
|
||||||
|| returnBreakpoint != null && returnBreakpoint.Armed
|
|| returnBreakpoint != null && returnBreakpoint.Armed
|
||||||
|| atmosphereBreakpoint != null && atmosphereBreakpoint.Armed
|
|| atmosphereBreakpoint != null && atmosphereBreakpoint.Armed
|
||||||
|| beginSceneBreakpoint != null && beginSceneBreakpoint.Armed
|
|| beginSceneBreakpoint != null && beginSceneBreakpoint.Armed
|
||||||
@@ -4494,7 +4605,7 @@ internal static partial class NativeFrameCapture
|
|||||||
|| missionBreakpoint != null && missionBreakpoint.Armed))
|
|| missionBreakpoint != null && missionBreakpoint.Armed))
|
||||||
{
|
{
|
||||||
if (!RestoreArmedBreakpointsWhileStopped(process,
|
if (!RestoreArmedBreakpointsWhileStopped(process,
|
||||||
cameraBreakpoint, projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint,
|
cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, returnBreakpoint, atmosphereBreakpoint,
|
||||||
beginSceneBreakpoint,
|
beginSceneBreakpoint,
|
||||||
endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint,
|
endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint,
|
||||||
presentCompletionBreakpoint, missionBreakpoint))
|
presentCompletionBreakpoint, missionBreakpoint))
|
||||||
@@ -4543,6 +4654,7 @@ internal static partial class NativeFrameCapture
|
|||||||
|| cameraMutationUnrestored || eventOutstanding
|
|| cameraMutationUnrestored || eventOutstanding
|
||||||
|| cameraBreakpoint != null && cameraBreakpoint.Armed
|
|| cameraBreakpoint != null && cameraBreakpoint.Armed
|
||||||
|| projectionBreakpoint != null && projectionBreakpoint.Armed
|
|| projectionBreakpoint != null && projectionBreakpoint.Armed
|
||||||
|
|| geometryProjectionBreakpoint != null && geometryProjectionBreakpoint.Armed
|
||||||
|| returnBreakpoint != null && returnBreakpoint.Armed
|
|| returnBreakpoint != null && returnBreakpoint.Armed
|
||||||
|| atmosphereBreakpoint != null && atmosphereBreakpoint.Armed
|
|| atmosphereBreakpoint != null && atmosphereBreakpoint.Armed
|
||||||
|| beginSceneBreakpoint != null && beginSceneBreakpoint.Armed
|
|| beginSceneBreakpoint != null && beginSceneBreakpoint.Armed
|
||||||
@@ -4572,7 +4684,7 @@ internal static partial class NativeFrameCapture
|
|||||||
if (DrainFrameTerminationEvents(process, eventBuffer, processId,
|
if (DrainFrameTerminationEvents(process, eventBuffer, processId,
|
||||||
ref eventOutstanding, ref outstandingPid, ref outstandingTid, ref outstandingCode,
|
ref eventOutstanding, ref outstandingPid, ref outstandingTid, ref outstandingCode,
|
||||||
ref outstandingContinueStatus, ref outstandingFileHandleClosed,
|
ref outstandingContinueStatus, ref outstandingFileHandleClosed,
|
||||||
new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, returnBreakpoint,
|
new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, returnBreakpoint,
|
||||||
atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint,
|
atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint,
|
||||||
presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint },
|
presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint },
|
||||||
out drainEvidence))
|
out drainEvidence))
|
||||||
@@ -4641,7 +4753,7 @@ internal static partial class NativeFrameCapture
|
|||||||
if (DrainFrameTerminationEvents(process, eventBuffer, processId,
|
if (DrainFrameTerminationEvents(process, eventBuffer, processId,
|
||||||
ref eventOutstanding, ref outstandingPid, ref outstandingTid, ref outstandingCode,
|
ref eventOutstanding, ref outstandingPid, ref outstandingTid, ref outstandingCode,
|
||||||
ref outstandingContinueStatus, ref outstandingFileHandleClosed,
|
ref outstandingContinueStatus, ref outstandingFileHandleClosed,
|
||||||
new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, returnBreakpoint,
|
new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, returnBreakpoint,
|
||||||
atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint,
|
atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint,
|
||||||
presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint },
|
presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint },
|
||||||
out drainEvidence))
|
out drainEvidence))
|
||||||
@@ -4679,7 +4791,7 @@ internal static partial class NativeFrameCapture
|
|||||||
else Log("PROCESS_STATE_AFTER_DETACH unreadable error=" + Marshal.GetLastWin32Error());
|
else Log("PROCESS_STATE_AFTER_DETACH unreadable error=" + Marshal.GetLastWin32Error());
|
||||||
}
|
}
|
||||||
breakpointsRestored = processExited || AreBreakpointsRestored(cameraBreakpoint,
|
breakpointsRestored = processExited || AreBreakpointsRestored(cameraBreakpoint,
|
||||||
projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
|
projectionBreakpoint, geometryProjectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
|
||||||
endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint,
|
endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint,
|
||||||
presentCompletionBreakpoint, missionBreakpoint);
|
presentCompletionBreakpoint, missionBreakpoint);
|
||||||
Log("FINAL_BREAKPOINT_STATE restored=" + breakpointsRestored + " processExited=" + processExited);
|
Log("FINAL_BREAKPOINT_STATE restored=" + breakpointsRestored + " processExited=" + processExited);
|
||||||
@@ -4811,7 +4923,7 @@ internal static partial class NativeFrameCapture
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static bool RestoreArmedBreakpointsWhileStopped(IntPtr process,
|
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 atmosphereBreakpoint, BreakpointInfo beginSceneBreakpoint, BreakpointInfo endSceneBreakpoint,
|
||||||
BreakpointInfo fogConsumerBreakpoint,
|
BreakpointInfo fogConsumerBreakpoint,
|
||||||
BreakpointInfo presentBreakpoint, BreakpointInfo presentCompletionBreakpoint,
|
BreakpointInfo presentBreakpoint, BreakpointInfo presentCompletionBreakpoint,
|
||||||
@@ -4819,6 +4931,7 @@ internal static partial class NativeFrameCapture
|
|||||||
{
|
{
|
||||||
return RestoreBreakpointWhileStopped(process, cameraBreakpoint)
|
return RestoreBreakpointWhileStopped(process, cameraBreakpoint)
|
||||||
&& RestoreBreakpointWhileStopped(process, projectionBreakpoint)
|
&& RestoreBreakpointWhileStopped(process, projectionBreakpoint)
|
||||||
|
&& RestoreBreakpointWhileStopped(process, geometryProjectionBreakpoint)
|
||||||
&& RestoreBreakpointWhileStopped(process, returnBreakpoint)
|
&& RestoreBreakpointWhileStopped(process, returnBreakpoint)
|
||||||
&& RestoreBreakpointWhileStopped(process, atmosphereBreakpoint)
|
&& RestoreBreakpointWhileStopped(process, atmosphereBreakpoint)
|
||||||
&& RestoreBreakpointWhileStopped(process, beginSceneBreakpoint)
|
&& RestoreBreakpointWhileStopped(process, beginSceneBreakpoint)
|
||||||
@@ -4830,7 +4943,7 @@ internal static partial class NativeFrameCapture
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static bool AreBreakpointsRestored(BreakpointInfo cameraBreakpoint,
|
private static bool AreBreakpointsRestored(BreakpointInfo cameraBreakpoint,
|
||||||
BreakpointInfo projectionBreakpoint, BreakpointInfo returnBreakpoint,
|
BreakpointInfo projectionBreakpoint, BreakpointInfo geometryProjectionBreakpoint, BreakpointInfo returnBreakpoint,
|
||||||
BreakpointInfo atmosphereBreakpoint, BreakpointInfo beginSceneBreakpoint, BreakpointInfo endSceneBreakpoint,
|
BreakpointInfo atmosphereBreakpoint, BreakpointInfo beginSceneBreakpoint, BreakpointInfo endSceneBreakpoint,
|
||||||
BreakpointInfo fogConsumerBreakpoint,
|
BreakpointInfo fogConsumerBreakpoint,
|
||||||
BreakpointInfo presentBreakpoint, BreakpointInfo presentCompletionBreakpoint,
|
BreakpointInfo presentBreakpoint, BreakpointInfo presentCompletionBreakpoint,
|
||||||
@@ -4838,6 +4951,7 @@ internal static partial class NativeFrameCapture
|
|||||||
{
|
{
|
||||||
return (cameraBreakpoint == null || !cameraBreakpoint.Armed)
|
return (cameraBreakpoint == null || !cameraBreakpoint.Armed)
|
||||||
&& (projectionBreakpoint == null || !projectionBreakpoint.Armed)
|
&& (projectionBreakpoint == null || !projectionBreakpoint.Armed)
|
||||||
|
&& (geometryProjectionBreakpoint == null || !geometryProjectionBreakpoint.Armed)
|
||||||
&& (returnBreakpoint == null || !returnBreakpoint.Armed)
|
&& (returnBreakpoint == null || !returnBreakpoint.Armed)
|
||||||
&& (atmosphereBreakpoint == null || !atmosphereBreakpoint.Armed)
|
&& (atmosphereBreakpoint == null || !atmosphereBreakpoint.Armed)
|
||||||
&& (beginSceneBreakpoint == null || !beginSceneBreakpoint.Armed)
|
&& (beginSceneBreakpoint == null || !beginSceneBreakpoint.Armed)
|
||||||
|
|||||||
@@ -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.
|
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 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:
|
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:
|
||||||
|
|||||||
Reference in new issue
Block a user