fix(render): animate sky layers on the native UV clock
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s

This commit is contained in:
Valentin Popov committed 2026-11-01 00:26:45 +04:00
1 parent d7c18d4c1a
commit 8fab4b44b8
8 files changed
+285 -13

No files matched your search

+17
View File
@@ -15,9 +15,16 @@ pub(super) struct LegacyCameraCapture {
pub(super) far_plane: f32, pub(super) far_plane: f32,
pub(super) field_of_view_radians: f32, pub(super) field_of_view_radians: f32,
pub(super) atmosphere_seconds: Option<f32>, pub(super) atmosphere_seconds: Option<f32>,
pub(super) sky_uv_clock: Option<SkyUvClock>,
pub(super) geometry_projection: Option<GeometryProjection>, pub(super) geometry_projection: Option<GeometryProjection>,
} }
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub(super) struct SkyUvClock {
pub(super) verified: bool,
pub(super) elapsed_milliseconds: u32,
}
#[derive(Clone, Debug, Deserialize, PartialEq)] #[derive(Clone, Debug, Deserialize, PartialEq)]
pub(super) struct GeometryProjection { pub(super) struct GeometryProjection {
verified: bool, verified: bool,
@@ -118,6 +125,9 @@ pub(super) fn load_legacy_camera_capture(
if capture.geometry_projection.is_none() { 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)"); eprintln!("render_coverage captured_projection_unverified=1 (capture has no geometry_projection; using the recorded camera boundary, which can clip nearby surfaces)");
} }
if capture.sky_uv_clock.is_none() {
eprintln!("render_coverage captured_sky_phase_unverified=1 (capture has no sky UV clock; using replay elapsed time)");
}
Ok(capture) Ok(capture)
} }
@@ -143,6 +153,13 @@ pub(super) fn parse_legacy_camera_capture(bytes: &[u8]) -> Result<LegacyCameraCa
if capture.render_input_usable == Some(false) { if capture.render_input_usable == Some(false) {
return Err("legacy camera capture is marked render_input_usable=false".to_string()); return Err("legacy camera capture is marked render_input_usable=false".to_string());
} }
if capture
.sky_uv_clock
.as_ref()
.is_some_and(|clock| !clock.verified)
{
return Err("legacy camera sky UV clock is unverified".to_string());
}
if capture if capture
.atmosphere_seconds .atmosphere_seconds
.is_some_and(|seconds| !seconds.is_finite() || seconds < 0.0) .is_some_and(|seconds| !seconds.is_finite() || seconds < 0.0)
+59 -1
View File
@@ -141,6 +141,10 @@ fn run(args: &[String]) -> Result<String, String> {
.map(load_legacy_camera_capture) .map(load_legacy_camera_capture)
.transpose()?; .transpose()?;
let atmosphere_seconds = selected_atmosphere_seconds(args.atmosphere_seconds, camera.as_ref()); let atmosphere_seconds = selected_atmosphere_seconds(args.atmosphere_seconds, camera.as_ref());
let captured_sky_uv_ms = camera
.as_ref()
.and_then(|c| c.sky_uv_clock.as_ref())
.map(|c| c.elapsed_milliseconds);
let hold_schedule_phase = camera.is_some() && atmosphere_seconds.is_some(); let hold_schedule_phase = camera.is_some() && atmosphere_seconds.is_some();
let terrain_materials_duration_started = Instant::now(); let terrain_materials_duration_started = Instant::now();
let preview = static_preview_mesh_and_materials( let preview = static_preview_mesh_and_materials(
@@ -159,12 +163,15 @@ fn run(args: &[String]) -> Result<String, String> {
// sky to the selected map (there are many `sky.ske` files in the campaign // sky to the selected map (there are many `sky.ske` files in the campaign
// tree). // tree).
let sky_started = Instant::now(); let sky_started = Instant::now();
let environment = load_environment( let mut environment = load_environment(
&args.root, &args.root,
&args.mission, &args.mission,
atmosphere_seconds, atmosphere_seconds,
hold_schedule_phase, hold_schedule_phase,
)?; )?;
if let Some(environment) = &mut environment {
environment.fixed_sky_uv_ms = captured_sky_uv_ms;
}
let sky_duration = sky_started.elapsed(); let sky_duration = sky_started.elapsed();
let audio = match audio::GameAudio::new(Arc::new(DirectoryVfs::new(&args.root)), &args.mission) let audio = match audio::GameAudio::new(Arc::new(DirectoryVfs::new(&args.root)), &args.mission)
{ {
@@ -4188,6 +4195,7 @@ struct DynamicEnvironment {
time_seconds: f32, time_seconds: f32,
fixed_schedule_seconds: Option<f32>, fixed_schedule_seconds: Option<f32>,
fixed_phase_reported: bool, fixed_phase_reported: bool,
fixed_sky_uv_ms: Option<u32>,
} }
struct DynamicEnvironmentFrame { struct DynamicEnvironmentFrame {
@@ -4232,6 +4240,7 @@ impl DynamicEnvironment {
fn update_cpu( fn update_cpu(
&mut self, &mut self,
dt_seconds: f32, dt_seconds: f32,
clock_ms: u32,
camera: &PreviewCameraFrame, camera: &PreviewCameraFrame,
aspect: f32, aspect: f32,
viewport: [f32; 2], viewport: [f32; 2],
@@ -4259,6 +4268,7 @@ impl DynamicEnvironment {
} }
let atmosphere = self.schedule.sample(absolute_time_seconds); let atmosphere = self.schedule.sample(absolute_time_seconds);
let mut sky_mesh = self.sky.mesh().clone(); let mut sky_mesh = self.sky.mesh().clone();
sky_mesh.update_uvs(self.fixed_sky_uv_ms.unwrap_or(clock_ms));
let sky_frame = let sky_frame =
SkyFrame::from_atmosphere(&atmosphere, &self.materials, absolute_time_seconds); SkyFrame::from_atmosphere(&atmosphere, &self.materials, absolute_time_seconds);
sky_mesh.update_colors(&sky_frame.sky); sky_mesh.update_colors(&sky_frame.sky);
@@ -4465,6 +4475,7 @@ fn load_environment(
time_seconds, time_seconds,
fixed_schedule_seconds, fixed_schedule_seconds,
fixed_phase_reported: false, fixed_phase_reported: false,
fixed_sky_uv_ms: None,
})) }))
} }
@@ -10187,6 +10198,7 @@ impl StaticVulkanApp {
let environment = if let Some(environment) = self.environment.as_mut() { let environment = if let Some(environment) = self.environment.as_mut() {
Some(environment.update_cpu( Some(environment.update_cpu(
clock.delta_seconds, clock.delta_seconds,
clock.time_ms,
&camera, &camera,
aspect, aspect,
viewport, viewport,
@@ -13562,6 +13574,7 @@ mod tests {
time_seconds: 0.0, time_seconds: 0.0,
fixed_schedule_seconds: None, fixed_schedule_seconds: None,
fixed_phase_reported: false, fixed_phase_reported: false,
fixed_sky_uv_ms: None,
}; };
let mut land = fparkan_terrain_format::LandMeshDocument { let mut land = fparkan_terrain_format::LandMeshDocument {
@@ -13811,6 +13824,34 @@ mod tests {
app.pending_scene_update.as_ref().unwrap().clock.time_ms, app.pending_scene_update.as_ref().unwrap().clock.time_ms,
500 500
); );
assert_eq!(
app.pending_scene_update
.as_ref()
.unwrap()
.environment
.as_ref()
.unwrap()
.sky_mesh
.vertices[0]
.uv[2],
[0.01, 0.0]
);
app.environment.as_mut().unwrap().fixed_sky_uv_ms = Some(25_000);
next.id = SceneSampleId::Live(2);
next.time_ms = 10_000;
app.prepare_scene_update(next, [1280.0, 720.0]).unwrap();
assert_eq!(
app.pending_scene_update
.as_ref()
.unwrap()
.environment
.as_ref()
.unwrap()
.sky_mesh
.vertices[0]
.uv[2],
[0.5, 0.0]
);
} }
#[test] #[test]
@@ -15633,6 +15674,22 @@ mod tests {
); );
} }
#[test]
fn captured_sky_uv_phase_requires_verified_clock() {
let mut value: serde_json::Value =
serde_json::from_slice(include_bytes!("../testdata/legacy-camera-parented.json"))
.unwrap();
value["sky_uv_clock"] =
serde_json::json!({"verified": true, "elapsed_milliseconds": 4051271258_u32});
let capture = parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).unwrap();
assert_eq!(
capture.sky_uv_clock.unwrap().elapsed_milliseconds,
4051271258
);
value["sky_uv_clock"]["verified"] = serde_json::json!(false);
assert!(parse_legacy_camera_capture(&serde_json::to_vec(&value).unwrap()).is_err());
}
#[test] #[test]
fn captured_atmosphere_is_default_and_cli_time_takes_precedence() { fn captured_atmosphere_is_default_and_cli_time_takes_precedence() {
let capture = LegacyCameraCapture { let capture = LegacyCameraCapture {
@@ -15648,6 +15705,7 @@ mod tests {
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, geometry_projection: None,
sky_uv_clock: None,
}; };
assert_eq!( assert_eq!(
+38
View File
@@ -328,6 +328,24 @@ pub struct SkyMesh {
} }
impl SkyMesh { impl SkyMesh {
/// Updates native nebula, star and cloud scroll from the sky object's elapsed milliseconds.
pub fn update_uvs(&mut self, elapsed_ms: u32) {
let offsets = [
[(elapsed_ms % 300_000) as f32 * 3.333_333_3e-6; 2],
[(elapsed_ms % 100_000) as f32 * 1.0e-5; 2],
[(elapsed_ms % 50_000) as f32 * 2.0e-5, 0.0],
];
let inverse_radius = self.radius.recip();
for vertex in &mut self.vertices {
for stage in 0..3 {
let scale = inverse_radius * SKY_UV_SCALES[stage];
vertex.uv[stage] = std::array::from_fn(|axis| {
scale * vertex.position[axis] + offsets[stage][axis]
});
}
}
}
/// Number of triangles in the mesh. /// Number of triangles in the mesh.
#[must_use] #[must_use]
pub fn triangle_count(&self) -> usize { pub fn triangle_count(&self) -> usize {
@@ -1786,6 +1804,26 @@ mod tests {
} }
} }
#[test]
fn scrolling_uvs_match_native_periods_and_wrapped_elapsed_clock() {
let mut mesh = SkyGeometryConfig::default().try_mesh(&sample()).unwrap();
let topology = mesh.indices.clone();
for elapsed in [0, 25_000, 49_999, 50_000, 100_000, 300_000, 4_051_271_258] {
mesh.update_uvs(elapsed);
let zenith = mesh.vertices[0].uv;
assert_eq!(zenith[0], [(elapsed % 300_000) as f32 * 3.333_333_3e-6; 2]);
assert_eq!(zenith[1], [(elapsed % 100_000) as f32 * 1e-5; 2]);
assert_eq!(zenith[2], [(elapsed % 50_000) as f32 * 2e-5, 0.0]);
}
// Live GOG sky render: now 265584022, object origin 509280060.
mesh.update_uvs(265_584_022_u32.wrapping_sub(509_280_060));
assert!((mesh.vertices[0].uv[2][0] - 0.42516).abs() < 1e-7);
let once = mesh.vertices.clone();
mesh.update_uvs(4_051_271_258);
assert_eq!(mesh.vertices, once, "updates must not accumulate drift");
assert_eq!(mesh.indices, topology);
}
#[test] #[test]
fn geometry_matches_original_counts_positions_uv_and_winding() { fn geometry_matches_original_counts_positions_uv_and_winding() {
let config = SkyGeometryConfig::new(10.0, std::f32::consts::FRAC_PI_2, 2.0, 4, 3); let config = SkyGeometryConfig::new(10.0, std::f32::consts::FRAC_PI_2, 2.0, 4, 3);
+4
View File
@@ -65,6 +65,10 @@ MSH-запрос со свободной preview-камерой; это не п
обхода мира, которое может отсекать близкие поверхности. Это отмечается обхода мира, которое может отсекать близкие поверхности. Это отмечается
`captured_projection_unverified`; для приёмки таких поверхностей нужен новый захват. `captured_projection_unverified`; для приёмки таких поверхностей нужен новый захват.
Захваты без `sky_uv_clock` не подтверждают начальное положение облаков,
звёзд и фона. Replay использует elapsed clock приложения и считает
это как `captured_sky_phase_unverified`; для приёмки неба нужен новый захват.
Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX
среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state
log на минимальных сценах с одним типом материала. log на минимальных сценах с одним типом материала.
+25
View File
@@ -149,6 +149,31 @@ gradient и clouds row 2. Для nebula/stars/clouds используются UV
directional RGB из `sky.packed[0]`. Это четыре фиксированных pass, а не три directional RGB из `sky.packed[0]`. Это четыре фиксированных pass, а не три
независимых цветных слоя. независимых цветных слоя.
Все три текстурных слоя движутся. Обновление GOG `CSky`
(`Terrain.dll`, RVA `0x48B90`, запись UV `0x4942A–0x495B7`;
[сборка и SHA-256](original-binaries.md)) берёт разность текущего `u32` clock
и сохранённого поля объекта `+8` с переполнением. Это отдельные миллисекунды
жизни неба, а не абсолютная фаза суток из SKE.
| Слой | Период | Добавка к исходным UV |
|---|---:|---|
| Фон | 300 000 мс | одинаковый сдвиг U и V |
| Звёзды | 100 000 мс | одинаковый сдвиг U и V |
| Облака | 50 000 мс | сдвиг только U |
Добавка равна остатку от деления elapsed clock на период, умноженному на
нативный коэффициент `f32` (`3.3333333e-6`, `1e-5`, `2e-5`). Вершинные UV
каждый раз вычисляются от позиции купола заново; накопление приращений
создавало бы дрейф. `SkyMesh::update_uvs` получает текущие миллисекунды кадра
и сохраняет геометрию купола. В работающем оригинале проверены UV всех трёх
слоёв, включая разность clock с переполнением.
Захват сохраняет `sky_uv_clock` на входе в рендер неба, проверяя его по
фактическим UV вершины зенита и clock того же кадра. Replay удерживает эту
фазу вместе с фазой суток. Без такого поля текстуры движутся по elapsed clock
приложения; начальная фаза оригинала неизвестна и явно считается как
`captured_sky_phase_unverified`.
Цвет первого screen-gradient и последнего пояса вычисляет Цвет первого screen-gradient и последнего пояса вычисляет
`SkyFrame::screen_gradient(yaw_radians, glare_rgb_delta)`. Native `CSky` сначала `SkyFrame::screen_gradient(yaw_radians, glare_rgb_delta)`. Native `CSky` сначала
округляет `(yaw + pi) * 180 / pi` режимом nearest-even, выбирает сектор округляет `(yaw + pi) * 180 / pi` режимом nearest-even, выбирает сектор
+7 -2
View File
@@ -1187,10 +1187,15 @@ drawable размер `(right - left) × (bottom - top)` и отклоняет
`FreeFlightCamera` в этом режиме не создаётся, но redraw продолжает обновлять `FreeFlightCamera` в этом режиме не создаётся, но redraw продолжает обновлять
environment и projected shadows от captured camera. Перед environment update и environment и projected shadows от captured camera. Перед environment update и
его audio events listener получает position, forward и up captured camera; его audio events listener получает position, forward и up captured camera;
начальная сортировка world draw использует её же position. Phase capture может начальная сортировка world draw использует её же position. Облака в обычном
запуске движутся с отдельным 50-секундным циклом UV; фон и звёзды имеют свои
периоды. Изменение цвета неба по расписанию не заменяет это движение.
Phase capture может
содержать наблюдаемый mission path и проверенный `atmosphere_seconds`; viewer содержать наблюдаемый mission path и проверенный `atmosphere_seconds`; viewer
использует эту абсолютную фазу по умолчанию, если не указан явный CLI override, использует эту абсолютную фазу по умолчанию, если не указан явный CLI override,
и удерживает sample атмосферы на ней через readback frames. Фазы FX следуют и удерживает sample атмосферы на ней через readback frames. Дополнительный
`sky_uv_clock` фиксирует положение движущихся текстур на том же кадре;
без него их начальная фаза не подтверждена. Фазы FX следуют
выбранному control clock: без `--control-clock-ms` он обновляется по elapsed выбранному control clock: без `--control-clock-ms` он обновляется по elapsed
time, а с явным значением readback frames переигрывают заданный clock. Это не time, а с явным значением readback frames переигрывают заданный clock. Это не
полное simulation time: `weather`, RNG и полное simulation time: `weather`, RNG и
+133 -10
View File
@@ -50,6 +50,7 @@ internal static partial class NativeFrameCapture
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 GeometryProjectionRva = 0x1D110;
private const uint SkyRenderRva = 0x48110;
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;
@@ -105,6 +106,7 @@ internal static partial class NativeFrameCapture
public ProjectionReadback GeometryProjection; public ProjectionReadback GeometryProjection;
public uint GeometryProjectionGeneration; public uint GeometryProjectionGeneration;
public uint GeometryProjectionThreadId; public uint GeometryProjectionThreadId;
public SkyUvReadback SkyUv;
public uint CameraWorldGameTimeWord; public uint CameraWorldGameTimeWord;
public bool CameraWorldGameTimeWordReadable; public bool CameraWorldGameTimeWordReadable;
public uint WorldGameTimeWord; public uint WorldGameTimeWord;
@@ -133,6 +135,11 @@ internal static partial class NativeFrameCapture
public string NativeFogStatus; public string NativeFogStatus;
} }
private sealed class SkyUvReadback
{
public uint Object, Origin, RawClock, ElapsedMilliseconds, Generation, ThreadId;
}
private sealed class AtmosphereReadback private sealed class AtmosphereReadback
{ {
public uint SampleThreadId; public uint SampleThreadId;
@@ -780,6 +787,66 @@ internal static partial class NativeFrameCapture
json.AppendLine(" },"); json.AppendLine(" },");
} }
private static SkyUvReadback CaptureSkyUv(IntPtr process, uint terrainBase, uint worldBase,
byte[] context, FrameSnapshot frame, uint threadId)
{
uint obj = BitConverter.ToUInt32(context, ContextEcxOffset);
uint stack = BitConverter.ToUInt32(context, ContextEspOffset);
if (frame.Camera == null || frame.CameraThreadId != threadId || frame.CameraGeneration == 0
|| ReadU32Exact(process, stack + 4) != frame.Camera.Camera
|| ReadU32Exact(process, obj) != unchecked(terrainBase + 0x65F98)) return null;
uint origin = ReadU32Exact(process, obj + 8);
uint rawClock = ReadU32Exact(process, unchecked(worldBase + 0x32A38));
uint elapsed = unchecked(rawClock - origin);
float[] expected = new float[] {
(elapsed % 300000) * 3.3333333e-6f, (elapsed % 300000) * 3.3333333e-6f,
(elapsed % 100000) * 1e-5f, (elapsed % 100000) * 1e-5f,
(elapsed % 50000) * 2e-5f, 0 };
uint[] pointers = new uint[] { ReadU32Exact(process, obj + 0x48),
ReadU32Exact(process, obj + 0x40), ReadU32Exact(process, obj + 0x44) };
for (int i = 0; i < expected.Length; i++)
{
float actual = BitConverter.ToSingle(BitConverter.GetBytes(
ReadU32Exact(process, pointers[i / 2] + (uint)(i % 2) * 4)), 0);
if (Single.IsNaN(actual) || Single.IsInfinity(actual)
|| Math.Abs(actual - expected[i]) > 1e-6f)
{
Log("SKY_UV_CLOCK_REJECTED zenith coordinate does not match the current world clock.");
return null;
}
}
Log("SKY_UV_CLOCK generation=" + frame.CameraGeneration + " tid=" + threadId
+ " raw=" + rawClock + " origin=" + origin + " elapsed_ms=" + elapsed);
return new SkyUvReadback { Object = obj, Origin = origin, RawClock = rawClock,
ElapsedMilliseconds = elapsed, Generation = frame.CameraGeneration, ThreadId = threadId };
}
private static bool SkyUvMatchesFrame(FrameSnapshot frame)
{
SkyUvReadback sky = frame.SkyUv;
return sky != null && sky.Generation == frame.CameraGeneration
&& sky.Generation == frame.ProjectionGeneration && sky.ThreadId == frame.CameraThreadId
&& sky.ThreadId == frame.ProjectionThreadId && frame.WorldGameTimeWordReadable
&& sky.RawClock == frame.WorldGameTimeWord && frame.CameraWorldGameTimeWordReadable
&& sky.RawClock == frame.CameraWorldGameTimeWord;
}
private static void AppendSkyUvJson(StringBuilder json, FrameSnapshot frame)
{
if (!SkyUvMatchesFrame(frame)) { json.AppendLine(" \"sky_uv_clock\": null,"); return; }
SkyUvReadback sky = frame.SkyUv;
json.AppendLine(" \"sky_uv_clock\": {");
json.AppendLine(" \"verified\": true,");
json.AppendLine(" \"sample_boundary\": \"Terrain+0x48110, sky render\",");
json.AppendLine(" \"terrain_module_sha256\": " + JsonString(ExpectedSha256[TerrainName]) + ",");
json.AppendLine(" \"raw_clock\": " + sky.RawClock + ",");
json.AppendLine(" \"origin_raw_clock\": " + sky.Origin + ",");
json.AppendLine(" \"frame_generation\": " + sky.Generation + ",");
json.AppendLine(" \"thread_id\": " + sky.ThreadId + ",");
json.AppendLine(" \"elapsed_milliseconds\": " + sky.ElapsedMilliseconds);
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)
{ {
@@ -1931,6 +1998,7 @@ internal static partial class NativeFrameCapture
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); AppendGeometryProjectionJson(json, frame);
AppendSkyUvJson(json, frame);
if (frame.SelectedCamera == null) json.AppendLine(" \"selected_camera\": null,"); if (frame.SelectedCamera == null) json.AppendLine(" \"selected_camera\": null,");
else else
{ {
@@ -2040,6 +2108,7 @@ internal static partial class NativeFrameCapture
SelfCheckCameraInput(); SelfCheckCameraInput();
SelfCheckSelectedCameraWarmup(); SelfCheckSelectedCameraWarmup();
SelfCheckGeometryProjection(); SelfCheckGeometryProjection();
SelfCheckSkyUv();
SelfCheckNativeFogPairing(); SelfCheckNativeFogPairing();
SelfCheckRenderInvocationGates(); SelfCheckRenderInvocationGates();
SelfCheckDebugEventHandleOwnership(); SelfCheckDebugEventHandleOwnership();
@@ -2350,6 +2419,32 @@ internal static partial class NativeFrameCapture
return SelfCheckReadback(); return SelfCheckReadback();
} }
private static void SelfCheckSkyUv()
{
FrameSnapshot frame = new FrameSnapshot { CameraGeneration = 7, ProjectionGeneration = 7,
CameraThreadId = 11, ProjectionThreadId = 11, WorldGameTimeWordReadable = true,
CameraWorldGameTimeWordReadable = true, CameraWorldGameTimeWord = 265584022,
WorldGameTimeWord = 265584022,
SkyUv = new SkyUvReadback { Generation = 7, ThreadId = 11, RawClock = 265584022,
Origin = 509280060, ElapsedMilliseconds = unchecked(265584022U - 509280060U) } };
StringBuilder json = new StringBuilder();
AppendSkyUvJson(json, frame);
if (!SkyUvMatchesFrame(frame) || json.ToString().IndexOf("4051271258", StringComparison.Ordinal) < 0)
throw new InvalidOperationException("Verified sky UV wrapping clock was rejected.");
frame.SkyUv.ThreadId++;
if (SkyUvMatchesFrame(frame)) throw new InvalidOperationException("Sky UV thread mismatch accepted.");
frame.SkyUv.ThreadId--;
frame.SkyUv.Generation++;
if (SkyUvMatchesFrame(frame)) throw new InvalidOperationException("Sky UV generation mismatch accepted.");
frame.SkyUv.Generation--;
frame.SkyUv.RawClock++;
json.Length = 0;
AppendSkyUvJson(json, frame);
if (SkyUvMatchesFrame(frame) || json.ToString().IndexOf("null", StringComparison.Ordinal) < 0)
throw new InvalidOperationException("Sky UV clock mismatch accepted.");
Console.WriteLine("self-check: sky UV clock requires matching thread/generation/world clock and preserves u32 wrap OK");
}
private static void SelfCheckGeometryProjection() private static void SelfCheckGeometryProjection()
{ {
FrameSnapshot frame = new FrameSnapshot { FrameSnapshot frame = new FrameSnapshot {
@@ -3018,6 +3113,7 @@ internal static partial class NativeFrameCapture
BreakpointInfo cameraBreakpoint = null; BreakpointInfo cameraBreakpoint = null;
BreakpointInfo projectionBreakpoint = null; BreakpointInfo projectionBreakpoint = null;
BreakpointInfo geometryProjectionBreakpoint = null; BreakpointInfo geometryProjectionBreakpoint = null;
BreakpointInfo skyUvBreakpoint = null;
BreakpointInfo returnBreakpoint = null; BreakpointInfo returnBreakpoint = null;
BreakpointInfo atmosphereBreakpoint = null; BreakpointInfo atmosphereBreakpoint = null;
BreakpointInfo beginSceneBreakpoint = null; BreakpointInfo beginSceneBreakpoint = null;
@@ -3208,6 +3304,14 @@ internal static partial class NativeFrameCapture
throw new InvalidOperationException("Terrain geometry projection boundary signature failed."); throw new InvalidOperationException("Terrain geometry projection boundary signature failed.");
geometryProjectionBreakpoint = ArmBreakpoint(process, "Terrain.geometry-projection", geometryProjectionBreakpoint = ArmBreakpoint(process, "Terrain.geometry-projection",
unchecked(terrainBase + GeometryProjectionRva), geometryPrefix[0]); unchecked(terrainBase + GeometryProjectionRva), geometryPrefix[0]);
byte[] skyPrefix = new byte[] { 0x6A, 0xFF, 0x68, 0, 0, 0, 0, 0x64, 0xA1, 0, 0, 0, 0 };
Array.Copy(BitConverter.GetBytes(unchecked(terrainBase + 0x637E6)), 0, skyPrefix, 3, 4);
byte[] skyCode = new byte[skyPrefix.Length];
if (!ReadExact(process, unchecked(terrainBase + SkyRenderRva), skyCode)
|| !ByteArraysEqual(skyCode, skyPrefix))
throw new InvalidOperationException("Terrain sky render boundary signature failed.");
skyUvBreakpoint = ArmBreakpoint(process, "Terrain.sky-uv-clock",
unchecked(terrainBase + SkyRenderRva), skyPrefix[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))
@@ -3676,6 +3780,9 @@ internal static partial class NativeFrameCapture
else if (geometryProjectionBreakpoint != null && geometryProjectionBreakpoint.Armed else if (geometryProjectionBreakpoint != null && geometryProjectionBreakpoint.Armed
&& exceptionAddress == geometryProjectionBreakpoint.Address) && exceptionAddress == geometryProjectionBreakpoint.Address)
hitBreakpoint = geometryProjectionBreakpoint; hitBreakpoint = geometryProjectionBreakpoint;
else if (skyUvBreakpoint != null && skyUvBreakpoint.Armed
&& exceptionAddress == skyUvBreakpoint.Address)
hitBreakpoint = skyUvBreakpoint;
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)
@@ -3750,6 +3857,10 @@ internal static partial class NativeFrameCapture
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.GeometryProjection = null;
frame.SkyUv = null;
if (skyUvBreakpoint != null && !skyUvBreakpoint.Armed)
skyUvBreakpoint = ArmBreakpoint(process, "Terrain.sky-uv-clock",
skyUvBreakpoint.Address, skyUvBreakpoint.OriginalByte);
frame.GeometryProjectionGeneration = 0; frame.GeometryProjectionGeneration = 0;
frame.GeometryProjectionThreadId = 0; frame.GeometryProjectionThreadId = 0;
if (geometryProjectionBreakpoint != null && !geometryProjectionBreakpoint.Armed) if (geometryProjectionBreakpoint != null && !geometryProjectionBreakpoint.Armed)
@@ -3843,6 +3954,12 @@ internal static partial class NativeFrameCapture
frame, eventTid, ref fogConsumerBreakpoint); frame, eventTid, ref fogConsumerBreakpoint);
} }
} }
else if (hitBreakpoint == skyUvBreakpoint)
{
if (frame.SkyUv == null && world3dBase != 0)
frame.SkyUv = CaptureSkyUv(process, terrainBase, world3dBase,
stoppedContext, frame, eventTid);
}
else if (hitBreakpoint == geometryProjectionBreakpoint) else if (hitBreakpoint == geometryProjectionBreakpoint)
{ {
uint esp = BitConverter.ToUInt32(stoppedContext, ContextEspOffset); uint esp = BitConverter.ToUInt32(stoppedContext, ContextEspOffset);
@@ -4086,7 +4203,7 @@ internal static partial class NativeFrameCapture
{ {
remote = StartRemoteSurfaceProbe(process, eventTid, ngiBase, remote = StartRemoteSurfaceProbe(process, eventTid, ngiBase,
returnBreakpoint, frame, returnBreakpoint, frame,
new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint,
returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
fogConsumerBreakpoint, fogConsumerBreakpoint,
endSceneBreakpoint, presentBreakpoint, endSceneBreakpoint, presentBreakpoint,
@@ -4234,7 +4351,7 @@ internal static partial class NativeFrameCapture
{ {
remote = StartRemoteSurfaceProbe(process, eventTid, ngiBase, remote = StartRemoteSurfaceProbe(process, eventTid, ngiBase,
endSceneBreakpoint, frame, endSceneBreakpoint, frame,
new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint,
returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
endSceneBreakpoint, endSceneBreakpoint,
fogConsumerBreakpoint, presentBreakpoint, fogConsumerBreakpoint, presentBreakpoint,
@@ -4282,7 +4399,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, geometryProjectionBreakpoint, new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint,
returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
endSceneBreakpoint, endSceneBreakpoint,
fogConsumerBreakpoint, presentBreakpoint, fogConsumerBreakpoint, presentBreakpoint,
@@ -4502,6 +4619,8 @@ internal static partial class NativeFrameCapture
&& !frame.NativeFogSampleLimitExceeded; && !frame.NativeFogSampleLimitExceeded;
if (hitBreakpoint == geometryProjectionBreakpoint) if (hitBreakpoint == geometryProjectionBreakpoint)
rearmAfterStep = frame.GeometryProjection == null; rearmAfterStep = frame.GeometryProjection == null;
if (hitBreakpoint == skyUvBreakpoint)
rearmAfterStep = frame.SkyUv == 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);
@@ -4574,7 +4693,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, geometryProjectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint, returnBreakpoint, atmosphereBreakpoint,
beginSceneBreakpoint, beginSceneBreakpoint,
endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint,
presentCompletionBreakpoint, missionBreakpoint)) presentCompletionBreakpoint, missionBreakpoint))
@@ -4595,6 +4714,7 @@ internal static partial class NativeFrameCapture
&& (cameraBreakpoint != null && cameraBreakpoint.Armed && (cameraBreakpoint != null && cameraBreakpoint.Armed
|| projectionBreakpoint != null && projectionBreakpoint.Armed || projectionBreakpoint != null && projectionBreakpoint.Armed
|| geometryProjectionBreakpoint != null && geometryProjectionBreakpoint.Armed || geometryProjectionBreakpoint != null && geometryProjectionBreakpoint.Armed
|| skyUvBreakpoint != null && skyUvBreakpoint.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
@@ -4605,7 +4725,7 @@ internal static partial class NativeFrameCapture
|| missionBreakpoint != null && missionBreakpoint.Armed)) || missionBreakpoint != null && missionBreakpoint.Armed))
{ {
if (!RestoreArmedBreakpointsWhileStopped(process, if (!RestoreArmedBreakpointsWhileStopped(process,
cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint, returnBreakpoint, atmosphereBreakpoint,
beginSceneBreakpoint, beginSceneBreakpoint,
endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint,
presentCompletionBreakpoint, missionBreakpoint)) presentCompletionBreakpoint, missionBreakpoint))
@@ -4655,6 +4775,7 @@ internal static partial class NativeFrameCapture
|| cameraBreakpoint != null && cameraBreakpoint.Armed || cameraBreakpoint != null && cameraBreakpoint.Armed
|| projectionBreakpoint != null && projectionBreakpoint.Armed || projectionBreakpoint != null && projectionBreakpoint.Armed
|| geometryProjectionBreakpoint != null && geometryProjectionBreakpoint.Armed || geometryProjectionBreakpoint != null && geometryProjectionBreakpoint.Armed
|| skyUvBreakpoint != null && skyUvBreakpoint.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
@@ -4684,7 +4805,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, geometryProjectionBreakpoint, returnBreakpoint, new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint, returnBreakpoint,
atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint,
presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint }, presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint },
out drainEvidence)) out drainEvidence))
@@ -4753,7 +4874,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, geometryProjectionBreakpoint, returnBreakpoint, new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint, returnBreakpoint,
atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint,
presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint }, presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint },
out drainEvidence)) out drainEvidence))
@@ -4791,7 +4912,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, geometryProjectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint, 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);
@@ -4923,7 +5044,7 @@ internal static partial class NativeFrameCapture
} }
private static bool RestoreArmedBreakpointsWhileStopped(IntPtr process, private static bool RestoreArmedBreakpointsWhileStopped(IntPtr process,
BreakpointInfo cameraBreakpoint, BreakpointInfo projectionBreakpoint, BreakpointInfo geometryProjectionBreakpoint, BreakpointInfo returnBreakpoint, BreakpointInfo cameraBreakpoint, BreakpointInfo projectionBreakpoint, BreakpointInfo geometryProjectionBreakpoint, BreakpointInfo skyUvBreakpoint, 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,
@@ -4932,6 +5053,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, geometryProjectionBreakpoint)
&& RestoreBreakpointWhileStopped(process, skyUvBreakpoint)
&& RestoreBreakpointWhileStopped(process, returnBreakpoint) && RestoreBreakpointWhileStopped(process, returnBreakpoint)
&& RestoreBreakpointWhileStopped(process, atmosphereBreakpoint) && RestoreBreakpointWhileStopped(process, atmosphereBreakpoint)
&& RestoreBreakpointWhileStopped(process, beginSceneBreakpoint) && RestoreBreakpointWhileStopped(process, beginSceneBreakpoint)
@@ -4943,7 +5065,7 @@ internal static partial class NativeFrameCapture
} }
private static bool AreBreakpointsRestored(BreakpointInfo cameraBreakpoint, private static bool AreBreakpointsRestored(BreakpointInfo cameraBreakpoint,
BreakpointInfo projectionBreakpoint, BreakpointInfo geometryProjectionBreakpoint, BreakpointInfo returnBreakpoint, BreakpointInfo projectionBreakpoint, BreakpointInfo geometryProjectionBreakpoint, BreakpointInfo skyUvBreakpoint, 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,
@@ -4952,6 +5074,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) && (geometryProjectionBreakpoint == null || !geometryProjectionBreakpoint.Armed)
&& (skyUvBreakpoint == null || !skyUvBreakpoint.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
View File
@@ -4,6 +4,8 @@ Capture modes attach only to the isolated GOG-compatible scratch copy of `Parkan
`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. `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.
`sky_uv_clock` records the independent scrolling-texture clock at sky render entry (`Terrain+0x48110`). It requires a verified module/prologue, the selected camera, matching render thread/generation/world clock, and zenith UV values matching all three native scroll formulas. It preserves wrapping `u32` subtraction from the sky object's origin clock. FParkan holds this phase during exact capture replay; ordinary application frames animate the cloud, star and nebula UVs with periods of 50, 100 and 300 seconds. Older captures without this object still load and report `render_coverage captured_sky_phase_unverified=1`; their sky texture phase is not a native reference.
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: