diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index 4ce86eb..fbdff67 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -9,9 +9,6 @@ use fparkan_msh::{ use fparkan_render::{LegacyDepthMode, LegacyIron3dEulerTransform, LegacyPipelineState}; use fparkan_terrain_format::LandMeshDocument; -/// Legacy `Land.msh` stored-height to mission-world-height scale. -const LEGACY_LAND_HEIGHT_SCALE: f32 = 1.0 / 32.0; - fn world_pipeline_state() -> LegacyPipelineState { LegacyPipelineState { depth: LegacyDepthMode::TestWrite, @@ -1139,31 +1136,6 @@ pub fn project_land_msh_to_static_mesh_in_world_space( }) } -/// Projects terrain into the mission space consumed by the legacy D3D7 camera. -/// -/// `Land.msh` stores horizontal coordinates directly, while its height stream -/// uses 1/32 units. The scale is evidenced by the GOG `AutoDemo` map: raw terrain -/// heights `95.29412..288.23532` become `2.978..9.007`, matching its placed -/// object heights and the live Ngi32 camera's world-space Z coordinate. -/// -/// This conversion is intentionally confined to the captured legacy-camera -/// path. [`project_land_msh_to_static_mesh_in_world_space`] remains the raw -/// source-coordinate bridge for format inspection. -/// -/// # Errors -/// -/// Returns [`VulkanAssetMeshError`] when the terrain cannot enter the static -/// Vulkan input contract. -pub fn project_land_msh_to_static_mesh_in_legacy_world_space( - terrain: &LandMeshDocument, -) -> Result { - let mut mesh = project_land_msh_to_static_mesh_in_world_space(terrain)?; - for vertex in &mut mesh.vertices { - vertex.position[2] *= LEGACY_LAND_HEIGHT_SCALE; - } - Ok(mesh) -} - fn static_terrain_indices(terrain: &LandMeshDocument) -> Result, VulkanAssetMeshError> { let mut indices = Vec::with_capacity( terrain @@ -2068,7 +2040,7 @@ mod tests { } #[test] - fn world_space_terrain_preserves_source_coordinates_and_faces() { + fn world_space_terrain_preserves_nonzero_source_heights_and_faces() { let terrain = LandMeshDocument { streams: Vec::new(), nodes_raw: Vec::new(), @@ -2076,7 +2048,11 @@ mod tests { header_raw: Vec::new(), slots_raw: Vec::new(), }, - positions: vec![[10.0, 20.0, 30.0], [40.0, 50.0, 60.0], [70.0, 80.0, 90.0]], + positions: vec![ + [100.0, 200.0, 32.0], + [300.0, 400.0, 288.0], + [500.0, 600.0, 96.0], + ], normals: Vec::new(), uv0: vec![[1024, 65024], [0, 2048], [64512, 512]], accelerator: Vec::new(), @@ -2089,41 +2065,12 @@ mod tests { .expect("representable source-space terrain"); assert_eq!(mesh.indices, vec![2, 0, 1]); - assert_eq!(mesh.vertices[0].position, [10.0, 20.0, 30.0]); - assert_eq!(mesh.vertices[2].position, [70.0, 80.0, 90.0]); + assert_eq!(mesh.vertices[0].position, [100.0, 200.0, 32.0]); + assert_eq!(mesh.vertices[1].position, [300.0, 400.0, 288.0]); + assert_eq!(mesh.vertices[2].position, [500.0, 600.0, 96.0]); assert_eq!(mesh.vertices[0].uv, [1.0, 63.5]); } - #[test] - fn legacy_world_space_terrain_scales_only_stored_height() { - let terrain = LandMeshDocument { - streams: Vec::new(), - nodes_raw: Vec::new(), - slots: TerrainSlotTable { - header_raw: Vec::new(), - slots_raw: Vec::new(), - }, - positions: vec![ - [100.0, 200.0, 96.0], - [300.0, 400.0, 288.0], - [500.0, 600.0, 192.0], - ], - normals: Vec::new(), - uv0: vec![[0, 0]; 3], - accelerator: Vec::new(), - aux14: Vec::new(), - aux18: Vec::new(), - faces: vec![terrain_face([0, 1, 2])], - }; - - let mesh = project_land_msh_to_static_mesh_in_legacy_world_space(&terrain) - .expect("representable terrain"); - - assert_eq!(mesh.vertices[0].position, [100.0, 200.0, 3.0]); - assert_eq!(mesh.vertices[1].position, [300.0, 400.0, 9.0]); - assert_eq!(mesh.vertices[2].position, [500.0, 600.0, 6.0]); - } - fn terrain_face(vertices: [u16; 3]) -> TerrainFace28 { terrain_face_with_tag(0, vertices) } diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs index 83fdbbf..7cf192c 100644 --- a/adapters/fparkan-render-vulkan/src/ffi.rs +++ b/adapters/fparkan-render-vulkan/src/ffi.rs @@ -23,7 +23,6 @@ mod validation; pub use self::asset_mesh::{ node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root, node38_pose_relative_to_root_from_hierarchy, project_land_msh_to_static_mesh, - project_land_msh_to_static_mesh_in_legacy_world_space, project_land_msh_to_static_mesh_in_world_space, project_land_msh_to_static_mesh_in_xy_frame, project_msh_to_static_mesh, project_msh_to_static_mesh_in_world_space, project_msh_to_static_mesh_in_world_space_with_node_fallback_poses, diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 7405458..c284115 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -37,8 +37,7 @@ use fparkan_render::{ }; use fparkan_render_vulkan::{ node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root, - node38_pose_relative_to_root_from_hierarchy, - project_land_msh_to_static_mesh_in_legacy_world_space, + node38_pose_relative_to_root_from_hierarchy, project_land_msh_to_static_mesh_in_world_space, project_msh_to_static_mesh_in_world_space_with_node_fallback_poses, project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount, project_msh_to_static_mesh_in_world_space_with_node_pose_buffer, @@ -110,6 +109,8 @@ fn run(args: &[String]) -> Result { .as_deref() .map(load_legacy_camera_capture) .transpose()?; + let atmosphere_seconds = selected_atmosphere_seconds(args.atmosphere_seconds, camera.as_ref()); + let hold_schedule_phase = camera.is_some() && atmosphere_seconds.is_some(); let terrain_materials_duration_started = Instant::now(); let preview = static_preview_mesh_and_materials( mission_assets, @@ -127,7 +128,12 @@ fn run(args: &[String]) -> Result { // sky to the selected map (there are many `sky.ske` files in the campaign // tree). let sky_started = Instant::now(); - let environment = load_environment(&args.root, &args.mission, args.atmosphere_seconds)?; + let environment = load_environment( + &args.root, + &args.mission, + atmosphere_seconds, + hold_schedule_phase, + )?; let sky_duration = sky_started.elapsed(); let audio = match audio::GameAudio::new(Arc::new(DirectoryVfs::new(&args.root)), &args.mission) { @@ -290,7 +296,6 @@ impl FreeFlightCamera { .ok() .flatten() .filter(|height| height.is_finite()) - .map(|height| height / 32.0) }; let (spawn_xy, spawn_ground) = sample_offsets .into_iter() @@ -434,6 +439,95 @@ impl FreeFlightCamera { clip_from_world: multiply_row_major(view, projection), } } + + fn preview_frame(self, aspect: f32) -> PreviewCameraFrame { + let camera = self.vulkan_camera(aspect); + PreviewCameraFrame { + position: self.position, + forward: self.forward(), + right: self.right(), + up: self.up(), + vertical_fov: self.vertical_fov, + near_plane: self.near_plane, + far_plane: self.far_plane, + clip_from_world: camera.clip_from_world, + } + } +} + +/// Immutable view data shared by the environment renderers. The optional +/// `FreeFlightCamera` remains only the input controller; captured views keep +/// their original basis and clip transform, including roll. +#[derive(Clone, Copy, Debug, PartialEq)] +struct PreviewCameraFrame { + position: [f32; 3], + forward: [f32; 3], + right: [f32; 3], + up: [f32; 3], + vertical_fov: f32, + near_plane: f32, + far_plane: f32, + clip_from_world: [f32; 16], +} + +impl PreviewCameraFrame { + fn from_legacy_d3d7( + transform: RawCameraTransform, + projection: LegacyD3d7Projection, + ) -> Option { + let camera = VulkanStaticCamera::from_legacy_d3d7(transform, projection)?; + let view = transform.try_direct3d7_view_row_major()?; + let width = projection.viewport[2].checked_sub(projection.viewport[0])?; + let height = projection.viewport[3].checked_sub(projection.viewport[1])?; + if width <= 0 || height <= 0 { + return None; + } + let aspect = width as f32 / height as f32; + let vertical_fov = 2.0 * ((projection.field_of_view_radians * 0.5).tan() / aspect).atan(); + let frame = Self { + position: transform.translation(), + // The camera axes are the columns of Ngi32's D3D7 view matrix. + // Keeping them directly preserves native roll and avoids + // reconstructing an incomplete yaw/pitch camera. + right: [view[0], view[4], view[8]], + up: [view[1], view[5], view[9]], + forward: [view[2], view[6], view[10]], + vertical_fov, + near_plane: projection.near_plane, + far_plane: projection.far_plane, + clip_from_world: camera.clip_from_world, + }; + (frame.position.iter().all(|value| value.is_finite()) + && frame.forward.iter().all(|value| value.is_finite()) + && frame.right.iter().all(|value| value.is_finite()) + && frame.up.iter().all(|value| value.is_finite()) + && frame.vertical_fov.is_finite() + && frame.vertical_fov > 0.0 + && frame.vertical_fov < std::f32::consts::PI) + .then_some(frame) + } + + fn vulkan_camera(self) -> VulkanStaticCamera { + VulkanStaticCamera { + clip_from_world: self.clip_from_world, + } + } + + fn forward(self) -> [f32; 3] { + self.forward + } + + fn right(self) -> [f32; 3] { + self.right + } + + fn up(self) -> [f32; 3] { + self.up + } + + fn yaw(self) -> f32 { + self.forward[1].atan2(self.forward[0]) + } } const ENVIRONMENT_SPRITE_ROWS: [usize; 2] = [3, 4]; @@ -1244,7 +1338,7 @@ fn terrain_shadow_receivers( .get(usize::from(index)) .copied() .unwrap_or([0.0; 3]); - [source[0], source[1], source[2] / 32.0] + source }); if !positions.iter().flatten().all(|value| value.is_finite()) { return Err("terrain shadow receiver contains a non-finite position".to_string()); @@ -1715,7 +1809,7 @@ fn quad_indices(slot_count: usize) -> Result, String> { } fn placeholder_quad_vertices( - camera: &FreeFlightCamera, + camera: &PreviewCameraFrame, slot_count: usize, size: f32, ) -> Vec { @@ -1738,7 +1832,7 @@ fn placeholder_quad_vertices( } fn screen_gradient_quad_vertices( - camera: &FreeFlightCamera, + camera: &PreviewCameraFrame, viewport: [f32; 2], color: [f32; 3], ) -> [VulkanStaticVertex; 4] { @@ -1785,7 +1879,7 @@ impl EnvironmentGpuScene { environment_materials: &SkyMaterials, material_assets: &HashMap, sky_mesh: &SkyMesh, - camera: &FreeFlightCamera, + camera: &PreviewCameraFrame, shadow_range_index: &mut usize, ) -> Result { let mut names = HashMap::new(); @@ -2238,7 +2332,7 @@ impl EnvironmentGpuScene { sky_frame: &SkyFrame, sky_mesh: &SkyMesh, environment_frame: &EnvironmentFrame, - camera: &FreeFlightCamera, + camera: &PreviewCameraFrame, viewport: [f32; 2], material_clock_ms: u32, terrain: &TerrainWorld, @@ -2280,7 +2374,7 @@ impl EnvironmentGpuScene { (optics.primary_color[index] - sky_frame.sun.color[index]).max(0.0) }) }); - let gradient = sky_frame.screen_gradient(camera.yaw, glare_rgb_delta); + let gradient = sky_frame.screen_gradient(camera.yaw(), glare_rgb_delta); if let Some(optics) = optics { let mut uniforms = renderer.frame_uniforms(); if sky_frame.sun.active { @@ -2589,7 +2683,7 @@ fn sprite_frame_for_row<'frame, 'materials>( .find(|sprite| sprite.row == row) } -fn horizontal_fov(camera: &FreeFlightCamera, aspect: f32) -> f32 { +fn horizontal_fov(camera: &PreviewCameraFrame, aspect: f32) -> f32 { let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { @@ -2602,14 +2696,14 @@ fn horizontal_fov(camera: &FreeFlightCamera, aspect: f32) -> f32 { /// /// The native CSun caller starts a half-unit in front of the camera along the /// normalized geometry direction, then queries the finite segment ending at -/// the sun center. `Land.msh` stores Z in the native 32x height unit while the -/// renderer's world projection divides it by 32, so only the Z components are -/// scaled before the terrain query. The ray parameter remains normalized to -/// the finite segment and is therefore independent of world distance. +/// the sun center. Terrain source positions and captured-camera positions both +/// use the `Land.msh` coordinate units, so the ray is queried without a Z +/// conversion. Its parameter remains normalized to the finite segment and is +/// therefore independent of world distance. #[cfg(test)] fn native_sun_unoccluded( terrain: &TerrainWorld, - camera: &FreeFlightCamera, + camera: &PreviewCameraFrame, direction: [f32; 3], ) -> bool { let empty_mesh = VulkanStaticMesh { @@ -2624,7 +2718,7 @@ fn native_sun_unoccluded_with_world( terrain: &TerrainWorld, world_mesh: &VulkanStaticMesh, sun_occlusion_range_indices: &[usize], - camera: &FreeFlightCamera, + camera: &PreviewCameraFrame, direction: [f32; 3], ) -> bool { // Native CSun requests object kinds 1, 3, 4, and 10 (the aggregate @@ -2655,11 +2749,9 @@ fn native_sun_unoccluded_with_world( if dot3(segment, segment) <= f32::EPSILON { return true; } - let origin = [start[0], start[1], start[2] * 32.0]; - let terrain_segment = [segment[0], segment[1], segment[2] * 32.0]; if let Ok(Some(hit)) = terrain.raycast_excluding( - origin, - terrain_segment, + start, + segment, FullSurfaceMask(0), FullSurfaceMask(0x0000_0020), ) { @@ -2782,7 +2874,7 @@ fn first_indexed_vertex_distance( } fn project_direction_pixels( - camera: &FreeFlightCamera, + camera: &PreviewCameraFrame, direction: [f32; 3], viewport: [f32; 2], ) -> Option<[f32; 2]> { @@ -2809,7 +2901,7 @@ fn project_direction_pixels( } fn screen_ray_from_pixels( - camera: &FreeFlightCamera, + camera: &PreviewCameraFrame, pixels: [f32; 2], viewport: [f32; 2], ) -> Option<[f32; 3]> { @@ -2835,7 +2927,7 @@ fn screen_ray_from_pixels( } fn sprite_quad_vertices_pixels( - camera: &FreeFlightCamera, + camera: &PreviewCameraFrame, direction: [f32; 3], distance: f32, half_width_pixels: f32, @@ -2878,7 +2970,7 @@ fn precipitation_quad_vertices( kind: PrecipitationKind, screen: &ScreenBillboard, color: [f32; 4], - camera: &FreeFlightCamera, + camera: &PreviewCameraFrame, viewport: [f32; 2], ) -> [VulkanStaticVertex; 4] { let rgb = [color[0], color[1], color[2]]; @@ -2902,7 +2994,7 @@ fn precipitation_quad_vertices( } fn world_from_ndc( - camera: &FreeFlightCamera, + camera: &PreviewCameraFrame, ndc: [f32; 2], depth: f32, viewport: [f32; 2], @@ -2945,13 +3037,15 @@ struct DynamicEnvironment { material_assets: HashMap, schedule_offset_seconds: f32, time_seconds: f32, + fixed_schedule_seconds: Option, + fixed_phase_reported: bool, } impl DynamicEnvironment { fn update( &mut self, dt_seconds: f32, - camera: &FreeFlightCamera, + camera: &PreviewCameraFrame, aspect: f32, viewport: [f32; 2], renderer: &mut VulkanSmokeRenderer, @@ -2962,12 +3056,21 @@ impl DynamicEnvironment { } else { 0.0 }; - self.time_seconds = if self.time_seconds.is_finite() { - self.time_seconds + dt_seconds - } else { - 0.0 - }; - let absolute_time_seconds = self.schedule_offset_seconds + self.time_seconds; + let (time_seconds, absolute_time_seconds) = environment_sample_time( + self.time_seconds, + self.schedule_offset_seconds, + dt_seconds, + self.fixed_schedule_seconds, + ); + self.time_seconds = time_seconds; + if let Some(seconds) = self.fixed_schedule_seconds { + if !self.fixed_phase_reported { + eprintln!( + "CAPTURE_PHASE_SAMPLE absolute_seconds={seconds:.6} schedule_frozen=true" + ); + self.fixed_phase_reported = true; + } + } let atmosphere = self.schedule.sample(absolute_time_seconds); let mut sky_mesh = self.sky.mesh().clone(); let sky_frame = @@ -2987,7 +3090,7 @@ impl DynamicEnvironment { } let mut uniforms = renderer.frame_uniforms(); - uniforms.clip_from_world = camera.vulkan_camera(aspect).clip_from_world; + uniforms.clip_from_world = camera.clip_from_world; for (target, source) in uniforms .directional_lights .iter_mut() @@ -3053,6 +3156,7 @@ fn load_environment( root: &Path, mission: &str, atmosphere_seconds: Option, + hold_schedule_phase: bool, ) -> Result, String> { let mission_dir = mission_asset_directory(root, mission)?; let Some(sky_path) = find_mission_file(&mission_dir, "sky.ske")? else { @@ -3198,6 +3302,11 @@ fn load_environment( let time_seconds = atmosphere_seconds .map(|seconds| seconds - initial_offset_seconds) .unwrap_or(0.0); + let fixed_schedule_seconds = if hold_schedule_phase { + atmosphere_seconds + } else { + None + }; let mut environment = EnvironmentSystem::new(0x4650_4152_4B41_4E_u64); environment.set_lightning_effect(lightning_effect); Ok(Some(DynamicEnvironment { @@ -3208,9 +3317,32 @@ fn load_environment( material_assets, schedule_offset_seconds: initial_offset_seconds, time_seconds, + fixed_schedule_seconds, + fixed_phase_reported: false, })) } +fn environment_sample_time( + time_seconds: f32, + schedule_offset_seconds: f32, + dt_seconds: f32, + fixed_schedule_seconds: Option, +) -> (f32, f32) { + if let Some(absolute_seconds) = fixed_schedule_seconds { + return (time_seconds, absolute_seconds); + } + + let next_time_seconds = if time_seconds.is_finite() { + time_seconds + dt_seconds + } else { + 0.0 + }; + ( + next_time_seconds, + schedule_offset_seconds + next_time_seconds, + ) +} + /// Returns the host directory containing the selected mission's assets. /// Mission keys are the same relative paths accepted by the runtime VFS; the /// host root is only used here for the small set of loose mission-local files @@ -3353,12 +3485,35 @@ struct StaticPreviewScene { shadow_material_index: usize, terrain: Arc, camera: VulkanStaticCamera, + view_camera: PreviewCameraFrame, + captured_viewport: Option, free_camera: Option, camera_mode: &'static str, mesh_components: usize, terrain_components: usize, } +#[derive(Clone, Copy, Debug, PartialEq)] +struct CapturedViewport { + /// Original D3D7 viewport rectangle `(left, top, right, bottom)`. The + /// preview window renders `extent` and should be compared with this crop. + rect: [i32; 4], + extent: [u32; 2], +} + +fn validate_captured_drawable_extent( + captured: CapturedViewport, + actual: [u32; 2], +) -> Result<(), String> { + if actual == captured.extent { + return Ok(()); + } + Err(format!( + "legacy camera viewport crop {:?} requires a {}x{} drawable, but the window provides {}x{}; refusing to compare a stretched render", + captured.rect, captured.extent[0], captured.extent[1], actual[0], actual[1] + )) +} + #[derive(Clone, Copy, Debug, Default)] struct StartupTimings { mission_assets: Duration, @@ -3519,7 +3674,7 @@ fn static_preview_mesh_and_materials( assets: &MissionAssets, terrain: &TerrainWorld, roots: &[MissionObjectDraft], - legacy_camera: Option, + legacy_camera: Option, static_animation_frame: Option, static_material_phase: Option, root: &std::path::Path, @@ -3539,7 +3694,7 @@ fn static_preview_mesh_and_materials( let mut shadow_casters = Vec::new(); let shadow_receivers = terrain_shadow_receivers(terrain_mesh)?; let mut terrain_material_loader = StandaloneWearMaterialLoader::new(root); - let terrain_component = project_land_msh_to_static_mesh_in_legacy_world_space(terrain_mesh) + let terrain_component = project_land_msh_to_static_mesh_in_world_space(terrain_mesh) .map_err(|err| format!("project mission terrain for Vulkan: {err}"))?; let terrain_materials = static_preview_terrain_base_materials( &mut terrain_material_loader, @@ -3853,11 +4008,18 @@ fn static_preview_mesh_and_materials( } else { None }; - let camera = legacy_camera.unwrap_or_else(|| { + let captured_viewport = legacy_camera + .as_ref() + .map(LegacyCameraCapture::captured_viewport) + .transpose()?; + let view_camera = if let Some(capture) = legacy_camera.as_ref() { + capture.preview_camera_frame()? + } else { free_camera - .map(|camera| camera.vulkan_camera(16.0 / 9.0)) - .unwrap_or_default() - }); + .map(|camera| camera.preview_frame(16.0 / 9.0)) + .ok_or_else(|| "static preview camera is unavailable".to_string())? + }; + let camera = view_camera.vulkan_camera(); let shadow_material_index = append_shadow_material(&mut materials)?; Ok(StaticPreviewScene { mesh, @@ -3869,6 +4031,8 @@ fn static_preview_mesh_and_materials( shadow_material_index, terrain: Arc::new(terrain.clone()), camera, + view_camera, + captured_viewport, free_camera, camera_mode: if legacy_camera.is_some() { "legacy-d3d7-capture" @@ -4560,17 +4724,7 @@ fn run_static_vulkan_mode( )?; let environment_gpu = match environment.as_mut() { Some(environment) => { - let camera = preview.free_camera.unwrap_or_else(|| { - FreeFlightCamera::from_mesh(&preview.mesh).unwrap_or(FreeFlightCamera { - position: [0.0; 3], - yaw: 0.0, - pitch: 0.0, - vertical_fov: std::f32::consts::FRAC_PI_3, - near_plane: 0.1, - far_plane: 100_000.0, - move_speed: 1.0, - }) - }); + let camera = preview.view_camera; let environment_materials = environment.materials.clone(); let material_assets = environment.material_assets.clone(); let sky_mesh = environment.sky.mesh().clone(); @@ -4620,6 +4774,8 @@ struct StaticVulkanApp { world_range_indices: Vec, world_range_index_counts: HashMap, camera: VulkanStaticCamera, + view_camera: PreviewCameraFrame, + captured_viewport: Option, free_camera: Option, environment: Option, environment_gpu: Option, @@ -4698,6 +4854,8 @@ impl StaticVulkanApp { world_range_indices, world_range_index_counts, camera: preview.camera, + view_camera: preview.view_camera, + captured_viewport: preview.captured_viewport, free_camera: preview.free_camera, environment, environment_gpu, @@ -4832,21 +4990,18 @@ impl StaticVulkanApp { } else { size.width as f32 / size.height as f32 }; - let (camera, listener_position, listener_forward, listener_up) = { + let view_camera = { let free_camera = self .free_camera .as_mut() .ok_or_else(|| "free-flight camera disappeared".to_string())?; free_camera.advance(&self.pressed_keys, elapsed.as_secs_f32()); - ( - free_camera.vulkan_camera(aspect), - free_camera.position, - free_camera.forward(), - free_camera.up(), - ) + free_camera.preview_frame(aspect) }; + let camera = view_camera.vulkan_camera(); + self.view_camera = view_camera; self.camera = camera; - let sort_keys = self.world_draw_sort_keys(listener_position)?; + let sort_keys = self.world_draw_sort_keys(view_camera.position)?; if let Some(renderer) = self.renderer.as_mut() { renderer .set_camera(camera) @@ -4859,11 +5014,6 @@ impl StaticVulkanApp { })?; } } - if let Some(audio) = self.audio.as_mut() { - audio - .update_listener(listener_position, listener_forward, listener_up) - .map_err(|error| format!("update audio listener: {error}"))?; - } Ok(()) } @@ -4934,14 +5084,18 @@ impl StaticVulkanApp { let Some(renderer) = self.renderer.as_mut() else { return Ok(()); }; - let viewport = self - .window - .as_ref() - .map(|window| { - let size = window.inner_size(); - [size.width.max(1) as f32, size.height.max(1) as f32] - }) - .unwrap_or([1280.0, 720.0]); + let viewport = self.captured_viewport.map_or_else( + || { + self.window + .as_ref() + .map(|window| { + let size = window.inner_size(); + [size.width.max(1) as f32, size.height.max(1) as f32] + }) + .unwrap_or([1280.0, 720.0]) + }, + |viewport| [viewport.extent[0] as f32, viewport.extent[1] as f32], + ); let aspect = viewport[0] / viewport[1].max(1.0); let now = Instant::now(); let dt = now @@ -4963,8 +5117,13 @@ impl StaticVulkanApp { &active_materials, renderer, )?; - let free_camera = self.free_camera; - if let (Some(camera), Some(environment)) = (free_camera, self.environment.as_mut()) { + let camera = self.view_camera; + if let Some(audio) = self.audio.as_mut() { + audio + .update_listener(camera.position, camera.forward, camera.up) + .map_err(|error| format!("update audio listener: {error}"))?; + } + if let Some(environment) = self.environment.as_mut() { let (sky_frame, sky_mesh, environment_frame) = environment.update(dt, &camera, aspect, viewport, renderer, self.audio.as_mut())?; self.environment_primitive_count = environment_frame.primitives.len(); @@ -5003,7 +5162,7 @@ impl StaticVulkanApp { Some(shadow_camera), )?; } else { - let shadow_camera = free_camera.map(|camera| ShadowCamera { + let shadow_camera = Some(ShadowCamera { position: camera.position, viewport_width: viewport[0], horizontal_fov: horizontal_fov(&camera, aspect), @@ -5114,14 +5273,24 @@ impl StaticVulkanApp { .map_or(report.renderer_report.swapchain_image_format, |artifact| { artifact.format }); + let captured_crop = self.captured_viewport.map_or_else( + || "none".to_string(), + |viewport| { + format!( + "{:?} ({}x{})", + viewport.rect, viewport.extent[0], viewport.extent[1] + ) + }, + ); self.output = Some(format!( - "rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, materials={}, environment_primitives={}, shadow_casters={}, shadow_indices={}, swapchain_recreates={}, validation={}, swapchain_format={}, readback_format={}, readback={}, startup_ms={{mission_assets:{:.1},terrain_materials:{:.1},sky:{:.1},gpu_initialization:{:.1}}}", + "rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, captured_viewport_crop={}, materials={}, environment_primitives={}, shadow_casters={}, shadow_indices={}, swapchain_recreates={}, validation={}, swapchain_format={}, readback_format={}, readback={}, startup_ms={{mission_assets:{:.1},terrain_materials:{:.1},sky:{:.1},gpu_initialization:{:.1}}}", self.mission, self.frames_presented, self.object_count, self.mesh_components, self.terrain_components, self.camera_mode, + captured_crop, self.materials.len(), self.environment_primitive_count, self.shadow_last_evidence.visible_casters, @@ -5176,9 +5345,17 @@ impl ApplicationHandler for StaticVulkanApp { return; } }; + let (width, height) = self + .captured_viewport + .map(|viewport| (viewport.extent[0], viewport.extent[1])) + .unwrap_or((plan.width, plan.height)); let attributes = Window::default_attributes() .with_title("FParkan mission") - .with_inner_size(WinitPhysicalSize::new(plan.width, plan.height)); + .with_inner_size(WinitPhysicalSize::new(width, height)) + // The captured clip matrix is exact for the recorded viewport + // aspect. Keep that drawable fixed; compare it with the same + // (left, top, right, bottom) crop in the original capture. + .with_resizable(self.captured_viewport.is_none()); let window = match event_loop.create_window(attributes) { Ok(window) => window, Err(err) => { @@ -5187,12 +5364,21 @@ impl ApplicationHandler for StaticVulkanApp { return; } }; + let size = window.inner_size(); + if let Some(captured_viewport) = self.captured_viewport { + if let Err(error) = + validate_captured_drawable_extent(captured_viewport, [size.width, size.height]) + { + self.error = Some(error); + event_loop.exit(); + return; + } + } let Some(native_handles) = window_native_handles(&window) else { self.error = Some("winit window does not expose native handles".to_string()); event_loop.exit(); return; }; - let size = window.inner_size(); let gpu_initialization_started = Instant::now(); let mut renderer = match VulkanSmokeRenderer::new(&VulkanSmokeRendererCreateInfo { application_name: "fparkan-game".to_string(), @@ -5206,8 +5392,8 @@ impl ApplicationHandler for StaticVulkanApp { bootstrap_progress: None, }) { Ok(renderer) => renderer, - Err(err) => { - self.error = Some(err.to_string()); + Err(error) => { + self.error = Some(error.to_string()); event_loop.exit(); return; } @@ -5217,32 +5403,29 @@ impl ApplicationHandler for StaticVulkanApp { event_loop.exit(); return; } - if let Some(free_camera) = self.free_camera { - match self.world_draw_sort_keys(free_camera.position) { - Ok(sort_keys) => { - for (range_index, sort_key) in sort_keys { - if let Err(error) = renderer.set_draw_range_sort_key(range_index, sort_key) - { - self.error = Some(format!( - "update world draw range {range_index} sort key: {error}" - )); - event_loop.exit(); - return; - } + match self.world_draw_sort_keys(self.view_camera.position) { + Ok(sort_keys) => { + for (range_index, sort_key) in sort_keys { + if let Err(error) = renderer.set_draw_range_sort_key(range_index, sort_key) { + self.error = Some(format!( + "update world draw range {range_index} sort key: {error}" + )); + event_loop.exit(); + return; } } - Err(error) => { - self.error = Some(error); - event_loop.exit(); - return; - } + } + Err(error) => { + self.error = Some(error); + event_loop.exit(); + return; } } self.startup_timings.gpu_initialization = gpu_initialization_started.elapsed(); renderer.set_readback_enabled(self.readback_out.is_some()); self.window_id = Some(window.id()); - self.renderer = Some(renderer); self.window = Some(window); + self.renderer = Some(renderer); self.last_tick = Instant::now(); self.environment_last_tick = Instant::now(); self.material_last_tick = Instant::now(); @@ -5300,8 +5483,26 @@ impl ApplicationHandler for StaticVulkanApp { } WindowEvent::CursorMoved { .. } => {} WindowEvent::Resized(size) => { + // Queued creation resize events can predate the current physical drawable. + let drawable_size = if self.captured_viewport.is_some() { + self.window.as_ref().map(Window::inner_size).unwrap_or(size) + } else { + size + }; + if let Some(captured_viewport) = self.captured_viewport { + if drawable_size.width != 0 && drawable_size.height != 0 { + if let Err(error) = validate_captured_drawable_extent( + captured_viewport, + [drawable_size.width, drawable_size.height], + ) { + self.error = Some(error); + event_loop.exit(); + return; + } + } + } if let Some(renderer) = self.renderer.as_mut() { - renderer.request_resize((size.width, size.height)); + renderer.request_resize((drawable_size.width, drawable_size.height)); } } WindowEvent::RedrawRequested => { @@ -5484,22 +5685,70 @@ impl Args { } } -#[derive(Deserialize)] +#[derive(Clone, Debug, Deserialize, PartialEq)] struct LegacyCameraCapture { schema: String, + render_input_usable: Option, selector0_words: [u32; 16], viewport: [i32; 4], near_plane: f32, far_plane: f32, field_of_view_radians: f32, + atmosphere_seconds: Option, } -fn load_legacy_camera_capture(path: &std::path::Path) -> Result { +fn selected_atmosphere_seconds( + command_line: Option, + capture: Option<&LegacyCameraCapture>, +) -> Option { + command_line.or_else(|| capture.and_then(|capture| capture.atmosphere_seconds)) +} + +impl LegacyCameraCapture { + fn captured_viewport(&self) -> Result { + let width = self.viewport[2] + .checked_sub(self.viewport[0]) + .filter(|width| *width > 0) + .ok_or_else(|| "legacy camera capture has an invalid viewport width".to_string())?; + let height = self.viewport[3] + .checked_sub(self.viewport[1]) + .filter(|height| *height > 0) + .ok_or_else(|| "legacy camera capture has an invalid viewport height".to_string())?; + Ok(CapturedViewport { + rect: self.viewport, + extent: [ + u32::try_from(width) + .map_err(|_| "legacy camera viewport width exceeds u32".to_string())?, + u32::try_from(height) + .map_err(|_| "legacy camera viewport height exceeds u32".to_string())?, + ], + }) + } + + fn preview_camera_frame(&self) -> Result { + let frame = PreviewCameraFrame::from_legacy_d3d7( + RawCameraTransform { + words: self.selector0_words, + }, + LegacyD3d7Projection { + viewport: self.viewport, + near_plane: self.near_plane, + far_plane: self.far_plane, + field_of_view_radians: self.field_of_view_radians, + }, + ) + .ok_or_else(|| "legacy camera capture contains an invalid D3D7 camera".to_string())?; + self.captured_viewport()?; + Ok(frame) + } +} + +fn load_legacy_camera_capture(path: &std::path::Path) -> Result { let bytes = std::fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?; parse_legacy_camera_capture(&bytes).map_err(|err| format!("{}: {err}", path.display())) } -fn parse_legacy_camera_capture(bytes: &[u8]) -> Result { +fn parse_legacy_camera_capture(bytes: &[u8]) -> Result { // Windows PowerShell 5.1 writes redirected text as UTF-16LE with a BOM. // `capture-original-camera.ps1` intentionally emits plain JSON, so accept // that normal hand-off format as well as UTF-8 without making the caller @@ -5510,18 +5759,19 @@ fn parse_legacy_camera_capture(bytes: &[u8]) -> Result Result { @@ -5662,7 +5912,7 @@ mod tests { "{mission}: empty material set" ); - if load_environment(&root, &mission, None) + if load_environment(&root, &mission, None, false) .unwrap_or_else(|error| panic!("prepare environment mission {mission}: {error}")) .is_some() { @@ -6025,19 +6275,20 @@ mod tests { near_plane: 0.1, far_plane: 100.0, move_speed: 1.0, - }; + } + .preview_frame(16.0 / 9.0); let terrain = TerrainWorld::default(); assert!(native_sun_unoccluded(&terrain, &camera, [1.0, 0.0, 0.0])); assert!(!native_sun_unoccluded(&terrain, &camera, [0.0, 0.0, 0.0])); - let terrain = TerrainWorld::from_land_msh(&fparkan_terrain_format::LandMeshDocument { + let land = fparkan_terrain_format::LandMeshDocument { streams: Vec::new(), nodes_raw: Vec::new(), slots: fparkan_terrain_format::TerrainSlotTable { header_raw: Vec::new(), slots_raw: Vec::new(), }, - // The native Land.msh height is 32; renderer world Z is 1. + // Nonzero source Z is shared by the camera, terrain and shadows. positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.0]], normals: Vec::new(), uv0: Vec::new(), @@ -6053,17 +6304,25 @@ mod tests { tail_raw: [0; 8], raw: [0; 28], }], - }) - .expect("synthetic sun-occluder terrain"); + }; + let shadow_receivers = terrain_shadow_receivers(&land).expect("raw-height receivers"); + assert_eq!(shadow_receivers[0].positions[0], [0.0, 0.0, 32.0]); + let terrain = TerrainWorld::from_land_msh(&land).expect("synthetic terrain"); + assert_eq!(terrain.height_at([0.25, 0.25]).unwrap(), Some(32.0)); + let floor_camera = + FreeFlightCamera::from_mesh_and_terrain(&VulkanStaticMesh::smoke_triangle(), &terrain) + .expect("camera above nonzero terrain"); + assert_eq!(floor_camera.position[2], 34.0); let blocked_camera = FreeFlightCamera { - position: [0.25, 0.25, 2.0], + position: [0.25, 0.25, 34.0], yaw: 0.0, pitch: 0.0, vertical_fov: 1.0, near_plane: 0.1, far_plane: 100.0, move_speed: 1.0, - }; + } + .preview_frame(16.0 / 9.0); assert!(!native_sun_unoccluded( &terrain, &blocked_camera, @@ -6072,7 +6331,7 @@ mod tests { // The same hit is beyond a short native sun segment, so it must not // occlude the light even though the terrain contains a surface. - let short_segment_camera = FreeFlightCamera { + let short_segment_camera = PreviewCameraFrame { far_plane: 1.0, ..blocked_camera }; @@ -6383,19 +6642,35 @@ mod tests { 1.0_f32.to_bits(), ]; let json = format!( - "{{\"schema\":\"fparkan-legacy-camera-v1\",\"selector0_words\":{:?},\"viewport\":[0,0,1024,768],\"near_plane\":0.5,\"far_plane\":700.0,\"field_of_view_radians\":1.3}}", + "{{\"schema\":\"fparkan-legacy-camera-v1\",\"render_input_usable\":true,\"atmosphere_seconds\":56.227,\"selector0_words\":{:?},\"viewport\":[23,17,1047,785],\"near_plane\":0.5,\"far_plane\":700.0,\"field_of_view_radians\":1.3}}", words ); - let camera = parse_legacy_camera_capture(json.as_bytes()).expect("valid legacy camera"); + let capture = parse_legacy_camera_capture(json.as_bytes()).expect("valid legacy camera"); + let camera = capture.preview_camera_frame().expect("valid preview frame"); - assert!(camera.is_finite()); + assert_eq!(capture.render_input_usable, Some(true)); + assert_eq!(capture.atmosphere_seconds, Some(56.227)); + assert!(camera.clip_from_world.iter().all(|value| value.is_finite())); assert_eq!(camera.clip_from_world[0], 0.65_f32.cos()); + assert_eq!(capture.viewport, [23, 17, 1047, 785]); + assert_eq!( + capture.captured_viewport().unwrap().rect, + [23, 17, 1047, 785] + ); + assert_eq!(capture.captured_viewport().unwrap().extent, [1024, 768]); + assert!((horizontal_fov(&camera, 4.0 / 3.0) - 1.3).abs() < 1.0e-5); + assert_eq!( + camera.vulkan_camera().clip_from_world, + camera.clip_from_world + ); let mut utf16le = vec![0xff, 0xfe]; utf16le.extend(json.encode_utf16().flat_map(u16::to_le_bytes)); assert_eq!( parse_legacy_camera_capture(&utf16le) .expect("PowerShell UTF-16LE capture must be accepted") + .preview_camera_frame() + .expect("UTF-16 preview frame") .clip_from_world, camera.clip_from_world ); @@ -6405,6 +6680,125 @@ mod tests { ); } + #[test] + fn captured_atmosphere_is_default_and_cli_time_takes_precedence() { + let capture = LegacyCameraCapture { + schema: "fparkan-legacy-camera-v1".to_string(), + render_input_usable: None, + selector0_words: [0; 16], + viewport: [0, 0, 1, 1], + near_plane: 0.1, + far_plane: 1.0, + field_of_view_radians: 1.0, + atmosphere_seconds: Some(56.227), + }; + + assert_eq!( + selected_atmosphere_seconds(None, Some(&capture)), + Some(56.227) + ); + assert_eq!( + selected_atmosphere_seconds(Some(100.0), Some(&capture)), + Some(100.0) + ); + assert_eq!(selected_atmosphere_seconds(None, None), None); + } + + #[test] + fn captured_environment_holds_exact_phase_while_free_time_advances() { + let (fixed_relative, fixed_absolute) = + environment_sample_time(0.227, 56.0, 0.25, Some(56.227)); + assert_eq!(fixed_relative, 0.227); + assert_eq!(fixed_absolute, 56.227); + + let (free_relative, free_absolute) = environment_sample_time(0.227, 56.0, 0.25, None); + assert_eq!(free_relative, 0.477); + assert_eq!(free_absolute, 56.477); + } + + #[test] + fn legacy_camera_capture_rejects_invalid_phase_and_unusable_input() { + let base = r#"{"schema":"fparkan-legacy-camera-v1","selector0_words":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"viewport":[0,0,1,1],"near_plane":0.1,"far_plane":1.0,"field_of_view_radians":1.0}"#; + let invalid_phase = base.replace( + "\"near_plane\"", + "\"atmosphere_seconds\":-1.0,\"near_plane\"", + ); + assert!(parse_legacy_camera_capture(invalid_phase.as_bytes()) + .expect_err("negative captured phase") + .contains("atmosphere_seconds must be finite and non-negative")); + + let unusable = base.replace( + "\"near_plane\"", + "\"render_input_usable\":false,\"near_plane\"", + ); + assert!(parse_legacy_camera_capture(unusable.as_bytes()) + .expect_err("explicitly unusable camera capture") + .contains("render_input_usable=false")); + } + + #[test] + fn captured_camera_frame_keeps_native_roll_basis() { + let roll = 0.37_f32; + let (sin, cos) = roll.sin_cos(); + let transform = RawCameraTransform { + words: [ + 1.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + cos.to_bits(), + (-sin).to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + sin.to_bits(), + cos.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 1.0_f32.to_bits(), + ], + }; + let projection = LegacyD3d7Projection { + viewport: [0, 0, 800, 600], + near_plane: 0.1, + far_plane: 500.0, + field_of_view_radians: 1.0, + }; + let view = transform + .try_direct3d7_view_row_major() + .expect("valid native camera basis"); + let frame = PreviewCameraFrame::from_legacy_d3d7(transform, projection) + .expect("valid captured frame"); + + assert_eq!(frame.forward, [view[2], view[6], view[10]]); + assert_eq!(frame.right, [view[0], view[4], view[8]]); + assert_eq!(frame.up, [view[1], view[5], view[9]]); + assert!(frame.right[2].abs() > 0.3, "roll must tilt the right axis"); + assert!(frame.up[1].abs() > 0.3, "roll must tilt the up axis"); + assert_eq!( + frame.clip_from_world, + VulkanStaticCamera::from_legacy_d3d7(transform, projection) + .expect("legacy clip matrix") + .clip_from_world + ); + } + + #[test] + fn captured_camera_refuses_a_different_drawable_extent() { + let captured = CapturedViewport { + rect: [23, 17, 1047, 785], + extent: [1024, 768], + }; + assert!(validate_captured_drawable_extent(captured, [1024, 768]).is_ok()); + let error = validate_captured_drawable_extent(captured, [1280, 720]) + .expect_err("a mismatched surface must not stretch the captured view"); + assert!(error.contains("[23, 17, 1047, 785]")); + assert!(error.contains("1024x768")); + assert!(error.contains("1280x720")); + } + #[test] fn static_preview_component_merge_offsets_indices_and_remaps_local_selectors( ) -> Result<(), String> { @@ -6473,7 +6867,8 @@ mod tests { near_plane: 0.1, far_plane: 100.0, move_speed: 1.0, - }; + } + .preview_frame(1.0); let screen = ScreenBillboard { world_head: [0.0; 3], world_tail: [0.0; 3], @@ -6523,7 +6918,8 @@ mod tests { near_plane: 0.1, far_plane: 100.0, move_speed: 1.0, - }; + } + .preview_frame(1.0); let uv = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]]; let vertices = sprite_quad_vertices_pixels( &camera, diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md index 5add02b..c82fbb0 100644 --- a/docs/reference/render-frame.md +++ b/docs/reference/render-frame.md @@ -48,7 +48,17 @@ the exported `stdRenderGame` is RVA `0x13BD0`. It first calls `Terrain::stdSetCurrentCamera2(camera)`, stores the camera pointer only for the frame, and clears it before return. `sendEndOfRender` is a separate export at RVA `0x13D90`. This is frame-order evidence only: the camera ABI, matrices -and viewport values still require dynamic capture or further decompilation. +and viewport values are not recovered by this call-order analysis. The +implemented preview path below accepts selector-0 input produced through the +[native frame capture workflow](../../tools/native-frame-capture/README.md). +The capture workflow also records an observed mission path and, when +its thread/generation and clock checks succeed, an `atmosphere_seconds` sample. +For a fixed-camera preview, the renderer uses that optional absolute schedule +phase when no `--atmosphere-seconds` override is supplied and keeps schedule +sampling at that exact phase across readback frames. An explicit CLI value takes +precedence. The phase sample is not full simulation time: weather, RNG, and +other mission state remain unknown, so this evidence does not establish +full-frame parity. Receiver-side GOG Terrain evidence refines this contract. `stdSetCurrentCamera2` (RVA `0x4FD40` in Terrain SHA-256 @@ -67,6 +77,29 @@ mode string: only `REFLECTION` and `REFLECTION_SHIFTED` produce a non-zero mode value at `this + 0x1A0`. This is factory ABI evidence, not a recovered view or projection matrix contract. +## Legacy camera preview path + +`fparkan-game --legacy-camera-capture ` consumes the selector-0 transform, +the native D3D7 viewport RECT `[left, top, right, bottom]`, near/far planes and +field of view. It derives the D3D7 view and clip transform from those inputs, +carries the resulting `clip_from_world` matrix into the preview camera, and +keeps the view-matrix camera axes directly, so the captured basis, including +roll, is not reduced to yaw/pitch. The RECT becomes a physical drawable extent +of `right - left` by `bottom - top`. The preview window is fixed to that extent; +startup and non-zero resize events reject a physical-size mismatch instead of +stretching the captured view. Compare the result with the corresponding +`[left, top, right, bottom]` crop of the original PNG. `FreeFlightCamera` is not +created in this mode, but redraws still update environment and projected +shadows from the captured camera. Before environment updates can emit sound +events, the audio listener receives the captured position, forward and up axes; +initial world draw sorting uses the same captured position. A phase capture may +include an observed mission path and a verified `atmosphere_seconds` value; the +latter is used only when no explicit CLI override is supplied. This value pins +the atmosphere schedule sample during a fixed-camera comparison while FX still +receives real frame time. `simulation_time_seconds`, weather, RNG and complete +mission state remain unknown, and a camera/phase match alone does not establish +visual parity with a native frame. + ## Parity risks `VulkanStaticCamera::from_legacy_d3d7` keeps the D3D7 view and projection diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 71cad1d..16dd94f 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -871,9 +871,12 @@ RVA `0x36610` строит `Rz(z) * Ry(y) * Rx(x)` с переносом в по Повороты и переносы узлов модели применяются до размещения объекта в миссии. Горизонтальная плоскость карты — XY, высота — Z. У GOG AutoDemo диапазон XY -ландшафта составляет `0..1190.6976`. Высоты, сохранённые в `Land.msh`, переводятся -в единицы мира делением на 32. Это отдельное преобразование ландшафта; -применять его к координатам размещённых объектов нельзя. +ландшафта составляет `0..1190.6976`, а исходный диапазон Z в `Land.msh` — +`0..94.50981`. Проверенный native frame с той же камерой совпадает с силуэтом +ландшафта при прямом использовании исходного Z; деление на 32 опускало бы этот +горизонт примерно на 185 пикселей. Terrain, camera floor, shadow receivers и +sun-ray queries поэтому используют исходные координаты `Land.msh` без +преобразования Z. `Ngi32!niGet3DRender` (RVA `0x5640`) возвращает графический интерфейс. Вызов Direct3D7 `SetTransform` устанавливает projection из RVA `0x7030` @@ -946,7 +949,23 @@ projected shadows и audio events обновляются вместе с кам `--atmosphere-seconds` задаёт старт времени, `--frames 0` оставляет цикл бесконечным, а `--preview-roots` служит только диагностическим ограничителем. `--legacy-camera-capture` выбирает воспроизводимую камеру без free-flight -управления. +управления: запись selector 0 задаёт D3D7 transform, clip параметры и native +viewport RECT `[left, top, right, bottom]`. Путь сохраняет оси basis из view +matrix вместе с roll, переносит полученный `clip_from_world` в preview camera, +переводит RECT в физический drawable размер `(right - left) × (bottom - top)` +и отклоняет несовпадение размера при +создании или resize вместо растягивания кадра. Результат нужно сравнивать с +тем же crop исходного PNG `[left, top, right, bottom]`. Контроллер +`FreeFlightCamera` в этом режиме не создаётся, но redraw продолжает обновлять +environment и projected shadows от captured camera. Перед environment update и +его audio events listener получает position, forward и up captured camera; +начальная сортировка world draw использует её же position. Phase capture может +содержать наблюдаемый mission path и проверенный `atmosphere_seconds`; viewer +использует эту абсолютную фазу по умолчанию, если не указан явный CLI override, +и удерживает sample атмосферы на ней через readback frames. FX по-прежнему +получает реальный frame time. Это не полное simulation time: `weather`, RNG и +остальное mission state остаются неизвестными, поэтому совпадение камеры и +фазы не доказывает visual parity с native frame. Для сохранения кадра surface должен поддерживать `TRANSFER_SRC`. Renderer копирует последний отправленный swapchain image в host-visible buffer и diff --git a/tools/native-frame-capture/NativeFrameCapture.Readback.cs b/tools/native-frame-capture/NativeFrameCapture.Readback.cs new file mode 100644 index 0000000..872487b --- /dev/null +++ b/tools/native-frame-capture/NativeFrameCapture.Readback.cs @@ -0,0 +1,1863 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Web.Script.Serialization; +using System.Drawing; +using System.Drawing.Imaging; + +internal static partial class NativeFrameCapture +{ + private const uint NgiRendererGlobalRva = 0x3A460; + private const uint NgiDeviceGlobalRva = 0x3A488; + private const uint NgiRendererVtableRva = 0x315E0; + private const uint NgiWindowedPresentRva = 0x6E1B; + private const uint NgiFullscreenPresentRva = 0x6E38; + private const uint DdLockReadOnly = 0x00000010; + private const uint DdLockDoNotWait = 0x00004000; + private const int D3dDeviceGetRenderTargetOffset = 0x24; + private const int DdSurfaceLockOffset = 0x64; + private const int DdSurfaceUnlockOffset = 0x80; + private const int DdSurfaceReleaseOffset = 0x08; + private const uint DdErrSurfaceBusy = 0x887601AE; + private const uint DdErrWasStillDrawing = 0x8876021C; + private const uint DdsdPitch = 0x00000008; + private const uint DdsdHeight = 0x00000002; + private const uint DdsdWidth = 0x00000004; + private const uint DdsdPixelFormat = 0x00001000; + private const uint DdsdLpSurface = 0x00000800; + private const uint DdpfFourCc = 0x00000004; + private const uint DdpfRgb = 0x00000040; + private const int DdSurfaceDesc2Size = 124; + private const int CameraInputJsonLimit = 1024 * 1024; + private const uint ContextAllX86 = 0x0001003F; + // THREAD_QUERY_INFORMATION from the Windows SDK; required by NtQueryInformationThread. + private const uint ThreadQueryInformation = 0x00000040; + private const int ContextEflagsOffset = 192; + private const int PixelReadbackLimit = 64 * 1024 * 1024; + private const uint RemoteCodePage = 0x1000; + private const uint RemoteDataOffset = 0x1000; + private const uint RemoteAllocationBytes = 0x2000; + private const uint RemoteStubMaxCallStackBytes = 24; + private const uint PageGuard = 0x00000100; + private const uint RemoteDataStatusOffset = 0; + private const uint RemoteDataGetHrOffset = 4; + private const uint RemoteDataLockHrOffset = 8; + private const uint RemoteDataUnlockHrOffset = 12; + private const uint RemoteDataReleaseCountOffset = 16; + private const uint RemoteDataSurfaceOffset = 20; + private const uint RemoteDataDescOffset = 24; + private const uint CameraSetterMatrixOffset = 64; + private const uint CameraTransformInterfaceVtableRva = 0x66558; + private const uint CameraTransformSetterRva = 0x54C70; + private const uint CameraTransformInvalidatorRva = 0x55280; + private const uint CameraTransformSetterSlotOffset = 0x1C; + private const uint CameraTransformInvalidatorSlotOffset = 0x30; + private const uint RenderFunctionStackFrameBytes = 0x70; + + [StructLayout(LayoutKind.Sequential)] + private struct ClientIdX86 + { + public IntPtr UniqueProcess; + public IntPtr UniqueThread; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ThreadBasicInformationX86 + { + public int ExitStatus; + public IntPtr TebBaseAddress; + public ClientIdX86 ClientId; + public UIntPtr AffinityMask; + public int Priority; + public int BasePriority; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr VirtualAllocEx(IntPtr process, IntPtr address, + UIntPtr size, uint allocationType, uint protection); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool VirtualFreeEx(IntPtr process, IntPtr address, + UIntPtr size, uint freeType); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateProcess(IntPtr process, uint exitCode); + + [DllImport("ntdll.dll")] + private static extern int NtQueryInformationThread(IntPtr thread, int informationClass, + out ThreadBasicInformationX86 information, int informationLength, IntPtr returnLength); + + private sealed class SurfaceReadback + { + public uint Width; + public uint Height; + public int Pitch; + public uint BitCount; + public uint RedMask; + public uint GreenMask; + public uint BlueMask; + public uint AlphaMask; + public uint SurfacePointer; + public byte[] Rows; + public string Sha256; + public bool RgbMasksVerified; + } + + private sealed class ProjectionReadback + { + public int[] Viewport; + public float Near; + public float Far; + public float Fov; + public byte Mode; + public uint Renderer; + public uint RendererVtable; + public bool Verified; + public string Failure; + } + + private sealed class CameraReadback + { + public uint Camera; + public uint Vtable; + public uint TransformInterface; + public uint TransformVtable; + public byte CameraMode; + public uint EntryEsp; + public uint ReturnAddress; + public uint SelectorField; + public uint[] Words; + public byte[] EntryMatrixBytes; + public byte[] ProjectionMatrixBytes; + public bool CurrentAtProjectionVerified; + public bool WordsChangedAtProjection; + public string EntryMatrixSha256; + public string ProjectionMatrixSha256; + public bool MatrixFinite; + public bool LayoutVerified; + public string Failure; + } + + private sealed class SelectedCameraInput + { + public string Path; + public byte[] MatrixBytes; + public uint[] MatrixWords; + public string MatrixSha256; + public int[] Viewport; + public float Near; + public float Far; + public float FieldOfView; + public byte ProjectionMode; + } + + private enum CameraSetterPhase { Apply, Restore } + + private sealed class RemoteCameraSetterSession + { + public RemoteCode Code; + public uint Region; + public uint Camera; + public uint ThreadId; + public uint OriginalEsp; + public byte[] OriginalContext; + public byte[] CallerStack; + public uint FunctionEntryEsp; + public uint FunctionReturnAddress; + public uint EsiAtBoundary; + public byte[] FunctionCallerStack; + public byte[] MatrixBytes; + public CameraSetterPhase Phase; + public BreakpointInfo ResumeBreakpoint; + public bool RearmOnStep; + public DateTime DeadlineUtc; + } + + private sealed class RemoteCode + { + public uint Base; + public uint TrapAddress; + public uint DataBase; + public byte[] Bytes; + public int[] CallInstructionOffsets; + public int MaxCallStackBytes; + } + + private static IntPtr AllocateAlignedX86Context(out IntPtr allocation) + { + allocation = Marshal.AllocHGlobal(X86ContextSize + 15); + long raw = allocation.ToInt64(); + return new IntPtr((raw + 15L) & ~15L); + } + + private static byte[] GetFullX86Context(IntPtr thread) + { + IntPtr allocation; + IntPtr context = AllocateAlignedX86Context(out allocation); + try + { + Marshal.Copy(new byte[X86ContextSize], 0, context, X86ContextSize); + Marshal.WriteInt32(context, 0, unchecked((int)ContextAllX86)); + if (!GetThreadContext(thread, context)) + throw new InvalidOperationException("GetThreadContext(CONTEXT_ALL_X86) failed: " + Marshal.GetLastWin32Error()); + byte[] bytes = new byte[X86ContextSize]; + Marshal.Copy(context, bytes, 0, bytes.Length); + return bytes; + } + finally { Marshal.FreeHGlobal(allocation); } + } + + private static void SetFullX86Context(IntPtr thread, byte[] bytes) + { + if (bytes == null || bytes.Length != X86ContextSize) + throw new ArgumentException("Expected a complete x86 CONTEXT buffer.", "bytes"); + IntPtr allocation; + IntPtr context = AllocateAlignedX86Context(out allocation); + try + { + Marshal.Copy(bytes, 0, context, bytes.Length); + Marshal.WriteInt32(context, 0, unchecked((int)ContextAllX86)); + if (!SetThreadContext(thread, context)) + throw new InvalidOperationException("SetThreadContext(CONTEXT_ALL_X86) failed: " + Marshal.GetLastWin32Error()); + } + finally { Marshal.FreeHGlobal(allocation); } + } + + private static byte[] ContextForRemoteCode(byte[] original, uint codeAddress, uint stackPointer) + { + if (original == null || original.Length != X86ContextSize) + throw new ArgumentException("Expected a complete x86 CONTEXT buffer.", "original"); + byte[] result = (byte[])original.Clone(); + Buffer.BlockCopy(BitConverter.GetBytes(unchecked((int)ContextAllX86)), 0, result, 0, 4); + Buffer.BlockCopy(BitConverter.GetBytes(unchecked((int)codeAddress)), 0, result, ContextEipOffset, 4); + Buffer.BlockCopy(BitConverter.GetBytes(unchecked((int)stackPointer)), 0, result, ContextEspOffset, 4); + int flags = BitConverter.ToInt32(result, ContextEflagsOffset) & ~0x100; + Buffer.BlockCopy(BitConverter.GetBytes(flags), 0, result, ContextEflagsOffset, 4); + return result; + } + + private static uint ComputeSafeNativeStackPointer(IntPtr process, IntPtr thread, byte[] originalContext) + { + if (originalContext == null || originalContext.Length != X86ContextSize) + throw new ArgumentException("Expected a complete x86 CONTEXT buffer.", "originalContext"); + ThreadBasicInformationX86 information; + int status = NtQueryInformationThread(thread, 0, out information, + Marshal.SizeOf(typeof(ThreadBasicInformationX86)), IntPtr.Zero); + if (status < 0 || information.TebBaseAddress == IntPtr.Zero) + throw new InvalidOperationException("NtQueryInformationThread(ThreadBasicInformation) failed: 0x" + status.ToString("X8")); + uint teb = unchecked((uint)information.TebBaseAddress.ToInt32()); + uint stackBase = ReadU32Exact(process, unchecked(teb + 4)); + uint stackLimit = ReadU32Exact(process, unchecked(teb + 8)); + uint originalEsp = BitConverter.ToUInt32(originalContext, ContextEspOffset); + if (!HasNativeStackBounds(stackLimit, stackBase, originalEsp, RemoteStubMaxCallStackBytes)) + { + Log("REMOTE_STACK_BOUNDS_INVALID teb=0x" + teb.ToString("X8") + + " stackLimit=0x" + stackLimit.ToString("X8") + " originalEsp=0x" + originalEsp.ToString("X8") + + " stackBase=0x" + stackBase.ToString("X8") + " callerBytes=" + RemoteStubMaxCallStackBytes); + throw new InvalidOperationException("Native render-thread ESP does not leave the verified caller-push bytes within its committed stack bounds."); + } + uint lowestPushByte = originalEsp - RemoteStubMaxCallStackBytes; + MemoryBasicInformation memory = new MemoryBasicInformation(); + UIntPtr queried = VirtualQueryEx(process, Ptr(lowestPushByte), out memory, + new UIntPtr((uint)Marshal.SizeOf(typeof(MemoryBasicInformation)))); + uint regionBase = unchecked((uint)memory.BaseAddress.ToInt32()); + uint regionSize = memory.RegionSize.ToUInt32(); + string stackEvidence = "state=0x" + memory.State.ToString("X8") + + " protect=0x" + memory.Protect.ToString("X8") + " region=0x" + regionBase.ToString("X8") + + "+0x" + regionSize.ToString("X8"); + bool stackRangeWritable = queried.ToUInt32() != 0 && IsSafeNativeStackRange(stackLimit, stackBase, + originalEsp, RemoteStubMaxCallStackBytes, regionBase, regionSize, memory.State, memory.Protect); + Log("REMOTE_STACK_BOUNDS teb=0x" + teb.ToString("X8") + + " stackLimit=0x" + stackLimit.ToString("X8") + " originalEsp=0x" + originalEsp.ToString("X8") + + " stackBase=0x" + stackBase.ToString("X8") + " lowestPushByte=0x" + lowestPushByte.ToString("X8") + + " callerBytes=" + RemoteStubMaxCallStackBytes + " " + stackEvidence + " writable=" + stackRangeWritable); + if (!stackRangeWritable) + throw new InvalidOperationException("The full present-thread stub push range is not in one committed writable stack region or touches a guard page."); + return originalEsp; + } + + private static bool HasNativeStackBounds(uint stackLimit, uint stackBase, uint originalEsp, uint callerBytes) + { + return callerBytes != 0 && originalEsp > stackLimit && originalEsp < stackBase + && originalEsp >= callerBytes && originalEsp - callerBytes >= stackLimit; + } + + private static bool IsSafeNativeStackRange(uint stackLimit, uint stackBase, uint originalEsp, + uint callerBytes, uint regionBase, uint regionSize, uint state, uint protect) + { + if (!HasNativeStackBounds(stackLimit, stackBase, originalEsp, callerBytes) || regionSize == 0) + return false; + uint lowestPushByte = originalEsp - callerBytes; + ulong regionEnd = (ulong)regionBase + regionSize; + uint protection = protect & 0xFF; + bool writableProtection = protection == 0x04 || protection == 0x08 + || protection == 0x40 || protection == 0x80; + return state == MemCommit && (protect & PageGuard) == 0 && writableProtection + && lowestPushByte >= regionBase && (ulong)originalEsp <= regionEnd; + } + + private sealed class X86CodeBuilder + { + private readonly List _bytes = new List(); + private readonly Dictionary _labels = new Dictionary(StringComparer.Ordinal); + private readonly List> _relative32 = new List>(); + private readonly List _callInstructionOffsets = new List(); + private int _int3Count; + private int _terminalOffset = -1; + private int _currentPushBytes; + private int _maxCallStackBytes; + + public int Position { get { return _bytes.Count; } } + + public void Emit(params byte[] bytes) + { + _bytes.AddRange(bytes); + } + + public void EmitU32(uint value) + { + _bytes.AddRange(BitConverter.GetBytes(value)); + } + + public void PushReg(byte opcode) + { + if (opcode < 0x50 || opcode > 0x57) + throw new InvalidOperationException("Expected one x86 push-register opcode."); + _bytes.Add(opcode); + _currentPushBytes = checked(_currentPushBytes + 4); + } + + public void PushImm8(byte value) + { + _bytes.Add(0x6A); + _bytes.Add(value); + _currentPushBytes = checked(_currentPushBytes + 4); + } + + public void PushImm32(uint value) + { + _bytes.Add(0x68); + EmitU32(value); + _currentPushBytes = checked(_currentPushBytes + 4); + } + + public void CallStdCall(int argumentBytes, params byte[] opcode) + { + if (argumentBytes < 0 || (argumentBytes & 3) != 0 || _currentPushBytes != argumentBytes + || opcode == null || opcode.Length == 0) + throw new InvalidOperationException("x86 stdcall must match the emitted dword arguments."); + _maxCallStackBytes = Math.Max(_maxCallStackBytes, checked(argumentBytes + 4)); + _callInstructionOffsets.Add(_bytes.Count); + _bytes.AddRange(opcode); + // IDirect3DDevice7/IDirectDrawSurface7 vtable methods use STDMETHODCALLTYPE + // (stdcall), so the callee removes its arguments before returning. + _currentPushBytes -= argumentBytes; + } + + public void Mark(string label) + { + if (_labels.ContainsKey(label)) throw new InvalidOperationException("Duplicate x86 label: " + label); + _labels.Add(label, _bytes.Count); + } + + public void JumpIf(byte condition, string label) + { + _bytes.Add(0x0F); + _bytes.Add((byte)(0x80 | condition)); + int operand = _bytes.Count; + EmitU32(0); + _relative32.Add(new KeyValuePair(operand, label)); + } + + public void Jump(string label) + { + _bytes.Add(0xE9); + int operand = _bytes.Count; + EmitU32(0); + _relative32.Add(new KeyValuePair(operand, label)); + } + + public void EmitTerminalInt3() + { + _terminalOffset = _bytes.Count; + _bytes.Add(0xCC); + _int3Count++; + } + + public int Int3Count { get { return _int3Count; } } + public int TerminalOffset { get { return _terminalOffset; } } + public int BranchCount { get { return _relative32.Count; } } + public int MaxCallStackBytes { get { return _maxCallStackBytes; } } + public int[] CallInstructionOffsets { get { return _callInstructionOffsets.ToArray(); } } + + public void AssertTerminalControlFlow(byte[] finishedBytes, int expectedBranchCount) + { + if (finishedBytes == null || _int3Count != 1 || _terminalOffset != finishedBytes.Length - 1 + || _terminalOffset < 0 || finishedBytes[_terminalOffset] != 0xCC + || _relative32.Count != expectedBranchCount || _currentPushBytes != 0) + throw new InvalidOperationException("Readback stub must have one terminal INT3 and the expected branch count."); + + // Validate only branches registered by Jump/JumpIf. A rel32 operand + // can itself contain 0xCC bytes, which are not instructions. + for (int i = 0; i < _relative32.Count; i++) + { + KeyValuePair branch = _relative32[i]; + int labelOffset; + if (!_labels.TryGetValue(branch.Value, out labelOffset) + || branch.Key < 0 || branch.Key + 4 > finishedBytes.Length) + throw new InvalidOperationException("Readback stub has an invalid branch record."); + int resolvedOffset = checked(branch.Key + 4 + BitConverter.ToInt32(finishedBytes, branch.Key)); + if (resolvedOffset != labelOffset || resolvedOffset < 0 || resolvedOffset > _terminalOffset) + throw new InvalidOperationException("Readback stub branch does not resolve to a valid instruction label."); + } + } + + public byte[] Finish() + { + for (int i = 0; i < _relative32.Count; i++) + { + KeyValuePair fixup = _relative32[i]; + int destination; + if (!_labels.TryGetValue(fixup.Value, out destination)) + throw new InvalidOperationException("Unresolved x86 label: " + fixup.Value); + int relative = destination - (fixup.Key + 4); + byte[] bytes = BitConverter.GetBytes(relative); + for (int j = 0; j < 4; j++) _bytes[fixup.Key + j] = bytes[j]; + } + return _bytes.ToArray(); + } + } + + private static RemoteCode BuildReadbackStub(uint allocationBase, uint device, bool cleanup) + { + uint data = unchecked(allocationBase + RemoteDataOffset); + uint statusAddress = unchecked(data + RemoteDataStatusOffset); + uint getHrAddress = unchecked(data + RemoteDataGetHrOffset); + uint lockHrAddress = unchecked(data + RemoteDataLockHrOffset); + uint unlockHrAddress = unchecked(data + RemoteDataUnlockHrOffset); + uint releaseAddress = unchecked(data + RemoteDataReleaseCountOffset); + uint surfaceAddress = unchecked(data + RemoteDataSurfaceOffset); + uint descAddress = unchecked(data + RemoteDataDescOffset); + + X86CodeBuilder code = new X86CodeBuilder(); + if (!cleanup) + { + code.Emit(0xBE); code.EmitU32(device); // mov esi, device + code.Emit(0x8B, 0x06); // mov eax, [esi] + code.Emit(0x8D, 0x3D); code.EmitU32(surfaceAddress); // lea edi, [surface] + code.PushReg(0x57); code.PushReg(0x56); // push edi; push esi + code.CallStdCall(8, 0xFF, 0x50, (byte)D3dDeviceGetRenderTargetOffset); // call [eax+GetRenderTarget] + code.Emit(0xA3); code.EmitU32(getHrAddress); // mov [getHr], eax + code.Emit(0x85, 0xC0); // test eax,eax + code.JumpIf(0x89, "get_success"); // jns get_success + code.Emit(0x8B, 0x35); code.EmitU32(surfaceAddress); // mov esi, [surface] + code.Emit(0x85, 0xF6); // test esi,esi + code.JumpIf(0x84, "get_failed_no_surface"); + code.Emit(0xC7, 0x05); code.EmitU32(statusAddress); code.EmitU32(2); + code.Jump("release_and_stop"); + + code.Mark("get_success"); + code.Emit(0x8B, 0x35); code.EmitU32(surfaceAddress); // mov esi, [surface] + code.Emit(0x85, 0xF6); // test esi,esi + code.JumpIf(0x84, "get_success_no_surface"); + code.Emit(0x8B, 0x06); // mov eax, [esi] + code.PushImm8(0x00); // push NULL event + code.PushImm32(DdLockReadOnly | DdLockDoNotWait); + code.Emit(0x8D, 0x3D); code.EmitU32(descAddress); // lea edi,[desc] + code.PushReg(0x57); code.PushImm8(0x00); code.PushReg(0x56); // desc; NULL rect; this + code.CallStdCall(20, 0xFF, 0x50, (byte)DdSurfaceLockOffset); // call [eax+Lock] + code.Emit(0xA3); code.EmitU32(lockHrAddress); // mov [lockHr], eax + code.Emit(0x85, 0xC0); // test eax,eax + code.JumpIf(0x88, "lock_failed"); // js lock_failed + code.Emit(0xC7, 0x05); code.EmitU32(statusAddress); code.EmitU32(1); + code.Jump("stop"); // lock held; host copies pixels, then cleans up + + code.Mark("lock_failed"); + code.Emit(0xC7, 0x05); code.EmitU32(statusAddress); code.EmitU32(4); + code.Jump("release_and_stop"); + + code.Mark("get_success_no_surface"); + code.Emit(0xC7, 0x05); code.EmitU32(statusAddress); code.EmitU32(3); + code.Jump("stop"); + + code.Mark("get_failed_no_surface"); + code.Emit(0xC7, 0x05); code.EmitU32(statusAddress); code.EmitU32(5); + code.Jump("stop"); + + code.Mark("release_and_stop"); + code.Emit(0x8B, 0x35); code.EmitU32(surfaceAddress); + code.Emit(0x8B, 0x06); // mov eax, [esi] + code.PushReg(0x56); // push this + code.CallStdCall(4, 0xFF, 0x50, (byte)DdSurfaceReleaseOffset); // call [eax+Release] + code.Emit(0xA3); code.EmitU32(releaseAddress); + code.Jump("stop"); + } + else + { + code.Emit(0x8B, 0x35); code.EmitU32(surfaceAddress); // mov esi, [surface] + code.Emit(0x85, 0xF6); + code.JumpIf(0x84, "cleanup_no_surface"); + code.Emit(0x8B, 0x06); // mov eax, [esi] + code.PushImm8(0x00); // Unlock(NULL) + code.PushReg(0x56); // push this + code.CallStdCall(8, 0xFF, 0x90, 0x80, 0x00, 0x00, 0x00); // call dword ptr [eax+0x80] (disp32) + code.Emit(0xA3); code.EmitU32(unlockHrAddress); + code.Emit(0x8B, 0x06); // call Release even if Unlock failed + code.PushReg(0x56); + code.CallStdCall(4, 0xFF, 0x50, (byte)DdSurfaceReleaseOffset); + code.Emit(0xA3); code.EmitU32(releaseAddress); + code.Emit(0xC7, 0x05); code.EmitU32(statusAddress); code.EmitU32(6); + code.Jump("stop"); + code.Mark("cleanup_no_surface"); + code.Emit(0xC7, 0x05); code.EmitU32(statusAddress); code.EmitU32(7); + code.Jump("stop"); + } + + code.Mark("stop"); + code.EmitTerminalInt3(); + + byte[] bytes = code.Finish(); + code.AssertTerminalControlFlow(bytes, cleanup ? 3 : 10); + int expectedStack = cleanup ? 12 : 24; + if (code.MaxCallStackBytes != expectedStack) + throw new InvalidOperationException("Unexpected x86 caller stack footprint in the readback stub."); + if (bytes.Length > RemoteCodePage) + throw new InvalidOperationException("Remote DirectDraw stub exceeds one code page."); + RemoteCode result = new RemoteCode(); + result.Base = allocationBase; + result.DataBase = data; + result.Bytes = bytes; + result.CallInstructionOffsets = code.CallInstructionOffsets; + result.MaxCallStackBytes = code.MaxCallStackBytes; + result.TrapAddress = unchecked(allocationBase + (uint)code.TerminalOffset); + return result; + } + + private static RemoteCode BuildCameraSetterStub(uint allocationBase, uint transformInterface) + { + uint dataBase = unchecked(allocationBase + RemoteDataOffset); + uint matrixAddress = unchecked(dataBase + CameraSetterMatrixOffset); + X86CodeBuilder code = new X86CodeBuilder(); + code.Emit(0xBE); code.EmitU32(transformInterface); // mov esi, transform interface + code.Emit(0x8B, 0x0E); // mov ecx, [esi] (verified vtable) + code.PushImm32(matrixAddress); // argument 3: 64-byte transform + code.PushImm8(1); // argument 2: mode 1 + code.PushReg(0x56); // argument 1: this + code.CallStdCall(12, 0xFF, 0x51, (byte)CameraTransformSetterSlotOffset); + code.EmitTerminalInt3(); + byte[] bytes = code.Finish(); + code.AssertTerminalControlFlow(bytes, 0); + if (code.MaxCallStackBytes != 16 || bytes.Length > RemoteCodePage) + throw new InvalidOperationException("Camera setter stub has an unexpected stack footprint or size."); + RemoteCode result = new RemoteCode(); + result.Base = allocationBase; + result.DataBase = dataBase; + result.Bytes = bytes; + result.CallInstructionOffsets = code.CallInstructionOffsets; + result.MaxCallStackBytes = code.MaxCallStackBytes; + result.TrapAddress = unchecked(allocationBase + (uint)code.TerminalOffset); + return result; + } + + private static bool VerifyCameraSetterAbi(IntPtr process, uint terrainBase, uint camera, + out uint transformInterface, out string evidence) + { + transformInterface = unchecked(camera + 4); + uint primaryVtable = ReadU32(process, camera); + uint selector = ReadU32(process, unchecked(camera + 0x10)); + byte[] modeBytes = new byte[1]; + bool modeRead = ReadExact(process, unchecked(camera + 0x1A0), modeBytes); + uint transformVtable = ReadU32(process, transformInterface); + uint setter = ReadU32(process, unchecked(transformVtable + CameraTransformSetterSlotOffset)); + uint invalidate = ReadU32(process, unchecked(transformVtable + CameraTransformInvalidatorSlotOffset)); + bool valid = terrainBase != 0 + && primaryVtable == unchecked(terrainBase + ExternalCameraVtableRva) + && transformVtable == unchecked(terrainBase + CameraTransformInterfaceVtableRva) + && setter == unchecked(terrainBase + CameraTransformSetterRva) + && invalidate == unchecked(terrainBase + CameraTransformInvalidatorRva) + && selector == 0xFFFFFFFF && modeRead && modeBytes[0] == 0; + evidence = "camera=0x" + camera.ToString("X8") + " primaryVtable=0x" + primaryVtable.ToString("X8") + + " transform=0x" + transformInterface.ToString("X8") + " transformVtable=0x" + transformVtable.ToString("X8") + + " setter=0x" + setter.ToString("X8") + " invalidator=0x" + invalidate.ToString("X8") + + " selector=0x" + selector.ToString("X8") + + " mode=" + (modeRead ? modeBytes[0].ToString(CultureInfo.InvariantCulture) : "unreadable") + + " valid=" + valid; + return valid; + } + + private static RemoteCameraSetterSession StartCameraSetter(IntPtr process, uint threadId, + uint terrainBase, uint camera, byte[] matrixBytes, byte[] originalContext, + BreakpointInfo resumeBreakpoint, bool rearmOnStep, CameraSetterPhase phase, + uint functionEntryEsp, uint functionReturnAddress) + { + if (matrixBytes == null || matrixBytes.Length != 64 || originalContext == null + || originalContext.Length != X86ContextSize || resumeBreakpoint == null || !resumeBreakpoint.Armed) + throw new InvalidOperationException("Camera setter request is incomplete."); + string abiEvidence; + uint transformInterface; + if (!VerifyCameraSetterAbi(process, terrainBase, camera, out transformInterface, out abiEvidence)) + throw new InvalidOperationException("Camera setter ABI refused: " + abiEvidence); + Log("verified native camera setter ABI " + abiEvidence + " slot=+0x1C this=0x" + + transformInterface.ToString("X8") + " mode=1 matrixBytes=64 ret=0x0C"); + + uint expectedEip = unchecked(resumeBreakpoint.Address + 1); + uint originalEip = BitConverter.ToUInt32(originalContext, ContextEipOffset); + uint originalEsp = BitConverter.ToUInt32(originalContext, ContextEspOffset); + if (originalEip != expectedEip || originalEsp > UInt32.MaxValue - 8 + || functionEntryEsp > UInt32.MaxValue - 8) + throw new InvalidOperationException("Camera setter breakpoint EIP/ESP did not match its verified boundary."); + byte[] callerStack = new byte[8]; + if (!ReadExact(process, originalEsp, callerStack)) + throw new InvalidOperationException("Could not snapshot the actual native stack words at the setter boundary."); + byte[] functionCallerStack = new byte[8]; + if (!ReadExact(process, functionEntryEsp, functionCallerStack) + || !CameraFunctionStackMatches(phase, functionCallerStack, functionReturnAddress, camera)) + throw new InvalidOperationException("The native stdRenderGame return/argument stack did not match the selected setter phase."); + uint esiAtBoundary = BitConverter.ToUInt32(originalContext, ContextEsiOffset); + if (phase == CameraSetterPhase.Apply) + { + if (originalEsp != functionEntryEsp || BitConverter.ToUInt32(callerStack, 4) != camera) + throw new InvalidOperationException("The camera-entry setter stack did not contain the original camera argument."); + } + else + { + if (functionEntryEsp < RenderFunctionStackFrameBytes + || originalEsp != functionEntryEsp - RenderFunctionStackFrameBytes) + throw new InvalidOperationException("The restore boundary did not preserve the selected function ESP."); + } + + IntPtr thread = OpenThread(ThreadGetContext | ThreadSetContext | ThreadQueryInformation, false, threadId); + if (thread == IntPtr.Zero) throw new InvalidOperationException("OpenThread for camera setter failed: " + Marshal.GetLastWin32Error()); + uint region = 0; + try + { + uint safeEsp = ComputeSafeNativeStackPointer(process, thread, originalContext); + region = AllocateReadbackRegion(process); + RemoteCode code = BuildCameraSetterStub(region, transformInterface); + WriteRemoteStub(process, code, false); + UIntPtr written; + if (!WriteProcessMemory(process, Ptr(unchecked(code.DataBase + CameraSetterMatrixOffset)), matrixBytes, + new UIntPtr(64), out written) || written.ToUInt32() != 64) + throw new InvalidOperationException("Could not copy the validated 64-byte camera matrix to the bounded setter stub."); + RemoteCameraSetterSession session = new RemoteCameraSetterSession(); + session.Code = code; + session.Region = region; + session.Camera = camera; + session.ThreadId = threadId; + session.OriginalEsp = originalEsp; + session.OriginalContext = (byte[])originalContext.Clone(); + session.CallerStack = callerStack; + session.FunctionEntryEsp = functionEntryEsp; + session.FunctionReturnAddress = functionReturnAddress; + session.EsiAtBoundary = esiAtBoundary; + session.FunctionCallerStack = functionCallerStack; + session.MatrixBytes = (byte[])matrixBytes.Clone(); + session.Phase = phase; + session.ResumeBreakpoint = resumeBreakpoint; + session.RearmOnStep = rearmOnStep; + session.DeadlineUtc = DateTime.UtcNow.AddSeconds(5); + SetFullX86Context(thread, ContextForRemoteCode(originalContext, code.Base, safeEsp)); + Log("CAMERA_SETTER_STARTED phase=" + phase + " tid=" + threadId + + " camera=0x" + camera.ToString("X8") + " stub=0x" + code.Base.ToString("X8") + + " terminal=0x" + code.TrapAddress.ToString("X8") + " originalEsp=0x" + originalEsp.ToString("X8") + + " currentStack=" + BitConverter.ToString(callerStack) + + " functionCallerStack=" + BitConverter.ToString(functionCallerStack) + + " observedFunctionArgument=0x" + BitConverter.ToUInt32(functionCallerStack, 4).ToString("X8") + + " esiAtBoundary=0x" + esiAtBoundary.ToString("X8") + + " matrixSha256=" + HashBytes(matrixBytes)); + return session; + } + catch + { + if (region != 0) VirtualFreeEx(process, Ptr(region), UIntPtr.Zero, 0x8000); + throw; + } + finally { CloseHandle(thread); } + } + + private static bool VerifyCameraSetterReturnContext(IntPtr process, + RemoteCameraSetterSession session, out string evidence) + { + evidence = "unverified"; + IntPtr thread = OpenThread(ThreadGetContext, false, session.ThreadId); + if (thread == IntPtr.Zero) + { + evidence = "OpenThread failed=" + Marshal.GetLastWin32Error(); + return false; + } + try + { + byte[] context = GetFullX86Context(thread); + uint eip = BitConverter.ToUInt32(context, ContextEipOffset); + uint esp = BitConverter.ToUInt32(context, ContextEspOffset); + byte[] callerStack = new byte[session.CallerStack.Length]; + byte[] functionCallerStack = new byte[session.FunctionCallerStack.Length]; + byte[] matrix = new byte[session.MatrixBytes.Length]; + bool stackReadable = ReadExact(process, session.OriginalEsp, callerStack); + bool functionStackReadable = ReadExact(process, session.FunctionEntryEsp, functionCallerStack); + bool matrixReadable = ReadExact(process, + unchecked(session.Code.DataBase + CameraSetterMatrixOffset), matrix); + bool stackMatches = stackReadable && ByteArraysEqual(callerStack, session.CallerStack); + bool functionStackMatches = functionStackReadable + && ByteArraysEqual(functionCallerStack, session.FunctionCallerStack) + && CameraFunctionStackMatches(session.Phase, functionCallerStack, + session.FunctionReturnAddress, session.Camera); + bool matrixMatches = matrixReadable && ByteArraysEqual(matrix, session.MatrixBytes); + bool valid = eip == unchecked(session.Code.TrapAddress + 1) + && esp == session.OriginalEsp && stackMatches && functionStackMatches + && matrixMatches; + evidence = "expectedEip=0x" + unchecked(session.Code.TrapAddress + 1).ToString("X8") + + " actualEip=0x" + eip.ToString("X8") + " expectedEsp=0x" + session.OriginalEsp.ToString("X8") + + " actualEsp=0x" + esp.ToString("X8") + " callerStackIntact=" + stackMatches + + " functionCallerStackIntact=" + functionStackMatches + + " observedFunctionArgument=0x" + BitConverter.ToUInt32(session.FunctionCallerStack, 4).ToString("X8") + + " esiAtBoundary=0x" + session.EsiAtBoundary.ToString("X8") + + " matrixDataIntact=" + matrixMatches; + return valid; + } + finally { CloseHandle(thread); } + } + + private static byte[] ReadCameraMatrix(IntPtr process, uint camera) + { + byte[] matrix = new byte[64]; + if (!ReadExact(process, unchecked(camera + 0x20), matrix)) + throw new InvalidOperationException("Could not read back the native camera selector-0 matrix."); + return matrix; + } + + private static bool CameraFunctionStackMatches(CameraSetterPhase phase, byte[] stackWords, + uint expectedReturnAddress, uint camera) + { + if (stackWords == null || stackWords.Length != 8 + || BitConverter.ToUInt32(stackWords, 0) != expectedReturnAddress) + return false; + return phase == CameraSetterPhase.Restore + || BitConverter.ToUInt32(stackWords, 4) == camera; + } + + private static void SelfCheckRenderInvocationGates() + { + const uint threadId = 11; + const uint eip = 0x10013CE5; + const uint esp = 0x001AFC28; + const uint camera = 0x1644A8D8; + const uint returnAddress = 0x1005F243; + uint observedArgument; + bool overwrittenArgumentAccepted = RenderInvocationMatches(threadId, threadId, + eip, eip, esp, esp, camera, camera, returnAddress, returnAddress, true, + 0x0BADF00D, out observedArgument) && observedArgument == 0x0BADF00D; + bool wrongThreadRejected = !RenderInvocationMatches(threadId + 1, threadId, + eip, eip, esp, esp, camera, camera, returnAddress, returnAddress, true, + 0x0BADF00D, out observedArgument); + bool wrongEsiRejected = !RenderInvocationMatches(threadId, threadId, + eip, eip, esp, esp, camera + 4, camera, returnAddress, returnAddress, true, + 0x0BADF00D, out observedArgument); + bool wrongReturnRejected = !RenderInvocationMatches(threadId, threadId, + eip, eip, esp, esp, camera, camera, returnAddress + 4, returnAddress, false, + 0x0BADF00D, out observedArgument); + bool epilogueAllowsReassignedEsi = RenderInvocationMatches(threadId, threadId, + eip, eip, esp, esp, camera + 0x100, camera, returnAddress, returnAddress, false, + 0x0BADF00D, out observedArgument); + byte[] entryStack = new byte[8]; + Buffer.BlockCopy(BitConverter.GetBytes(returnAddress), 0, entryStack, 0, 4); + Buffer.BlockCopy(BitConverter.GetBytes(camera), 0, entryStack, 4, 4); + byte[] overwrittenStack = (byte[])entryStack.Clone(); + Buffer.BlockCopy(BitConverter.GetBytes(0x0BADF00Du), 0, overwrittenStack, 4, 4); + bool applyRequiresOriginalCameraArgument = CameraFunctionStackMatches( + CameraSetterPhase.Apply, entryStack, returnAddress, camera) + && !CameraFunctionStackMatches(CameraSetterPhase.Apply, overwrittenStack, returnAddress, camera); + bool restorePreservesOverwrittenArgument = CameraFunctionStackMatches( + CameraSetterPhase.Restore, overwrittenStack, returnAddress, camera) + && !CameraFunctionStackMatches(CameraSetterPhase.Restore, overwrittenStack, returnAddress + 4, camera); + if (!overwrittenArgumentAccepted || !wrongThreadRejected || !wrongEsiRejected + || !epilogueAllowsReassignedEsi + || !wrongReturnRejected || !applyRequiresOriginalCameraArgument + || !restorePreservesOverwrittenArgument) + throw new InvalidOperationException("Self-check failed: selected invocation identity or phase-specific camera stack gate."); + } + + private static bool IsSelectedRenderInvocation(IntPtr process, byte[] context, uint threadId, + SelectedCameraState selected, uint expectedBreakpointEip, bool requireEsiCamera, + out string evidence) + { + if (selected == null) + { + evidence = "selected invocation state is missing"; + return false; + } + if (threadId != selected.ThreadId) + { + evidence = "actualTid=" + threadId + " expectedTid=" + selected.ThreadId + + " camera=0x" + selected.CameraPointer.ToString("X8"); + return false; + } + return IsRenderInvocation(process, context, threadId, selected.ThreadId, + selected.CameraPointer, selected.EntryEsp, selected.FunctionStackEsp, + selected.ReturnAddress, expectedBreakpointEip, requireEsiCamera, out evidence); + } + + private static bool IsRenderInvocation(IntPtr process, byte[] context, uint threadId, + uint expectedThreadId, uint camera, uint entryEsp, uint functionStackEsp, + uint returnAddress, uint expectedBreakpointEip, bool requireEsiCamera, out string evidence) + { + uint actualEip = 0; + uint actualEsp = 0; + uint actualEsi = 0; + if (context != null && context.Length == X86ContextSize) + { + actualEip = BitConverter.ToUInt32(context, ContextEipOffset); + actualEsp = BitConverter.ToUInt32(context, ContextEspOffset); + actualEsi = BitConverter.ToUInt32(context, ContextEsiOffset); + } + byte[] callerStack = new byte[8]; + bool stackReadable = entryEsp <= UInt32.MaxValue - 8 + && ReadExact(process, entryEsp, callerStack); + uint savedReturn = stackReadable ? BitConverter.ToUInt32(callerStack, 0) : 0; + uint observedArgument = stackReadable ? BitConverter.ToUInt32(callerStack, 4) : 0; + uint diagnosticArgument; + bool valid = RenderInvocationMatches(threadId, expectedThreadId, actualEip, + expectedBreakpointEip, actualEsp, functionStackEsp, actualEsi, camera, + savedReturn, returnAddress, requireEsiCamera, observedArgument, out diagnosticArgument) && stackReadable; + evidence = "actualTid=" + threadId + " expectedTid=" + expectedThreadId + + " actualEip=0x" + actualEip.ToString("X8") + " expectedEip=0x" + expectedBreakpointEip.ToString("X8") + + " actualEsp=0x" + actualEsp.ToString("X8") + " expectedFunctionEsp=0x" + functionStackEsp.ToString("X8") + + " entryEsp=0x" + entryEsp.ToString("X8") + " savedReturn=0x" + savedReturn.ToString("X8") + + " expectedReturn=0x" + returnAddress.ToString("X8") + " observedEntryArgument=0x" + diagnosticArgument.ToString("X8") + + " entryArgumentMayBeOverwritten=true esi=0x" + actualEsi.ToString("X8") + + " esiRequired=" + requireEsiCamera + + " expectedCamera=0x" + camera.ToString("X8") + " stackReadable=" + stackReadable + + " match=" + valid; + return valid; + } + + private static bool RenderInvocationMatches(uint threadId, uint expectedThreadId, + uint eip, uint expectedEip, uint esp, uint expectedEsp, uint esi, uint camera, + uint savedReturn, uint expectedReturn, bool requireEsiCamera, + uint observedArgument, out uint diagnosticArgument) + { + // The camera argument's original stack slot becomes a COM output pointer + // during World3D's selector-6 query. Keep it in diagnostics, not identity. + diagnosticArgument = observedArgument; + return threadId == expectedThreadId && eip == expectedEip && esp == expectedEsp + && (!requireEsiCamera || esi == camera) && savedReturn == expectedReturn; + } + + private static RemoteReadbackSession StartRemoteReadback(IntPtr process, uint threadId, uint ngiBase, + BreakpointInfo present, FrameSnapshot frame, string outputJson) + { + uint device; + uint getRenderTarget; + string evidence; + if (!VerifyD3d7GetRenderTarget(process, ngiBase, out device, out getRenderTarget, out evidence)) + throw new InvalidOperationException("D3D7 GetRenderTarget call was refused: " + evidence); + Log("verified D3D7 GetRenderTarget ABI " + evidence); + if (frame == null || frame.Projection == null + || ReadU32(process, unchecked(ngiBase + NgiRendererGlobalRva)) != frame.Projection.Renderer) + throw new InvalidOperationException("Ngi32 renderer changed since the matching projection snapshot."); + if (ReadU32(process, unchecked(frame.Projection.Renderer + 0x114)) == 0) + throw new InvalidOperationException("Ngi32 is not in the verified windowed present path for RVA 0x6E1B."); + + IntPtr thread = OpenThread(ThreadGetContext | ThreadSetContext | ThreadQueryInformation, false, threadId); + if (thread == IntPtr.Zero) throw new InvalidOperationException("OpenThread for D3D7 readback failed: " + Marshal.GetLastWin32Error()); + uint region = 0; + try + { + byte[] originalContext = GetFullX86Context(thread); + uint stoppedEip = BitConverter.ToUInt32(originalContext, ContextEipOffset); + if (stoppedEip != unchecked(present.Address + 1)) + throw new InvalidOperationException("Ngi32 present EIP did not match the armed callsite."); + uint safeEsp = ComputeSafeNativeStackPointer(process, thread, originalContext); + uint originalEsp = BitConverter.ToUInt32(originalContext, ContextEspOffset); + if (originalEsp > UInt32.MaxValue - PresentCallArgumentsBytes) + throw new InvalidOperationException("Ngi32 present argument block address overflowed."); + byte[] presentStackArguments = new byte[PresentCallArgumentsBytes]; + if (!ReadExact(process, originalEsp, presentStackArguments)) + throw new InvalidOperationException("Could not read the original Ngi32 present call's 24-byte argument block."); + region = AllocateReadbackRegion(process); + RemoteCode acquire = BuildReadbackStub(region, device, false); + WriteRemoteStub(process, acquire, true); + RemoteReadbackSession session = new RemoteReadbackSession(); + session.Acquire = acquire; + session.Region = region; + session.ThreadId = threadId; + session.SafeEsp = safeEsp; + session.OriginalContext = originalContext; + session.PresentStackArguments = presentStackArguments; + session.PresentStackArgumentsIntact = true; + session.RemoteContextIntact = true; + session.PresentBreakpoint = present; + session.Frame = frame; + session.Generation = frame.CameraGeneration; + session.OutputJson = outputJson; + session.OutputPng = Path.ChangeExtension(outputJson, ".png"); + session.DeadlineUtc = DateTime.UtcNow.AddSeconds(5); + session.Phase = RemoteReadbackPhase.Acquire; + Log("PRESENT_STACK_ARGS stage=before-com esp=0x" + originalEsp.ToString("X8") + + " bytes=" + BitConverter.ToString(presentStackArguments)); + Log("REMOTE_ACQUIRE_STARTED tid=" + threadId + " stub=0x" + acquire.Base.ToString("X8") + + " terminal=0x" + acquire.TrapAddress.ToString("X8") + " safeEsp=0x" + safeEsp.ToString("X8") + + " generation=" + session.Generation); + SetFullX86Context(thread, ContextForRemoteCode(originalContext, acquire.Base, safeEsp)); + return session; + } + catch + { + if (region != 0) VirtualFreeEx(process, Ptr(region), UIntPtr.Zero, 0x8000); + throw; + } + finally { CloseHandle(thread); } + } + + private static void BeginRemoteCleanup(IntPtr process, RemoteReadbackSession session) + { + session.Cleanup = BuildReadbackStub(session.Region, 0, true); + WriteRemoteStub(process, session.Cleanup, false); + IntPtr thread = OpenThread(ThreadGetContext | ThreadSetContext, false, session.ThreadId); + if (thread == IntPtr.Zero) throw new InvalidOperationException("OpenThread for D3D7 Unlock/Release failed: " + Marshal.GetLastWin32Error()); + try + { + SetFullX86Context(thread, ContextForRemoteCode(session.OriginalContext, session.Cleanup.Base, session.SafeEsp)); + } + finally { CloseHandle(thread); } + session.Phase = RemoteReadbackPhase.Cleanup; + session.DeadlineUtc = DateTime.UtcNow.AddSeconds(5); + Log("REMOTE_CLEANUP_STARTED tid=" + session.ThreadId + " stub=0x" + session.Cleanup.Base.ToString("X8") + + " terminal=0x" + session.Cleanup.TrapAddress.ToString("X8")); + } + + private static uint AllocateReadbackRegion(IntPtr process) + { + const uint MemReserve = 0x2000; + IntPtr memory = VirtualAllocEx(process, IntPtr.Zero, new UIntPtr(RemoteAllocationBytes), + MemReserve | MemCommit, 0x04); + if (memory == IntPtr.Zero) throw new InvalidOperationException("VirtualAllocEx for bounded readback stub failed: " + Marshal.GetLastWin32Error()); + uint address = unchecked((uint)memory.ToInt32()); + if (address == 0 || address + RemoteAllocationBytes < address) + { + VirtualFreeEx(process, memory, UIntPtr.Zero, 0x8000); + throw new InvalidOperationException("VirtualAllocEx returned an invalid x86 address."); + } + // Keep the executable stub on its own read/execute page. The result + // structure stays writable; COM uses the stopped render thread's + // native stack after a TEB stack-bound check. + uint oldProtection; + if (!VirtualProtectEx(process, memory, new UIntPtr(RemoteCodePage), 0x20, out oldProtection)) + { + VirtualFreeEx(process, memory, UIntPtr.Zero, 0x8000); + throw new InvalidOperationException("VirtualProtectEx for readback code failed: " + Marshal.GetLastWin32Error()); + } + return address; + } + + private static void WriteRemoteStub(IntPtr process, RemoteCode code, bool initializeData) + { + uint oldProtection; + if (!VirtualProtectEx(process, Ptr(code.Base), new UIntPtr(RemoteCodePage), PageExecuteReadWrite, out oldProtection)) + throw new InvalidOperationException("Could not make temporary readback stub writable: " + Marshal.GetLastWin32Error()); + try + { + UIntPtr written; + if (!WriteProcessMemory(process, Ptr(code.Base), code.Bytes, + new UIntPtr((uint)code.Bytes.Length), out written) || written.ToUInt32() != (uint)code.Bytes.Length) + throw new InvalidOperationException("Writing temporary readback stub failed: " + Marshal.GetLastWin32Error()); + if (initializeData) + { + byte[] descriptor = new byte[DdSurfaceDesc2Size]; + Buffer.BlockCopy(BitConverter.GetBytes((uint)DdSurfaceDesc2Size), 0, descriptor, 0, 4); + UIntPtr descWritten; + if (!WriteProcessMemory(process, Ptr(unchecked(code.DataBase + RemoteDataDescOffset)), descriptor, + new UIntPtr((uint)descriptor.Length), out descWritten) || descWritten.ToUInt32() != (uint)descriptor.Length) + throw new InvalidOperationException("Initializing DDSURFACEDESC2 failed: " + Marshal.GetLastWin32Error()); + byte[] zeros = new byte[RemoteDataDescOffset]; + UIntPtr zerosWritten; + if (!WriteProcessMemory(process, Ptr(code.DataBase), zeros, + new UIntPtr((uint)zeros.Length), out zerosWritten) || zerosWritten.ToUInt32() != (uint)zeros.Length) + throw new InvalidOperationException("Initializing readback result block failed: " + Marshal.GetLastWin32Error()); + } + } + finally + { + uint ignored; + if (!VirtualProtectEx(process, Ptr(code.Base), new UIntPtr(RemoteCodePage), oldProtection, out ignored)) + throw new InvalidOperationException("Restoring temporary readback code protection failed: " + Marshal.GetLastWin32Error()); + if (!FlushInstructionCache(process, Ptr(code.Base), new UIntPtr((uint)code.Bytes.Length))) + throw new InvalidOperationException("FlushInstructionCache for readback stub failed: " + Marshal.GetLastWin32Error()); + } + } + + private static bool ReadExact(IntPtr process, uint address, byte[] bytes) + { + UIntPtr read; + return address != 0 && ReadProcessMemory(process, Ptr(address), bytes, + new UIntPtr((uint)bytes.Length), out read) && read.ToUInt32() == (uint)bytes.Length; + } + + private static bool VerifyPresentStackArguments(IntPtr process, RemoteReadbackSession session, string stage) + { + uint esp = BitConverter.ToUInt32(session.OriginalContext, ContextEspOffset); + byte[] current = new byte[PresentCallArgumentsBytes]; + bool readable = esp <= UInt32.MaxValue - PresentCallArgumentsBytes + && ReadExact(process, esp, current); + bool unchanged = readable && ByteArraysEqual(session.PresentStackArguments, current); + Log("PRESENT_STACK_ARGS stage=" + stage + " esp=0x" + esp.ToString("X8") + + " readable=" + readable + " unchanged=" + unchanged + + " before=" + (session.PresentStackArguments == null ? "null" : BitConverter.ToString(session.PresentStackArguments)) + + " after=" + (readable ? BitConverter.ToString(current) : "unreadable")); + return unchanged; + } + + private static bool VerifyRemoteStubContext(IntPtr process, RemoteReadbackSession session, + uint expectedEip, string stage) + { + IntPtr thread = OpenThread(ThreadGetContext, false, session.ThreadId); + if (thread == IntPtr.Zero) + { + Log("REMOTE_STUB_CONTEXT stage=" + stage + " OpenThreadFailed=" + Marshal.GetLastWin32Error()); + return false; + } + try + { + byte[] context = GetFullX86Context(thread); + uint eip = BitConverter.ToUInt32(context, ContextEipOffset); + uint esp = BitConverter.ToUInt32(context, ContextEspOffset); + uint expectedEsp = BitConverter.ToUInt32(session.OriginalContext, ContextEspOffset); + bool matches = eip == expectedEip && esp == expectedEsp; + Log("REMOTE_STUB_CONTEXT stage=" + stage + " expectedEip=0x" + expectedEip.ToString("X8") + + " actualEip=0x" + eip.ToString("X8") + " expectedEsp=0x" + expectedEsp.ToString("X8") + + " actualEsp=0x" + esp.ToString("X8") + " matches=" + matches); + return matches; + } + catch (Exception error) + { + Log("REMOTE_STUB_CONTEXT stage=" + stage + " readFailed=" + error.Message); + return false; + } + finally { CloseHandle(thread); } + } + + private static bool ByteArraysEqual(byte[] left, byte[] right) + { + if (left == null || right == null || left.Length != right.Length) return false; + for (int i = 0; i < left.Length; i++) + if (left[i] != right[i]) return false; + return true; + } + + private static uint ReadU32Exact(IntPtr process, uint address) + { + byte[] bytes = new byte[4]; + if (!ReadExact(process, address, bytes)) throw new InvalidOperationException("Unreadable remote readback result at 0x" + address.ToString("X8")); + return BitConverter.ToUInt32(bytes, 0); + } + + private static ProjectionReadback ReadProjection(IntPtr process, uint ngiBase) + { + ProjectionReadback result = new ProjectionReadback(); + uint rendererPointer = ReadU32(process, unchecked(ngiBase + NgiRendererGlobalRva)); + result.Renderer = rendererPointer; + if (rendererPointer == 0) + { + result.Failure = "Ngi32 renderer global is null"; + return result; + } + uint vtable = ReadU32(process, rendererPointer); + result.RendererVtable = vtable; + if (vtable != unchecked(ngiBase + NgiRendererVtableRva)) + { + result.Failure = "Ngi32 renderer vtable mismatch: 0x" + vtable.ToString("X8"); + return result; + } + byte[] viewportBytes = new byte[16]; + byte[] nearBytes = new byte[4]; + byte[] farBytes = new byte[4]; + byte[] fovBytes = new byte[4]; + byte[] modeBytes = new byte[1]; + if (!ReadExact(process, unchecked(rendererPointer + 0x18), viewportBytes) + || !ReadExact(process, unchecked(rendererPointer + 0x38), nearBytes) + || !ReadExact(process, unchecked(rendererPointer + 0x3C), farBytes) + || !ReadExact(process, unchecked(rendererPointer + 0x54), fovBytes) + || !ReadExact(process, unchecked(rendererPointer + 0x118), modeBytes)) + { + result.Failure = "Ngi32 projection fields are unreadable"; + return result; + } + int[] viewport = new int[4]; + for (int i = 0; i < viewport.Length; i++) viewport[i] = BitConverter.ToInt32(viewportBytes, i * 4); + float nearPlane = BitConverter.ToSingle(nearBytes, 0); + float farPlane = BitConverter.ToSingle(farBytes, 0); + float fov = BitConverter.ToSingle(fovBytes, 0); + result.Viewport = viewport; + result.Near = nearPlane; + result.Far = farPlane; + result.Fov = fov; + result.Mode = modeBytes[0]; + int width = viewport[2] - viewport[0]; + int height = viewport[3] - viewport[1]; + result.Verified = width > 0 && height > 0 && width <= 8192 && height <= 8192 + && IsFinite(nearPlane) && nearPlane > 0.0f + && IsFinite(farPlane) && farPlane > nearPlane + && IsFinite(fov) && fov > 0.0f && fov < 3.141593f; + if (!result.Verified) result.Failure = "Ngi32 viewport or projection values failed bounded finite/range checks"; + return result; + } + + private static SurfaceReadback ReadSurfaceRows(IntPtr process, uint descAddress) + { + byte[] desc = new byte[DdSurfaceDesc2Size]; + if (!ReadExact(process, descAddress, desc)) throw new InvalidOperationException("Could not read D3D7 surface descriptor."); + Log("D3D7_LOCK_DDSURFACEDESC2 bytes=" + desc.Length + " raw=" + BitConverter.ToString(desc)); + uint size = BitConverter.ToUInt32(desc, 0); + uint flags = BitConverter.ToUInt32(desc, 4); + uint height = BitConverter.ToUInt32(desc, 8); + uint width = BitConverter.ToUInt32(desc, 12); + int pitch = BitConverter.ToInt32(desc, 16); + uint surface = BitConverter.ToUInt32(desc, 36); + uint pfSize = BitConverter.ToUInt32(desc, 72); + uint pfFlags = BitConverter.ToUInt32(desc, 76); + uint fourCc = BitConverter.ToUInt32(desc, 80); + uint bitCount = BitConverter.ToUInt32(desc, 84); + uint red = BitConverter.ToUInt32(desc, 88); + uint green = BitConverter.ToUInt32(desc, 92); + uint blue = BitConverter.ToUInt32(desc, 96); + uint alpha = BitConverter.ToUInt32(desc, 100); + if (size != DdSurfaceDesc2Size || pfSize != 32) + throw new InvalidOperationException("DDSURFACEDESC2 or DDPIXELFORMAT size was unexpected."); + if (!HasLockedSurfaceDescriptor(flags, surface)) + throw new InvalidOperationException("D3D7 Lock descriptor flags were incomplete: size=" + size + + " flags=0x" + flags.ToString("X8") + " width=" + width + " height=" + height + + " pitch=" + pitch + " surface=0x" + surface.ToString("X8") + + " pfSize=" + pfSize + " pfFlags=0x" + pfFlags.ToString("X8") + + " bitCount=" + bitCount + " masks=0x" + red.ToString("X8") + "/0x" + + green.ToString("X8") + "/0x" + blue.ToString("X8") + "/0x" + alpha.ToString("X8")); + if ((flags & DdsdLpSurface) == 0) + Log("D3D7_LOCK_LPSURFACE_FLAG_ABSENT accepted_after_S_OK_and_nonzero_pointer; exact bounded ReadProcessMemory remains required"); + if ((pfFlags & DdpfRgb) == 0 || (pfFlags & DdpfFourCc) != 0) + throw new InvalidOperationException("D3D7 render target is not an uncompressed RGB surface."); + if (bitCount != 16 && bitCount != 24 && bitCount != 32) + throw new InvalidOperationException("Unsupported D3D7 render-target bit depth: " + bitCount); + if (width == 0 || height == 0 || width > 8192 || height > 8192 || surface == 0) + throw new InvalidOperationException("D3D7 surface dimensions or pointer are outside the accepted bounds."); + long rowBytes = ((long)width * bitCount + 7) / 8; + long pitchAbs = Math.Abs((long)pitch); + long totalBytes = pitchAbs * height; + if (pitch == 0 || pitchAbs < rowBytes || totalBytes <= 0 || totalBytes > PixelReadbackLimit) + throw new InvalidOperationException("D3D7 pitch/byte count is outside the bounded readback limits."); + long lowest = pitch >= 0 ? surface : (long)surface + (long)(height - 1) * pitch; + if (lowest <= 0 || lowest + totalBytes > UInt32.MaxValue) + throw new InvalidOperationException("D3D7 surface address range overflowed x86 address space."); + byte[] rows = new byte[(int)totalBytes]; + if (!ReadExact(process, unchecked((uint)lowest), rows)) + throw new InvalidOperationException("Could not copy the bounded locked D3D7 surface rows."); + SurfaceReadback result = new SurfaceReadback(); + result.Width = width; + result.Height = height; + result.Pitch = pitch; + result.BitCount = bitCount; + result.RedMask = red; + result.GreenMask = green; + result.BlueMask = blue; + result.AlphaMask = alpha; + result.SurfacePointer = surface; + result.Rows = rows; + result.RgbMasksVerified = IsValidChannelMask(red, bitCount, true) + && IsValidChannelMask(green, bitCount, true) + && IsValidChannelMask(blue, bitCount, true) + && IsValidChannelMask(alpha, bitCount, false) + && (red & green) == 0 && (red & blue) == 0 && (green & blue) == 0 + && (alpha == 0 || ((alpha & red) == 0 && (alpha & green) == 0 && (alpha & blue) == 0)); + if (!result.RgbMasksVerified) + throw new InvalidOperationException("D3D7 RGB channel masks are missing or overlap."); + using (SHA256 sha = SHA256.Create()) + { + byte[] hash = sha.ComputeHash(rows); + StringBuilder hex = new StringBuilder(hash.Length * 2); + for (int i = 0; i < hash.Length; i++) hex.Append(hash[i].ToString("X2")); + result.Sha256 = hex.ToString(); + } + return result; + } + + private static bool HasLockedSurfaceDescriptor(uint flags, uint surface) + { + const uint required = DdsdHeight | DdsdWidth | DdsdPitch | DdsdPixelFormat; + // Successful IDirectDrawSurface7::Lock returns the lpSurface address; + // tolerate drivers that omit only DDSD_LPSURFACE, then require exact bounded + // ReadProcessMemory for every pixel row before accepting the capture. + return surface != 0 && (flags & required) == required; + } + + private static int MaskChannel(uint pixel, uint mask, int fallback) + { + if (mask == 0) return fallback; + int shift = 0; + while (((mask >> shift) & 1) == 0 && shift < 31) shift++; + ulong maximum = mask >> shift; + ulong value = (pixel & mask) >> shift; + return (int)((value * 255UL + maximum / 2UL) / maximum); + } + + private static bool IsValidChannelMask(uint mask, uint bitCount, bool required) + { + if (mask == 0) return !required; + if (bitCount < 32 && (mask >> (int)bitCount) != 0) return false; + int shift = 0; + while (shift < 32 && ((mask >> shift) & 1) == 0) shift++; + if (shift >= 32) return false; + uint normalized = mask >> shift; + return (normalized & unchecked(normalized + 1u)) == 0; + } + + private static byte[] DecodeSurfaceToBgra(SurfaceReadback surface) + { + if (surface == null || surface.Rows == null || !surface.RgbMasksVerified) + throw new InvalidOperationException("Surface was not verified before pixel conversion."); + int width = checked((int)surface.Width); + int height = checked((int)surface.Height); + int bytesPerPixel = checked((int)(surface.BitCount / 8)); + int sourcePitch = checked((int)Math.Abs((long)surface.Pitch)); + int rowBytes = checked(width * bytesPerPixel); + if (width <= 0 || height <= 0 || (surface.BitCount != 16 && surface.BitCount != 24 && surface.BitCount != 32) + || sourcePitch < rowBytes || surface.Rows.Length < checked(sourcePitch * height)) + throw new InvalidOperationException("Surface pixel buffer shape is inconsistent."); + byte[] bgra = new byte[checked(width * height * 4)]; + for (int y = 0; y < height; y++) + { + int sourceRow = surface.Pitch >= 0 ? y : (height - 1 - y); + int sourceBase = checked(sourceRow * sourcePitch); + int destinationBase = checked(y * width * 4); + for (int x = 0; x < width; x++) + { + int offset = sourceBase + x * bytesPerPixel; + uint packed; + if (surface.BitCount == 16) packed = BitConverter.ToUInt16(surface.Rows, offset); + else if (surface.BitCount == 24) + packed = (uint)(surface.Rows[offset] | (surface.Rows[offset + 1] << 8) | (surface.Rows[offset + 2] << 16)); + else packed = BitConverter.ToUInt32(surface.Rows, offset); + int outOffset = destinationBase + x * 4; + bgra[outOffset] = (byte)MaskChannel(packed, surface.BlueMask, 0); + bgra[outOffset + 1] = (byte)MaskChannel(packed, surface.GreenMask, 0); + bgra[outOffset + 2] = (byte)MaskChannel(packed, surface.RedMask, 0); + // PNG is a screenshot of the composited framebuffer. The + // render target's alpha bits are not window opacity metadata. + bgra[outOffset + 3] = 255; + } + } + return bgra; + } + + private static void AssertPixel(byte[] actual, params byte[] expected) + { + if (actual == null || actual.Length != expected.Length) + throw new InvalidOperationException("Pixel converter self-check returned the wrong byte count."); + for (int i = 0; i < expected.Length; i++) + if (actual[i] != expected[i]) + throw new InvalidOperationException("Pixel converter self-check failed at byte " + i + "."); + } + + private static void SaveSurfacePng(SurfaceReadback surface, string outputPath) + { + int width = checked((int)surface.Width); + int height = checked((int)surface.Height); + bool created = false; + try + { + using (FileStream png = new FileStream(outputPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + created = true; + using (Bitmap bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb)) + { + Rectangle rectangle = new Rectangle(0, 0, width, height); + BitmapData bits = bitmap.LockBits(rectangle, ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb); + try + { + if (bits.Stride < width * 4) throw new InvalidOperationException("PNG staging bitmap stride is too short."); + byte[] sourceBgra = DecodeSurfaceToBgra(surface); + byte[] rgba = new byte[checked(bits.Stride * height)]; + int sourceStride = width * 4; + for (int y = 0; y < height; y++) + Buffer.BlockCopy(sourceBgra, y * sourceStride, rgba, y * bits.Stride, sourceStride); + Marshal.Copy(rgba, 0, bits.Scan0, rgba.Length); + } + finally { bitmap.UnlockBits(bits); } + bitmap.Save(png, ImageFormat.Png); + } + png.Flush(); + } + } + catch + { + if (created) try { File.Delete(outputPath); } catch { } + throw; + } + } + + private static void WriteTextCreateNew(string outputPath, string text) + { + bool created = false; + try + { + using (FileStream output = new FileStream(outputPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + created = true; + byte[] bytes = new UTF8Encoding(false).GetBytes(text); + output.Write(bytes, 0, bytes.Length); + output.Flush(); + } + } + catch + { + if (created) try { File.Delete(outputPath); } catch { } + throw; + } + } + + private static CameraReadback ReadCameraSnapshot(IntPtr process, uint threadId, uint terrainBase) + { + CameraReadback result = new CameraReadback(); + IntPtr thread = OpenThread(ThreadGetContext | ThreadSetContext, false, threadId); + IntPtr contextAllocation; + IntPtr context = AllocateAlignedX86Context(out contextAllocation); + try + { + Marshal.Copy(new byte[X86ContextSize], 0, context, X86ContextSize); + Marshal.WriteInt32(context, 0, unchecked((int)ContextAllX86)); + if (thread == IntPtr.Zero || !GetThreadContext(thread, context)) + { + result.Failure = "World3D breakpoint context was unreadable: " + Marshal.GetLastWin32Error(); + return result; + } + uint esp = unchecked((uint)Marshal.ReadInt32(context, ContextEspOffset)); + uint returnAddress = ReadU32(process, esp); + uint camera = ReadU32(process, unchecked(esp + 4)); + uint vtable = ReadU32(process, camera); + uint transformInterface = unchecked(camera + 4); + uint transformVtable = ReadU32(process, transformInterface); + uint selectorField = ReadU32(process, unchecked(camera + 0x10)); + byte[] modeBytes = new byte[1]; + byte cameraMode = ReadExact(process, unchecked(camera + 0x1A0), modeBytes) ? modeBytes[0] : (byte)0xFF; + byte[] matrixBytes = new byte[64]; + if (camera == 0 || !ReadExact(process, unchecked(camera + 0x20), matrixBytes)) + { + result.Failure = "World3D camera argument or 64-byte selector matrix was unreadable"; + return result; + } + uint[] words = new uint[16]; + bool matrixFinite = true; + for (int i = 0; i < words.Length; i++) + { + words[i] = BitConverter.ToUInt32(matrixBytes, i * 4); + if (!IsFinite(BitConverter.ToSingle(matrixBytes, i * 4))) matrixFinite = false; + } + uint expectedVtable = unchecked(terrainBase + ExternalCameraVtableRva); + uint expectedTransformVtable = unchecked(terrainBase + 0x66558); + uint setterSlot = ReadU32(process, unchecked(transformVtable + 0x1C)); + uint invalidateSlot = ReadU32(process, unchecked(transformVtable + 0x30)); + bool setterAbiVerified = transformVtable == expectedTransformVtable + && setterSlot == unchecked(terrainBase + 0x54C70) + && invalidateSlot == unchecked(terrainBase + 0x55280); + result.Camera = camera; + result.Vtable = vtable; + result.TransformInterface = transformInterface; + result.TransformVtable = transformVtable; + result.CameraMode = cameraMode; + result.EntryEsp = esp; + result.ReturnAddress = returnAddress; + result.SelectorField = selectorField; + result.Words = words; + result.EntryMatrixBytes = (byte[])matrixBytes.Clone(); + result.EntryMatrixSha256 = HashBytes(matrixBytes); + result.MatrixFinite = matrixFinite; + result.LayoutVerified = terrainBase != 0 && vtable == expectedVtable + && setterAbiVerified && cameraMode == 0 + && selectorField == 0xFFFFFFFF && matrixFinite; + if (!result.LayoutVerified) + result.Failure = "external camera primary/transform vtables, setter slots, normal mode, selector field, or matrix finite check failed"; + return result; + } + finally + { + Marshal.FreeHGlobal(contextAllocation); + if (thread != IntPtr.Zero) CloseHandle(thread); + } + } + + private static Dictionary ParseJsonObject(string json) + { + if (String.IsNullOrEmpty(json) || json.Length > CameraInputJsonLimit) + throw new InvalidDataException("Camera JSON is empty or exceeds the 1 MiB input limit."); + JavaScriptSerializer serializer = new JavaScriptSerializer(); + serializer.MaxJsonLength = CameraInputJsonLimit; + serializer.RecursionLimit = 32; + object value; + try { value = serializer.DeserializeObject(json); } + catch (Exception error) { throw new InvalidDataException("Camera JSON is malformed.", error); } + Dictionary result = value as Dictionary; + if (result == null) throw new InvalidDataException("Camera JSON must be a top-level object."); + return result; + } + + private static object RequiredJsonField(Dictionary json, string key) + { + object value; + if (json == null || !json.TryGetValue(key, out value) || value == null) + throw new InvalidDataException("Camera JSON is missing '" + key + "'."); + return value; + } + + private static string JsonStringField(Dictionary json, string key) + { + string value = RequiredJsonField(json, key) as string; + if (value == null) throw new InvalidDataException("Camera JSON field '" + key + "' must be a string."); + return value; + } + + private static object[] JsonArrayField(Dictionary json, string key) + { + object value = RequiredJsonField(json, key); + IList list = value as IList; + if (list == null) throw new InvalidDataException("Camera JSON field '" + key + "' must be an array."); + object[] result = new object[list.Count]; + list.CopyTo(result, 0); + return result; + } + + private static bool TryJsonDouble(object value, out double result) + { + if (value is double) result = (double)value; + else if (value is decimal) result = (double)(decimal)value; + else if (value is int) result = (int)value; + else if (value is long) result = (long)value; + else if (value is uint) result = (uint)value; + else if (value is ulong) result = (ulong)value; + else if (value is float) result = (float)value; + else { result = 0; return false; } + return !Double.IsNaN(result) && !Double.IsInfinity(result); + } + + private static bool TryJsonUInt32(object value, out uint result) + { + double number; + if (!TryJsonDouble(value, out number) || number < 0 || number > UInt32.MaxValue + || Math.Truncate(number) != number) + { result = 0; return false; } + result = (uint)number; + return true; + } + + private static bool TryJsonInt32(object value, out int result) + { + double number; + if (!TryJsonDouble(value, out number) || number < Int32.MinValue || number > Int32.MaxValue + || Math.Truncate(number) != number) + { result = 0; return false; } + result = (int)number; + return true; + } + + private static bool TryJsonFloat(object value, out float result) + { + double number; + if (!TryJsonDouble(value, out number) || number < -Single.MaxValue || number > Single.MaxValue) + { result = 0; return false; } + result = (float)number; + return IsFinite(result); + } + + private static bool IsOrthonormalAffineCamera(byte[] matrixBytes, out string failure) + { + failure = null; + if (matrixBytes == null || matrixBytes.Length != 64) + { + failure = "camera matrix must contain exactly 64 bytes"; + return false; + } + float[] m = new float[16]; + for (int i = 0; i < m.Length; i++) + { + m[i] = BitConverter.ToSingle(matrixBytes, i * 4); + if (!IsFinite(m[i])) { failure = "camera matrix contains a nonfinite value"; return false; } + } + const double affineTolerance = 0.0001; + if (Math.Abs(m[12]) > affineTolerance || Math.Abs(m[13]) > affineTolerance + || Math.Abs(m[14]) > affineTolerance || Math.Abs(m[15] - 1.0) > affineTolerance) + { + failure = "camera matrix last row is not affine [0,0,0,1]"; + return false; + } + if (Math.Abs(m[3]) > 1000000 || Math.Abs(m[7]) > 1000000 || Math.Abs(m[11]) > 1000000) + { + failure = "camera translation is outside the supported finite range"; + return false; + } + + // Native selector-0 matrices are row-major affine transforms with a + // rigid, orthonormal 3x3 basis. Reject shear/scale before the DLL call. + double[] rowLengthSquared = new double[3]; + for (int row = 0; row < 3; row++) + { + int offset = row * 4; + for (int column = 0; column < 3; column++) + rowLengthSquared[row] += (double)m[offset + column] * m[offset + column]; + if (Math.Abs(rowLengthSquared[row] - 1.0) > 0.02) + { + failure = "camera basis row is not unit length"; + return false; + } + } + for (int firstRow = 0; firstRow < 3; firstRow++) + for (int secondRow = firstRow + 1; secondRow < 3; secondRow++) + { + double dot = 0; + for (int column = 0; column < 3; column++) + dot += (double)m[firstRow * 4 + column] * m[secondRow * 4 + column]; + if (Math.Abs(dot) > 0.01) + { + failure = "camera basis contains shear or nonorthogonal axes"; + return false; + } + } + double determinant = (double)m[0] * (m[5] * m[10] - m[6] * m[9]) + - (double)m[1] * (m[4] * m[10] - m[6] * m[8]) + + (double)m[2] * (m[4] * m[9] - m[5] * m[8]); + if (Math.Abs(Math.Abs(determinant) - 1.0) > 0.04) + { + failure = "camera basis is singular or not approximately rigid"; + return false; + } + return true; + } + + private static SelectedCameraInput ParseSelectedCameraJson(string json, string sourcePath) + { + Dictionary root = ParseJsonObject(json); + bool renderInputUsable = RequiredJsonField(root, "render_input_usable") is bool + && (bool)RequiredJsonField(root, "render_input_usable"); + if (!String.Equals(JsonStringField(root, "schema"), "fparkan-legacy-camera-v1", StringComparison.Ordinal) + || !String.Equals(JsonStringField(root, "capture_status"), "native-frame-captured", StringComparison.Ordinal) + || !renderInputUsable) + throw new InvalidDataException("Camera input must be a usable fparkan-legacy-camera-v1 native capture."); + + SelectedCameraInput input = new SelectedCameraInput(); + input.Path = Path.GetFullPath(sourcePath); + object[] words = JsonArrayField(root, "selector0_words"); + if (words.Length != 16) throw new InvalidDataException("selector0_words must contain exactly 16 uint32 values."); + input.MatrixWords = new uint[16]; + input.MatrixBytes = new byte[64]; + for (int i = 0; i < words.Length; i++) + { + uint word; + if (!TryJsonUInt32(words[i], out word)) + throw new InvalidDataException("selector0_words contains a value outside uint32 range."); + input.MatrixWords[i] = word; + Buffer.BlockCopy(BitConverter.GetBytes(word), 0, input.MatrixBytes, i * 4, 4); + } + string matrixFailure; + if (!IsOrthonormalAffineCamera(input.MatrixBytes, out matrixFailure)) + throw new InvalidDataException("Camera input rejected: " + matrixFailure + "."); + input.MatrixSha256 = HashBytes(input.MatrixBytes); + + object[] viewport = JsonArrayField(root, "viewport"); + if (viewport.Length != 4) throw new InvalidDataException("viewport must contain exactly four integers."); + input.Viewport = new int[4]; + for (int i = 0; i < viewport.Length; i++) + if (!TryJsonInt32(viewport[i], out input.Viewport[i])) + throw new InvalidDataException("viewport contains an invalid integer."); + if (input.Viewport[0] < 0 || input.Viewport[1] < 0 + || input.Viewport[2] <= input.Viewport[0] || input.Viewport[3] <= input.Viewport[1]) + throw new InvalidDataException("viewport bounds must have positive width and height."); + + if (!TryJsonFloat(RequiredJsonField(root, "near_plane"), out input.Near) + || !TryJsonFloat(RequiredJsonField(root, "far_plane"), out input.Far) + || !TryJsonFloat(RequiredJsonField(root, "field_of_view_radians"), out input.FieldOfView) + || !IsFinite(input.Near) || !IsFinite(input.Far) || !IsFinite(input.FieldOfView) + || input.Near <= 0 || input.Far <= input.Near || input.FieldOfView <= 0.05f || input.FieldOfView >= 3.1f) + throw new InvalidDataException("Camera projection must have finite, ordered near/far planes and a finite perspective FOV."); + uint projectionMode; + if (!TryJsonUInt32(RequiredJsonField(root, "projection_mode_byte"), out projectionMode) + || projectionMode == 0 || projectionMode > Byte.MaxValue) + throw new InvalidDataException("Camera input requires a verified perspective projection mode."); + input.ProjectionMode = (byte)projectionMode; + return input; + } + + private static SelectedCameraInput LoadSelectedCameraInput(string path) + { + string fullPath = Path.GetFullPath(path); + if (!File.Exists(fullPath)) throw new FileNotFoundException("Camera input JSON does not exist.", fullPath); + FileInfo file = new FileInfo(fullPath); + if (file.Length > CameraInputJsonLimit) + throw new InvalidDataException("Camera input exceeds the 1 MiB limit."); + byte[] bytes = File.ReadAllBytes(fullPath); + if (bytes.Length > CameraInputJsonLimit) + throw new InvalidDataException("Camera input exceeded the 1 MiB limit while being read."); + int offset = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF ? 3 : 0; + string text; + try { text = new UTF8Encoding(false, true).GetString(bytes, offset, bytes.Length - offset); } + catch (DecoderFallbackException error) { throw new InvalidDataException("Camera input is not valid UTF-8.", error); } + return ParseSelectedCameraJson(text, fullPath); + } + + private static bool ProjectionMatchesCameraInput(ProjectionReadback projection, SelectedCameraInput input) + { + if (projection == null || input == null || !projection.Verified || projection.Mode != input.ProjectionMode + || projection.Viewport == null || projection.Viewport.Length != 4) return false; + for (int i = 0; i < 4; i++) if (projection.Viewport[i] != input.Viewport[i]) return false; + return NearlyEqual(projection.Near, input.Near, 0.0001f) + && NearlyEqual(projection.Far, input.Far, 0.001f) + && NearlyEqual(projection.Fov, input.FieldOfView, 0.0001f); + } + + private static bool NearlyEqual(float left, float right, float tolerance) + { + return IsFinite(left) && IsFinite(right) + && Math.Abs((double)left - right) <= tolerance * Math.Max(1.0, Math.Max(Math.Abs((double)left), Math.Abs((double)right))); + } + + private static void SelfCheckCameraInput() + { + string valid = "{\"schema\":\"fparkan-legacy-camera-v1\",\"capture_status\":\"native-frame-captured\"," + + "\"render_input_usable\":true,\"selector0_words\":[1065353216,0,0,1065353216," + + "0,1065353216,0,1073741824,0,0,1065353216,1077936128,0,0,0,1065353216]," + + "\"viewport\":[0,0,1280,1024],\"near_plane\":0.5,\"far_plane\":700," + + "\"field_of_view_radians\":1.04,\"projection_mode_byte\":1}"; + SelectedCameraInput parsed = ParseSelectedCameraJson(valid, "input.json"); + if (parsed.MatrixBytes.Length != 64 || parsed.Viewport[2] != 1280 || parsed.MatrixSha256.Length != 64) + throw new InvalidOperationException("Camera JSON parser self-check failed on a valid rigid transform."); + string zeroMatrix = valid.Replace("1065353216,0,0,1065353216,0,1065353216,0,1073741824,0,0,1065353216,1077936128,0,0,0,1065353216", + "0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0"); + bool rejected = false; + try { ParseSelectedCameraJson(zeroMatrix, "bad.json"); } + catch (InvalidDataException) { rejected = true; } + if (!rejected) throw new InvalidOperationException("Camera JSON parser accepted a singular all-zero matrix."); + string nonPerspective = valid.Replace("\"projection_mode_byte\":1", "\"projection_mode_byte\":0"); + rejected = false; + try { ParseSelectedCameraJson(nonPerspective, "bad.json"); } + catch (InvalidDataException) { rejected = true; } + if (!rejected) throw new InvalidOperationException("Camera JSON parser accepted a non-perspective projection."); + string nonNumericWord = valid.Replace("1065353216,0,0,1065353216", "\"1065353216\",0,0,1065353216"); + rejected = false; + try { ParseSelectedCameraJson(nonNumericWord, "bad.json"); } + catch (InvalidDataException) { rejected = true; } + if (!rejected) throw new InvalidOperationException("Camera JSON parser accepted a quoted matrix word."); + } + + private static bool RefreshCameraWordsAtProjection(IntPtr process, CameraReadback camera, + uint terrainBase, uint generation) + { + if (camera == null || !camera.LayoutVerified || camera.Camera == 0 + || camera.EntryMatrixBytes == null || camera.EntryMatrixBytes.Length != 64) + { + if (camera != null) + { + camera.CurrentAtProjectionVerified = false; + camera.Failure = "camera entry layout was not verified before the projection boundary"; + } + return false; + } + + uint vtable = ReadU32(process, camera.Camera); + uint selectorField = ReadU32(process, unchecked(camera.Camera + 0x10)); + uint expectedVtable = unchecked(terrainBase + ExternalCameraVtableRva); + byte[] current = new byte[64]; + if (vtable != expectedVtable || selectorField != 0xFFFFFFFF + || !ReadExact(process, unchecked(camera.Camera + 0x20), current)) + { + camera.CurrentAtProjectionVerified = false; + camera.Failure = "camera pointer, vtable, selector, or matrix changed before projection"; + Log("CAMERA_MATRIX_AT_PROJECTION_REJECTED generation=" + generation + + " vtable=0x" + vtable.ToString("X8") + " selector=0x" + selectorField.ToString("X8") + + " expectedVtable=0x" + expectedVtable.ToString("X8")); + return false; + } + + uint[] words = new uint[16]; + bool finite = true; + for (int i = 0; i < words.Length; i++) + { + words[i] = BitConverter.ToUInt32(current, i * 4); + if (!IsFinite(BitConverter.ToSingle(current, i * 4))) finite = false; + } + if (!finite) + { + camera.CurrentAtProjectionVerified = false; + camera.Failure = "camera matrix contains a non-finite value at projection boundary"; + Log("CAMERA_MATRIX_AT_PROJECTION_REJECTED generation=" + generation + " nonFinite=true"); + return false; + } + + bool changed = !ByteArraysEqual(camera.EntryMatrixBytes, current); + camera.Vtable = vtable; + camera.SelectorField = selectorField; + camera.Words = words; + camera.ProjectionMatrixBytes = (byte[])current.Clone(); + camera.MatrixFinite = true; + camera.CurrentAtProjectionVerified = true; + camera.WordsChangedAtProjection = changed; + camera.ProjectionMatrixSha256 = HashBytes(current); + Log("CAMERA_MATRIX_AT_PROJECTION generation=" + generation + " verified=true changedSinceEntry=" + changed + + " entrySha256=" + camera.EntryMatrixSha256 + " projectionSha256=" + camera.ProjectionMatrixSha256); + return true; + } + + private static string HashBytes(byte[] bytes) + { + using (SHA256 sha = SHA256.Create()) + { + byte[] hash = sha.ComputeHash(bytes); + StringBuilder result = new StringBuilder(hash.Length * 2); + for (int i = 0; i < hash.Length; i++) result.Append(hash[i].ToString("X2")); + return result.ToString(); + } + } + + private static int SelfCheckReadback() + { + if (IntPtr.Size != 4) throw new InvalidOperationException("This helper must run as x86."); + uint flags = DdLockReadOnly | DdLockDoNotWait; + if (DdLockReadOnly != 0x10 || DdLockDoNotWait != 0x4000 || flags != 0x4010 || flags == 0x30) + throw new InvalidOperationException("DDLOCK flag self-check failed."); + RemoteCode acquire = BuildReadbackStub(0x10000000, 0x20000000, false); + RemoteCode cleanup = BuildReadbackStub(0x10000000, 0, true); + RemoteCode cameraSetter = BuildCameraSetterStub(0x30000000, 0x40000000); + string[] d3d7DeviceVtable = new string[] { + "QueryInterface", "AddRef", "Release", "GetCaps", "EnumTextureFormats", "BeginScene", "EndScene", + "GetDirect3D", "SetRenderTarget", "GetRenderTarget", "Clear", "SetTransform", "GetTransform", + "SetViewport", "MultiplyTransform", "GetViewport", "SetMaterial", "GetMaterial", "SetLight", "GetLight" + }; + string[] surface7Vtable = new string[] { + "QueryInterface", "AddRef", "Release", "AddAttachedSurface", "AddOverlayDirtyRect", "Blt", "BltBatch", + "BltFast", "DeleteAttachedSurface", "EnumAttachedSurfaces", "EnumOverlayZOrders", "Flip", "GetAttachedSurface", + "GetBltStatus", "GetCaps", "GetClipper", "GetColorKey", "GetDC", "GetFlipStatus", "GetOverlayPosition", + "GetPalette", "GetPixelFormat", "GetSurfaceDesc", "Initialize", "IsLost", "Lock", "ReleaseDC", "Restore", + "SetClipper", "SetColorKey", "SetOverlayPosition", "SetPalette", "Unlock", "UpdateOverlay", + "UpdateOverlayDisplay", "UpdateOverlayZOrder", "GetDDInterface", "PageLock", "PageUnlock", "SetSurfaceDesc", + "SetPrivateData", "GetPrivateData", "FreePrivateData", "GetUniquenessValue", "ChangeUniquenessValue", + "SetPriority", "GetPriority", "SetLOD", "GetLOD" + }; + byte[] originalContext = new byte[X86ContextSize]; + const uint originalEsp = 0x00100100; + Buffer.BlockCopy(BitConverter.GetBytes(unchecked((int)0x12345678)), 0, originalContext, ContextEipOffset, 4); + Buffer.BlockCopy(BitConverter.GetBytes(unchecked((int)originalEsp)), 0, originalContext, ContextEspOffset, 4); + Buffer.BlockCopy(BitConverter.GetBytes(0x00000202), 0, originalContext, ContextEflagsOffset, 4); + byte[] remoteContext = ContextForRemoteCode(originalContext, 0x10001000, originalEsp); + byte[] presentArgs = new byte[PresentCallArgumentsBytes]; + for (int i = 0; i < presentArgs.Length; i++) presentArgs[i] = (byte)(i + 1); + byte[] samePresentArgs = (byte[])presentArgs.Clone(); + byte[] changedPresentArgs = (byte[])presentArgs.Clone(); + changedPresentArgs[PresentCallArgumentsBytes - 1] ^= 1; + bool validStackRange = IsSafeNativeStackRange(0x00100000, 0x00200000, originalEsp, 24, + 0x00100000, 0x1000, MemCommit, 0x04); + if (Marshal.SizeOf(typeof(X86ContextLayout)) != X86ContextSize + || Marshal.OffsetOf(typeof(X86ContextLayout), "Eip").ToInt32() != ContextEipOffset + || Marshal.OffsetOf(typeof(X86ContextLayout), "Esp").ToInt32() != ContextEspOffset + || ContextAllX86 != 0x0001003F || acquire.Bytes.Length == 0 || cleanup.Bytes.Length == 0 + || acquire.MaxCallStackBytes != RemoteStubMaxCallStackBytes || cleanup.MaxCallStackBytes != 12 + || cameraSetter.Bytes.Length == 0 || cameraSetter.MaxCallStackBytes != 16 + || cameraSetter.CallInstructionOffsets == null || cameraSetter.CallInstructionOffsets.Length != 1 + || DecodeIndirectCallDisplacement(cameraSetter.Bytes, cameraSetter.CallInstructionOffsets[0]) + != CameraTransformSetterSlotOffset + || cameraSetter.TrapAddress != 0x30000000 + cameraSetter.Bytes.Length - 1 + || VtableOffset(d3d7DeviceVtable, "GetRenderTarget") != D3dDeviceGetRenderTargetOffset + || VtableOffset(surface7Vtable, "Lock") != DdSurfaceLockOffset + || VtableOffset(surface7Vtable, "SetOverlayPosition") != 0x78 + || VtableOffset(surface7Vtable, "Unlock") != DdSurfaceUnlockOffset + || VtableOffset(surface7Vtable, "Release") != DdSurfaceReleaseOffset + || cleanup.CallInstructionOffsets == null || cleanup.CallInstructionOffsets.Length != 2 + || DecodeIndirectCallDisplacement(cleanup.Bytes, cleanup.CallInstructionOffsets[0]) != DdSurfaceUnlockOffset + || DecodeIndirectCallDisplacement(cleanup.Bytes, cleanup.CallInstructionOffsets[1]) != DdSurfaceReleaseOffset + || acquire.CallInstructionOffsets == null || acquire.CallInstructionOffsets.Length != 3 + || DecodeIndirectCallDisplacement(acquire.Bytes, acquire.CallInstructionOffsets[0]) != D3dDeviceGetRenderTargetOffset + || DecodeIndirectCallDisplacement(acquire.Bytes, acquire.CallInstructionOffsets[1]) != DdSurfaceLockOffset + || DecodeIndirectCallDisplacement(acquire.Bytes, acquire.CallInstructionOffsets[2]) != DdSurfaceReleaseOffset + || DecodeIndirectCallDisplacement(new byte[] { 0xFF, 0x50, 0x80 }, 0) != -128 + || acquire.TrapAddress != 0x10000000 + acquire.Bytes.Length - 1 + || cleanup.TrapAddress != 0x10000000 + cleanup.Bytes.Length - 1 + || !validStackRange || !HasNativeStackBounds(0x00100000, 0x00200000, 0x00100018, 24) + || HasNativeStackBounds(0x00100000, 0x00200000, 0x00100017, 24) + || HasNativeStackBounds(0x00100000, 0x00200000, 0x00000010, 24) + || HasNativeStackBounds(0x00100000, 0x00200000, 0x00100100, 0) + || !IsSafeNativeStackRange(0x00100000, 0x00200000, originalEsp, 24, + 0x001000E8, 0x1000, MemCommit, 0x04) + || IsSafeNativeStackRange(0x00100000, 0x00200000, originalEsp, 24, + 0x00100000, 0x1000, MemCommit, 0x104) + || IsSafeNativeStackRange(0x00100000, 0x00200000, originalEsp, 24, + 0x00100000, 0xF0, MemCommit, 0x04) + || BitConverter.ToUInt32(remoteContext, ContextEspOffset) != originalEsp + || BitConverter.ToUInt32(remoteContext, ContextEipOffset) != 0x10001000 + || BitConverter.ToUInt32(remoteContext, ContextEflagsOffset) != 0x00000202 + || !ByteArraysEqual(presentArgs, samePresentArgs) + || ByteArraysEqual(presentArgs, changedPresentArgs) + || !IsViewportInsideSurface(new int[] { 0, 0, 1280, 1024 }, 1280, 1024) + || IsViewportInsideSurface(new int[] { -1, 0, 1280, 1024 }, 1280, 1024) + || IsViewportInsideSurface(new int[] { 0, 0, 1281, 1024 }, 1280, 1024) + || IsViewportInsideSurface(new int[] { 0, 0, 0, 1024 }, 1280, 1024)) + throw new InvalidOperationException("Remote stub branch/INT3 self-check failed."); + if (!HasLockedSurfaceDescriptor(0x100F, 0x19510000) + || HasLockedSurfaceDescriptor(0x1007, 0x19510000) + || HasLockedSurfaceDescriptor(0x100F, 0)) + throw new InvalidOperationException("D3D7 Lock descriptor compatibility self-check failed."); + AssertPixel(DecodeSurfaceToBgra(new SurfaceReadback { + Width = 1, Height = 1, Pitch = 2, BitCount = 16, + RedMask = 0xF800, GreenMask = 0x07E0, BlueMask = 0x001F, + Rows = new byte[] { 0x1F, 0xF8 }, RgbMasksVerified = true + }), 255, 0, 255, 255); + AssertPixel(DecodeSurfaceToBgra(new SurfaceReadback { + Width = 1, Height = 1, Pitch = 3, BitCount = 24, + RedMask = 0x00FF0000, GreenMask = 0x0000FF00, BlueMask = 0x000000FF, + Rows = new byte[] { 0x11, 0x22, 0x33 }, RgbMasksVerified = true + }), 0x11, 0x22, 0x33, 255); + AssertPixel(DecodeSurfaceToBgra(new SurfaceReadback { + Width = 1, Height = 1, Pitch = 4, BitCount = 32, + RedMask = 0x00FF0000, GreenMask = 0x0000FF00, BlueMask = 0x000000FF, AlphaMask = 0xFF000000, + Rows = new byte[] { 0x44, 0x55, 0x66, 0x77 }, RgbMasksVerified = true + }), 0x44, 0x55, 0x66, 255); + AssertPixel(DecodeSurfaceToBgra(new SurfaceReadback { + Width = 1, Height = 2, Pitch = -3, BitCount = 24, + RedMask = 0x00FF0000, GreenMask = 0x0000FF00, BlueMask = 0x000000FF, + Rows = new byte[] { 1, 2, 3, 4, 5, 6 }, RgbMasksVerified = true + }), 4, 5, 6, 255, 1, 2, 3, 255); + Console.WriteLine("self-check: D3D7 GetRenderTarget and Surface7 Lock/Unlock/Release SDK vtable ordinals OK"); + Console.WriteLine("self-check: caller stack footprint (24/12 bytes), original ESP, committed writable range and guard rejection OK"); + Console.WriteLine("self-check: selected-camera setter stub uses verified interface slot +0x1C and 16-byte maximum caller stack OK"); + Console.WriteLine("self-check: 24-byte present call arguments compare unchanged and detect a changed byte"); + Console.WriteLine("self-check: return context logging verifies remote EIP/ESP before restoring the original present call"); + Console.WriteLine("self-check: viewport bounds fit the captured pixel surface OK"); + Console.WriteLine("self-check: DDLOCK_READONLY|DONOTWAIT=0x4010; success/failure paths include Release; cleanup calls Release after Unlock regardless of result"); + Console.WriteLine("self-check: RGB565, 24-bit, 32-bit opaque PNG conversion and negative-pitch row order OK"); + return 0; + } + + private static int VtableOffset(string[] methodOrder, string method) + { + for (int i = 0; i < methodOrder.Length; i++) + if (String.Equals(methodOrder[i], method, StringComparison.Ordinal)) return i * 4; + return -1; + } + + private static int DecodeIndirectCallDisplacement(byte[] code, int offset) + { + if (code == null || offset < 0 || offset + 2 > code.Length || code[offset] != 0xFF + || ((code[offset + 1] >> 3) & 7) != 2) + throw new InvalidOperationException("Expected an x86 indirect CALL instruction."); + int mod = code[offset + 1] >> 6; + int rm = code[offset + 1] & 7; + if (mod == 1) + { + if (offset + 3 > code.Length) throw new InvalidOperationException("Truncated disp8 indirect CALL."); + return unchecked((sbyte)code[offset + 2]); + } + if (mod == 2 || (mod == 0 && rm == 5)) + { + if (offset + 6 > code.Length) throw new InvalidOperationException("Truncated disp32 indirect CALL."); + return BitConverter.ToInt32(code, offset + 2); + } + return 0; + } +} diff --git a/tools/native-frame-capture/NativeFrameCapture.cs b/tools/native-frame-capture/NativeFrameCapture.cs new file mode 100644 index 0000000..2a2d8c9 --- /dev/null +++ b/tools/native-frame-capture/NativeFrameCapture.cs @@ -0,0 +1,2525 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; + +// Small, x86-only, one-shot debugger for the isolated GOG-compatible scratch +// process. It records verified camera/projection fields and a D3D7 frame. Pixel +// readback calls the target's verified D3D7 COM methods on its stopped render +// thread through a bounded temporary stub; it does not load game DLLs. +internal static partial class NativeFrameCapture +{ + private const uint DbgContinue = 0x00010002; + private const uint DbgExceptionNotHandled = 0x80010001; + private const uint ExceptionDebugEvent = 1; + private const uint CreateProcessDebugEvent = 3; + private const uint CreateThreadDebugEvent = 2; + private const uint ExitThreadDebugEvent = 4; + private const uint ExitProcessDebugEvent = 5; + private const uint LoadDllDebugEvent = 6; + private const uint PageExecuteReadWrite = 0x40; + private const uint ThreadGetContext = 0x0008; + private const uint ThreadSetContext = 0x0010; + private const int ContextEipOffset = 184; + private const int ContextEbxOffset = 164; + private const int ContextEdxOffset = 168; + private const int ContextEcxOffset = 172; + private const int ContextEsiOffset = 160; + private const int ContextEbpOffset = 180; + private const int ContextEspOffset = 196; + private const int PresentCallArgumentsBytes = 24; + private const int X86ContextSize = 716; + private const uint MemCommit = 0x1000; + private const uint PageNoAccess = 0x01; + private const uint PageReadOnly = 0x02; + private const uint PageReadWrite = 0x04; + private const uint PageWriteCopy = 0x08; + private const uint PageExecuteRead = 0x20; + private const uint PageExecuteWriteCopy = 0x80; + private const string GameDirectory = @"target\shadow-probe\Parkan - Iron Strategy"; + private const string GameExe = "iron_3d.exe"; + private const string World3DName = "World3D.dll"; + private const string TerrainName = "Terrain.dll"; + private const string Ngi32Name = "Ngi32.dll"; + private const string Iron3dName = "iron3d.dll"; + private const uint RenderGameRva = 0x13BD0; + private const uint ProjectionSnapshotRva = 0x13CE4; + private const uint RenderReturnRva = 0x13D7B; + private const uint AtmospherePhaseRva = 0x421DC; + private const uint PresentBoundaryRva = 0x6E1B; + private const uint RelocatedGlobalRva = 0x79518C; + private const uint ExternalCameraVtableRva = 0x665B4; + private const uint MissionFactoryRva = 0xA1FE1; + private const uint MissionVtableCallRva = 0xA1FF0; + private const uint MissionImportThunkRva = 0xCD01C; + private const int MissionProbeAttemptLimit = 8; + private const int FrameCaptureTimeoutSeconds = 300; + + private static readonly Dictionary ExpectedSha256 = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "iron_3d.exe", "F476AF85C034A4B4F34F49D0806E4DFF397B5DA0EE26D382A7674231144979F7" }, + { "World3D.dll", "17E4A3089B2583A8CF2356C9DB0390B1ABA138356A09130D79B4E7E4791DA61E" }, + { "Ngi32.dll", "BAB9840D94F4E4E74FFC26677724FA896CF4823845504D09A9E025F80016EDF5" }, + { "Terrain.dll", "AF87D1B2E728A0BE73C52BE3B44CC196AB46DA7799F25A15D40F8C9B0B425EAD" }, + { "iron3d.dll", "D1DC4EA8535E2069B0A05C9E8BE2724DD438BA44CCA7A83007D8B2E50F9E35FA" } + }; + + private static string _logPath; + + private sealed class BreakpointInfo + { + public string Name; + public uint Address; + public byte OriginalByte; + public bool Armed; + } + + private sealed class FrameSnapshot + { + public uint CameraGeneration; + public uint CameraThreadId; + public CameraReadback Camera; + public uint ProjectionGeneration; + public uint ProjectionThreadId; + public ProjectionReadback Projection; + public uint CameraWorldGameTimeWord; + public bool CameraWorldGameTimeWordReadable; + public uint WorldGameTimeWord; + public bool WorldGameTimeWordReadable; + public AtmosphereReadback Atmosphere; + public readonly Dictionary AtmosphereByThread = new Dictionary(); + public string MissionPath; + public string MissionEvidence; + public bool SelectedCameraRequested; + public SelectedCameraState SelectedCamera; + } + + private sealed class AtmosphereReadback + { + public uint SampleThreadId; + public uint CameraGenerationAtSample; + public uint CameraThreadIdAtSample; + public uint AtmosphereObject; + public uint RawClock; + public uint PhaseMilliseconds; + public uint Origin; + public uint PeriodMilliseconds; + public uint RecomputedPhaseMilliseconds; + public uint WorldGameTimeWord; + public bool WorldGameTimeWordReadable; + public string TerrainModuleSha256; + public bool TerrainModuleHashVerified; + public bool ArithmeticVerified; + public bool WorldTimeMatchesRawClock; + public uint CandidateRenderGeneration; + public uint CandidateRenderThreadId; + public uint MatchedRenderGeneration; + public uint MatchedRenderThreadId; + public bool SameGenerationVerified; + } + + private sealed class PendingStep + { + public BreakpointInfo Breakpoint; + public uint ThreadId; + public DateTime DeadlineUtc; + public bool RearmOnComplete; + } + + private sealed class SelectedCameraState + { + public SelectedCameraInput Input; + public uint CandidateCameraPointer; + public uint CandidateThreadId; + public uint CandidateGeneration; + public bool CandidateProjectionVerified; + public bool Applied; + public bool ProjectionMatchesInput; + public bool Restored; + public bool RestoreVerified; + public bool MatrixIntactAtReturn; + public bool PixelAttributionInvalidated; + public string PixelAttributionFailure; + public bool ProjectionGateDiagnosticLogged; + public bool ReturnGateDiagnosticLogged; + public DateTime RestoreDeadlineUtc; + public uint CameraPointer; + public uint ThreadId; + public uint Generation; + public uint EntryEsp; + public uint FunctionStackEsp; + public uint ReturnAddress; + public byte[] OriginalMatrixBytes; + public string OriginalMatrixSha256; + } + + private enum RemoteReadbackPhase { Acquire, Cleanup } + + private sealed class RemoteReadbackSession + { + public RemoteCode Acquire; + public RemoteCode Cleanup; + public uint Region; + public uint ThreadId; + public uint SafeEsp; + public byte[] OriginalContext; + public byte[] PresentStackArguments; + public bool PresentStackArgumentsIntact; + public bool RemoteContextIntact; + public BreakpointInfo PresentBreakpoint; + public FrameSnapshot Frame; + public uint Generation; + public string OutputJson; + public string OutputPng; + public DateTime DeadlineUtc; + public RemoteReadbackPhase Phase; + public SurfaceReadback Surface; + public string PixelFailure; + public uint Status; + public uint ReleaseResult; + public uint GetHr; + public uint LockHr; + public uint UnlockHr; + } + + [StructLayout(LayoutKind.Explicit, Size = X86ContextSize)] + private struct X86ContextLayout + { + [FieldOffset(ContextEipOffset)] public uint Eip; + [FieldOffset(ContextEsiOffset)] public uint Esi; + [FieldOffset(ContextEbpOffset)] public uint Ebp; + [FieldOffset(ContextEspOffset)] public uint Esp; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MemoryBasicInformation + { + public IntPtr BaseAddress; + public IntPtr AllocationBase; + public uint AllocationProtect; + public UIntPtr RegionSize; + public uint State; + public uint Protect; + public uint Type; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DebugActiveProcess(uint processId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DebugActiveProcessStop(uint processId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DebugSetProcessKillOnExit(bool killOnExit); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool WaitForDebugEvent(IntPtr debugEvent, uint milliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ContinueDebugEvent(uint processId, uint threadId, uint continueStatus); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ReadProcessMemory(IntPtr process, IntPtr address, + [Out] byte[] buffer, UIntPtr size, out UIntPtr bytesRead); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool WriteProcessMemory(IntPtr process, IntPtr address, + byte[] buffer, UIntPtr size, out UIntPtr bytesWritten); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool VirtualProtectEx(IntPtr process, IntPtr address, + UIntPtr size, uint newProtection, out uint oldProtection); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool FlushInstructionCache(IntPtr process, IntPtr address, UIntPtr size); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenThread(uint desiredAccess, bool inheritHandle, uint threadId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetThreadContext(IntPtr thread, IntPtr context); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetThreadContext(IntPtr thread, IntPtr context); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(uint desiredAccess, bool inheritHandle, uint processId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern UIntPtr VirtualQueryEx(IntPtr process, IntPtr address, + out MemoryBasicInformation information, UIntPtr length); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern uint GetFinalPathNameByHandleW(IntPtr file, StringBuilder path, + uint pathLength, uint flags); + + [DllImport("psapi.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern uint GetModuleFileNameExW(IntPtr process, IntPtr module, + StringBuilder fileName, uint size); + + private static IntPtr Ptr(uint value) { return new IntPtr(unchecked((int)value)); } + + private static void Log(string text) + { + if (_logPath != null) + File.AppendAllText(_logPath, DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture) + + " " + text + Environment.NewLine, Encoding.UTF8); + } + + private static string NormalizePath(string path) + { + if (path.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) return @"\\" + path.Substring(8); + if (path.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase)) return path.Substring(4); + return Path.GetFullPath(path); + } + + private static string FindRepositoryRoot() + { + DirectoryInfo directory = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "Cargo.toml")) + && Directory.Exists(Path.Combine(directory.FullName, ".git"))) return directory.FullName; + directory = directory.Parent; + } + directory = new DirectoryInfo(Environment.CurrentDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "Cargo.toml"))) return directory.FullName; + directory = directory.Parent; + } + throw new InvalidOperationException("Run from this repository or place the EXE beneath it."); + } + + private static string HashFile(string path) + { + using (SHA256 sha = SHA256.Create()) + using (FileStream stream = File.OpenRead(path)) + { + byte[] hash = sha.ComputeHash(stream); + StringBuilder result = new StringBuilder(hash.Length * 2); + for (int i = 0; i < hash.Length; i++) result.Append(hash[i].ToString("X2")); + return result.ToString(); + } + } + + private static string VerifyScratchFiles(string repositoryRoot) + { + string directory = Path.GetFullPath(Path.Combine(repositoryRoot, GameDirectory)); + foreach (KeyValuePair expected in ExpectedSha256) + { + string path = Path.Combine(directory, expected.Key); + if (!File.Exists(path)) throw new FileNotFoundException("Missing scratch file: " + path, path); + string observed = HashFile(path); + if (!String.Equals(observed, expected.Value, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("SHA-256 mismatch for " + path + ": " + observed); + } + return Path.Combine(directory, GameExe); + } + + private static bool Read(IntPtr process, uint address, byte[] bytes) + { + UIntPtr count; + return address != 0 && ReadProcessMemory(process, Ptr(address), bytes, + new UIntPtr((uint)bytes.Length), out count) && count.ToUInt32() == (uint)bytes.Length; + } + + private static uint ReadU32(IntPtr process, uint address) + { + byte[] bytes = new byte[4]; + return Read(process, address, bytes) ? BitConverter.ToUInt32(bytes, 0) : 0; + } + + private static bool IsFinite(float value) + { + return !Single.IsNaN(value) && !Single.IsInfinity(value); + } + + private static bool WriteByte(IntPtr process, uint address, byte value) + { + uint oldProtection; + if (!VirtualProtectEx(process, Ptr(address), new UIntPtr(1), PageExecuteReadWrite, out oldProtection)) + return false; + UIntPtr written; + bool result = WriteProcessMemory(process, Ptr(address), new byte[] { value }, new UIntPtr(1), out written) + && written.ToUInt32() == 1; + uint ignored; + VirtualProtectEx(process, Ptr(address), new UIntPtr(1), oldProtection, out ignored); + FlushInstructionCache(process, Ptr(address), new UIntPtr(1)); + return result; + } + + private static string PathForHandle(IntPtr file) + { + if (file == IntPtr.Zero) return ""; + StringBuilder buffer = new StringBuilder(1024); + uint length = GetFinalPathNameByHandleW(file, buffer, (uint)buffer.Capacity, 0); + return length == 0 || length >= buffer.Capacity ? "" : NormalizePath(buffer.ToString()); + } + + private static string ModulePath(IntPtr process, uint moduleBase) + { + StringBuilder buffer = new StringBuilder(1024); + uint length = GetModuleFileNameExW(process, Ptr(moduleBase), buffer, (uint)buffer.Capacity); + return length == 0 || length >= buffer.Capacity ? "" : NormalizePath(buffer.ToString()); + } + + private static bool VerifyRenderEntry(IntPtr process, uint moduleBase, out byte firstByte, out string evidence) + { + firstByte = 0; + evidence = ""; + byte[] code = new byte[15]; + uint address = unchecked(moduleBase + RenderGameRva); + if (!Read(process, address, code)) + { + evidence = "World3D+0x13BD0 unreadable"; + return false; + } + uint relocatedOperand = BitConverter.ToUInt32(code, 5); + bool match = code[0] == 0x83 && code[1] == 0xEC && code[2] == 0x64 + && code[3] == 0xC7 && code[4] == 0x05 + && relocatedOperand == unchecked(moduleBase + RelocatedGlobalRva) + && code[9] == 0 && code[10] == 0 && code[11] == 0 && code[12] == 0 + && code[13] == 0x53 && code[14] == 0x56; + evidence = "base=0x" + moduleBase.ToString("X8") + " RVA=0x13BD0 bytes=" + + BitConverter.ToString(code) + " relocOperand=0x" + relocatedOperand.ToString("X8") + + " expectedOperand=0x" + unchecked(moduleBase + RelocatedGlobalRva).ToString("X8"); + firstByte = code[0]; + return match; + } + + private static bool VerifyRenderReturnBoundary(IntPtr process, uint moduleBase, + out byte firstByte, out string evidence) + { + firstByte = 0; + byte[] expected = new byte[] { 0x5F, 0x5E, 0x5B, 0x83, 0xC4, 0x64, 0xC2, 0x04, 0x00 }; + byte[] actual = new byte[expected.Length]; + uint address = unchecked(moduleBase + RenderReturnRva); + if (!ReadExact(process, address, actual)) { evidence = "return-epilogue bytes unreadable"; return false; } + if (!ByteArraysEqual(actual, expected)) + { + evidence = "return-epilogue mismatch at 0x" + address.ToString("X8") + + " expected=" + BitConverter.ToString(expected) + " actual=" + BitConverter.ToString(actual); + return false; + } + firstByte = actual[0]; + evidence = "verified one-byte pop-edi at World3D+0x13D7B followed by pop esi/pop ebx/add esp,64h/ret 4"; + return true; + } + + private static bool VerifyD3d7GetRenderTarget(IntPtr process, uint ngiBase, + out uint device, out uint getRenderTarget, out string evidence) + { + device = ReadU32(process, unchecked(ngiBase + 0x3A488)); + getRenderTarget = 0; + if (device == 0) + { + evidence = "Ngi32 D3D7 device global is null"; + return false; + } + uint vtable = ReadU32(process, device); + if (vtable == 0 || !ReadExact(process, unchecked(vtable + 0x24), new byte[4])) + { + evidence = "D3D7 device vtable or GetRenderTarget slot is unreadable"; + return false; + } + getRenderTarget = ReadU32(process, unchecked(vtable + 0x24)); + if (getRenderTarget == 0) + { + evidence = "D3D7 GetRenderTarget slot is null"; + return false; + } + MemoryBasicInformation memory; + UIntPtr queried = VirtualQueryEx(process, Ptr(getRenderTarget), out memory, + new UIntPtr((uint)Marshal.SizeOf(typeof(MemoryBasicInformation)))); + uint protection = memory.Protect & 0xFF; + bool executable = memory.State == 0x1000 + && (protection == 0x10 || protection == 0x20 || protection == 0x40 || protection == 0x80); + evidence = "device=0x" + device.ToString("X8") + " vtable=0x" + vtable.ToString("X8") + + " GetRenderTarget=0x" + getRenderTarget.ToString("X8") + " pageState=0x" + + memory.State.ToString("X8") + " protect=0x" + memory.Protect.ToString("X8"); + return queried.ToUInt32() != 0 && executable; + } + + private static string JsonString(string value) + { + StringBuilder result = new StringBuilder(); + result.Append('"'); + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + if (c == '"') result.Append("\\\""); + else if (c == '\\') result.Append("\\\\"); + else if (c == '\n') result.Append("\\n"); + else if (c == '\r') result.Append("\\r"); + else if (c == '\t') result.Append("\\t"); + else if (c < 0x20) result.Append("\\u").Append(((int)c).ToString("X4")); + else result.Append(c); + } + result.Append('"'); + return result.ToString(); + } + + private static bool VerifyProjectionBoundary(IntPtr process, uint world3dBase, out byte firstByte, out string evidence) + { + const uint projectionGlobalRva = 0x795170; + firstByte = 0; + byte[] code = new byte[11]; + if (!Read(process, unchecked(world3dBase + ProjectionSnapshotRva), code)) + { + evidence = "projection boundary unreadable"; + return false; + } + uint relocatedGlobal = BitConverter.ToUInt32(code, 2); + bool match = code[0] == 0x8B && code[1] == 0x0D + && relocatedGlobal == unchecked(world3dBase + projectionGlobalRva) + && code[6] == 0x8B && code[7] == 0x11 + && code[8] == 0xFF && code[9] == 0x52 && code[10] == 0x34; + firstByte = code[0]; + evidence = "base=0x" + world3dBase.ToString("X8") + " RVA=0x13CE4 bytes=" + + BitConverter.ToString(code) + " global=0x" + relocatedGlobal.ToString("X8"); + return match; + } + + private static bool VerifyAtmospherePhaseBoundary(IntPtr process, uint terrainBase, + out byte firstByte, out string evidence) + { + firstByte = 0; + byte[] code = new byte[15]; + uint address = unchecked(terrainBase + AtmospherePhaseRva - 12); + if (!Read(process, address, code)) + { + evidence = "Terrain+0x421DC phase boundary unreadable"; + return false; + } + byte[] expected = new byte[] { + 0x8B, 0xC5, // mov eax, ebp (raw clock) + 0x2B, 0xC1, // sub eax, ecx (origin from [esi+0x144]) + 0x33, 0xD2, // xor edx, edx + 0xF7, 0xB6, 0x50, 0x01, 0x00, 0x00, // div dword ptr [esi+0x150] (phase remainder in edx) + 0x57, // push edi (breakpoint before phase is consumed) + 0x8B, 0xFA // mov edi, edx + }; + bool match = ByteArraysEqual(code, expected); + firstByte = code[12]; + evidence = "base=0x" + terrainBase.ToString("X8") + " RVA=0x421DC bytes=" + + BitConverter.ToString(code) + " rawClock=EBP origin=[ESI+0x144]" + + " period=[ESI+0x150] remainder=EDX"; + return match && firstByte == 0x57; + } + + private static bool VerifyMissionIdentityBoundary(IntPtr process, uint iron3dBase, + out byte firstByte, out string evidence) + { + firstByte = 0; + byte[] code = new byte[18]; + if (!Read(process, unchecked(iron3dBase + MissionFactoryRva), code)) + { + evidence = "iron3d mission factory/call boundary unreadable"; + return false; + } + firstByte = code[15]; + int displacement = BitConverter.ToInt32(code, 1); + uint factoryTarget = unchecked(iron3dBase + MissionFactoryRva + 5 + (uint)displacement); + bool match = code[0] == 0xE8 + && factoryTarget == unchecked(iron3dBase + MissionImportThunkRva) + && code[5] == 0x8B && code[6] == 0x54 && code[7] == 0x24 && code[8] == 0x40 + && code[9] == 0x8B && code[10] == 0xD8 + && code[11] == 0x8B && code[12] == 0x0B + && code[13] == 0x52 && code[14] == 0x53 + && code[15] == 0xFF && code[16] == 0x51 && code[17] == 0x08; + evidence = "base=0x" + iron3dBase.ToString("X8") + " factoryRva=0xA1FE1 bytes=" + + BitConverter.ToString(code) + " factoryTarget=0x" + factoryTarget.ToString("X8") + + " vtableCallRva=0xA1FF0"; + return match; + } + + private static bool IsReadableProtection(uint protection) + { + uint basic = protection & 0xFF; + return basic != PageNoAccess && (protection & PageGuard) == 0 + && (basic == PageReadOnly || basic == PageReadWrite || basic == PageWriteCopy + || basic == PageExecuteRead || basic == PageExecuteReadWrite || basic == PageExecuteWriteCopy); + } + + private static bool TryReadReadableRegion(IntPtr process, uint address, int maxBytes, + out byte[] bytes, out string evidence) + { + bytes = null; + evidence = "unreadable"; + if (address < 0x10000 || maxBytes <= 0) { evidence = "invalid address or length"; return false; } + MemoryBasicInformation memory; + UIntPtr queried = VirtualQueryEx(process, Ptr(address), out memory, + new UIntPtr((uint)Marshal.SizeOf(typeof(MemoryBasicInformation)))); + uint baseAddress = unchecked((uint)memory.BaseAddress.ToInt32()); + uint regionSize = memory.RegionSize.ToUInt32(); + ulong regionEnd = (ulong)baseAddress + regionSize; + uint protection = memory.Protect; + if (queried.ToUInt32() == 0 || memory.State != MemCommit || !IsReadableProtection(protection) + || address < baseAddress || (ulong)address >= regionEnd) + { + evidence = "VirtualQueryEx rejected addr=0x" + address.ToString("X8") + + " state=0x" + memory.State.ToString("X8") + " protect=0x" + protection.ToString("X8"); + return false; + } + int count = (int)Math.Min((ulong)maxBytes, regionEnd - address); + if (count <= 0) { evidence = "empty readable region"; return false; } + byte[] result = new byte[count]; + if (!Read(process, address, result)) + { + evidence = "ReadProcessMemory failed for addr=0x" + address.ToString("X8") + " bytes=" + count; + return false; + } + bytes = result; + evidence = "addr=0x" + address.ToString("X8") + " bytes=" + count + + " state=0x" + memory.State.ToString("X8") + " protect=0x" + protection.ToString("X8"); + return true; + } + + private static string DecodeAsciiMissionCandidate(byte[] bytes, int start, int maxLength) + { + if (bytes == null || start < 0 || start >= bytes.Length) return null; + int end = Math.Min(bytes.Length, start + maxLength); + StringBuilder text = new StringBuilder(); + for (int i = start; i < end; i++) + { + byte value = bytes[i]; + if (value == 0) break; + if (value < 0x20 || value > 0x7E) return null; + text.Append((char)value); + } + string candidate = text.ToString().Trim(); + return IsMissionPathCandidate(candidate) ? candidate : null; + } + + private static bool IsMissionPathCandidate(string candidate) + { + if (String.IsNullOrEmpty(candidate) || candidate.Length > 512) return false; + string lower = candidate.ToLowerInvariant().Replace('/', '\\'); + return lower.Contains("missions\\") && (lower.Contains(".tma") || lower.Contains(".mis") + || lower.Contains("autodemo") || lower.Contains("\\data")); + } + + private static string TryReadMissionPathArgument(IntPtr process, uint argument, out string evidence) + { + evidence = "argument pointer is not a verified mission path"; + if (argument < 0x10000) return null; + + byte[] objectBytes; + string objectEvidence; + if (!TryReadReadableRegion(process, argument, 64, out objectBytes, out objectEvidence)) + { + evidence = "argument=" + objectEvidence; + return null; + } + for (int i = 0; i < objectBytes.Length; i++) + { + string inline = DecodeAsciiMissionCandidate(objectBytes, i, 512); + if (inline != null) + { + evidence = "mission path found inline in call argument (" + objectEvidence + ")"; + return inline; + } + } + + // The callsite passes a string object by pointer. Try the observed word + // fields as readable C-string pointers without assuming a string ABI. + int pointerWords = Math.Min(8, objectBytes.Length / 4); + for (int i = 0; i < pointerWords; i++) + { + uint pointer = BitConverter.ToUInt32(objectBytes, i * 4); + byte[] pointed; + string pointedEvidence; + if (!TryReadReadableRegion(process, pointer, 512, out pointed, out pointedEvidence)) continue; + string candidate = DecodeAsciiMissionCandidate(pointed, 0, 512); + if (candidate == null) continue; + evidence = "mission path found through argument word +0x" + (i * 4).ToString("X2") + + " (" + pointedEvidence + ")"; + return candidate; + } + + evidence = "argument object=" + objectEvidence + " raw64=" + BitConverter.ToString(objectBytes); + return null; + } + + private static bool CaptureMissionIdentityAtCall(IntPtr process, uint iron3dBase, + byte[] context, uint threadId, out string path, out string evidence) + { + path = null; + evidence = "mission call arguments were not verified"; + if (context == null || context.Length != X86ContextSize + || BitConverter.ToUInt32(context, ContextEipOffset) != unchecked(iron3dBase + MissionVtableCallRva + 1)) + { + evidence = "iron3d mission vtable-call breakpoint context mismatch"; + return false; + } + uint ebx = BitConverter.ToUInt32(context, ContextEbxOffset); + uint edx = BitConverter.ToUInt32(context, ContextEdxOffset); + uint ecx = BitConverter.ToUInt32(context, ContextEcxOffset); + uint esp = BitConverter.ToUInt32(context, ContextEspOffset); + byte[] args = new byte[8]; + if (ebx == 0 || edx == 0 || ecx == 0 || !Read(process, esp, args)) + { + evidence = "mission vtable-call registers or stack arguments unreadable"; + return false; + } + uint stackThis = BitConverter.ToUInt32(args, 0); + uint stackString = BitConverter.ToUInt32(args, 4); + uint vtable = ReadU32(process, ebx); + uint method = ReadU32(process, unchecked(vtable + 8)); + MemoryBasicInformation methodMemory; + UIntPtr methodQuery = VirtualQueryEx(process, Ptr(method), out methodMemory, + new UIntPtr((uint)Marshal.SizeOf(typeof(MemoryBasicInformation)))); + uint methodProtection = methodMemory.Protect & 0xFF; + bool executableMethod = methodQuery.ToUInt32() != 0 && methodMemory.State == MemCommit + && (methodProtection == 0x10 || methodProtection == 0x20 + || methodProtection == 0x40 || methodProtection == 0x80); + Log("MISSION_VTABLE_CALL tid=" + threadId + " ebx=0x" + ebx.ToString("X8") + + " edx=0x" + edx.ToString("X8") + " ecx=0x" + ecx.ToString("X8") + + " vtable=0x" + vtable.ToString("X8") + " slot8=0x" + method.ToString("X8") + + " executableSlot=" + executableMethod + " stackThis=0x" + stackThis.ToString("X8") + + " stackArg1=0x" + stackString.ToString("X8")); + if (ecx != vtable || stackThis != ebx || stackString != edx || !executableMethod) + { + evidence = "mission vtable-call registers, stack arguments, or vtable slot failed verification"; + return false; + } + path = TryReadMissionPathArgument(process, edx, out evidence); + if (path == null) evidence = "mission vtable argument path unresolved: " + evidence; + return path != null; + } + + private static bool ComputeAtmospherePhaseMilliseconds(uint rawClock, uint origin, + uint periodMilliseconds, out uint phaseMilliseconds) + { + phaseMilliseconds = 0; + if (periodMilliseconds == 0) return false; + uint delta = unchecked(rawClock - origin); + phaseMilliseconds = delta % periodMilliseconds; + return true; + } + + private static AtmosphereReadback CaptureAtmospherePhase(IntPtr process, uint terrainBase, + uint world3dBase, byte[] context, uint threadId, string terrainModuleSha256, + FrameSnapshot frame) + { + if (context == null || context.Length != X86ContextSize + || BitConverter.ToUInt32(context, ContextEipOffset) != unchecked(terrainBase + AtmospherePhaseRva + 1)) + throw new InvalidOperationException("Terrain atmosphere-phase breakpoint context mismatch."); + + uint atmosphereObject = BitConverter.ToUInt32(context, ContextEsiOffset); + uint rawClock = BitConverter.ToUInt32(context, ContextEbpOffset); + uint phaseMilliseconds = BitConverter.ToUInt32(context, ContextEdxOffset); + if (atmosphereObject < 0x10000 || world3dBase == 0) + throw new InvalidOperationException("Terrain atmosphere object or World3D game-time source was unavailable."); + + uint origin = ReadU32Exact(process, unchecked(atmosphereObject + 0x144)); + uint periodMilliseconds = ReadU32Exact(process, unchecked(atmosphereObject + 0x150)); + uint worldGameTimeWord = ReadU32Exact(process, unchecked(world3dBase + 0x32A38)); + uint recomputed; + bool arithmeticVerified = ComputeAtmospherePhaseMilliseconds(rawClock, origin, + periodMilliseconds, out recomputed) && recomputed == phaseMilliseconds; + bool worldTimeMatchesRawClock = rawClock == worldGameTimeWord; + return new AtmosphereReadback + { + SampleThreadId = threadId, + CameraGenerationAtSample = frame == null ? 0 : frame.CameraGeneration, + CameraThreadIdAtSample = frame == null ? 0 : frame.CameraThreadId, + AtmosphereObject = atmosphereObject, + RawClock = rawClock, + PhaseMilliseconds = phaseMilliseconds, + Origin = origin, + PeriodMilliseconds = periodMilliseconds, + RecomputedPhaseMilliseconds = recomputed, + WorldGameTimeWord = worldGameTimeWord, + WorldGameTimeWordReadable = true, + TerrainModuleSha256 = terrainModuleSha256, + TerrainModuleHashVerified = String.Equals(terrainModuleSha256, + ExpectedSha256[TerrainName], StringComparison.OrdinalIgnoreCase), + ArithmeticVerified = arithmeticVerified, + WorldTimeMatchesRawClock = worldTimeMatchesRawClock + }; + } + + private static bool IsVerifiedAtmosphereForFrame(FrameSnapshot frame, uint projectionThreadId) + { + if (frame == null || frame.Atmosphere == null) return false; + AtmosphereReadback atmosphere = frame.Atmosphere; + return atmosphere.TerrainModuleHashVerified + && atmosphere.PeriodMilliseconds != 0 + && atmosphere.ArithmeticVerified + && atmosphere.WorldGameTimeWordReadable + && atmosphere.WorldTimeMatchesRawClock + && atmosphere.RawClock == frame.WorldGameTimeWord + && atmosphere.WorldGameTimeWord == frame.WorldGameTimeWord + && frame.CameraWorldGameTimeWordReadable + && frame.WorldGameTimeWordReadable + && frame.CameraWorldGameTimeWord == frame.WorldGameTimeWord + && atmosphere.SampleThreadId == frame.CameraThreadId + && atmosphere.SampleThreadId == frame.ProjectionThreadId + && frame.ProjectionThreadId == projectionThreadId + && frame.CameraGeneration != 0 + && frame.CameraGeneration == frame.ProjectionGeneration + && frame.CameraThreadId == projectionThreadId + && frame.ProjectionGeneration == atmosphere.MatchedRenderGeneration + && atmosphere.MatchedRenderThreadId == projectionThreadId + && frame.Camera != null && frame.Camera.CurrentAtProjectionVerified + && frame.Projection != null && frame.Projection.Verified; + } + + private static void PairAtmosphereToProjection(FrameSnapshot frame, uint projectionThreadId) + { + frame.Atmosphere = null; + AtmosphereReadback candidate; + if (frame.AtmosphereByThread == null + || !frame.AtmosphereByThread.TryGetValue(projectionThreadId, out candidate)) + { + Log("ATMOSPHERE_FRAME_PAIR missing_sample tid=" + projectionThreadId + + " generation=" + frame.CameraGeneration); + return; + } + candidate.CandidateRenderGeneration = frame.CameraGeneration; + candidate.CandidateRenderThreadId = projectionThreadId; + candidate.MatchedRenderGeneration = frame.CameraGeneration; + candidate.MatchedRenderThreadId = projectionThreadId; + frame.Atmosphere = candidate; + candidate.SameGenerationVerified = IsVerifiedAtmosphereForFrame(frame, projectionThreadId); + if (!candidate.SameGenerationVerified) + { + candidate.MatchedRenderGeneration = 0; + candidate.MatchedRenderThreadId = 0; + } + Log("ATMOSPHERE_FRAME_PAIR generation=" + frame.CameraGeneration + " tid=" + projectionThreadId + + " sampleTid=" + candidate.SampleThreadId + " esi=0x" + candidate.AtmosphereObject.ToString("X8") + + " rawClock=0x" + candidate.RawClock.ToString("X8") + " phaseMs=" + candidate.PhaseMilliseconds + + " origin=" + candidate.Origin + " periodMs=" + candidate.PeriodMilliseconds + + " recomputedPhaseMs=" + candidate.RecomputedPhaseMilliseconds + + " worldGameTime=0x" + candidate.WorldGameTimeWord.ToString("X8") + + " arithmeticVerified=" + candidate.ArithmeticVerified + + " rawMatchesWorldTime=" + candidate.WorldTimeMatchesRawClock + + " entryWorldTime=0x" + frame.CameraWorldGameTimeWord.ToString("X8") + + " projectionWorldTime=0x" + frame.WorldGameTimeWord.ToString("X8") + + " sameGenerationVerified=" + candidate.SameGenerationVerified); + } + + private static bool VerifyPresentBoundary(IntPtr process, uint ngiBase, out byte firstByte, out string evidence) + { + firstByte = 0; + byte[] code = new byte[3]; + if (!Read(process, unchecked(ngiBase + PresentBoundaryRva), code)) + { + evidence = "Ngi32 present boundary unreadable"; + return false; + } + firstByte = code[0]; + bool match = code[0] == 0xFF && code[1] == 0x50 && code[2] == 0x14; + evidence = "base=0x" + ngiBase.ToString("X8") + " RVA=0x6E1B bytes=" + BitConverter.ToString(code); + return match; + } + + private static byte[] ContextAtBreakpoint(byte[] original, uint address, bool singleStep) + { + if (original == null || original.Length != X86ContextSize) + throw new ArgumentException("Expected a full x86 thread context.", "original"); + byte[] result = (byte[])original.Clone(); + Buffer.BlockCopy(BitConverter.GetBytes(unchecked((int)address)), 0, result, ContextEipOffset, 4); + int flags = BitConverter.ToInt32(result, ContextEflagsOffset); + flags = singleStep ? (flags | 0x100) : (flags & ~0x100); + Buffer.BlockCopy(BitConverter.GetBytes(flags), 0, result, ContextEflagsOffset, 4); + return result; + } + + private static void BeginSingleStep(IntPtr process, uint threadId, BreakpointInfo breakpoint, + byte[] stoppedContext, out PendingStep pending, bool rearmOnComplete) + { + pending = null; + if (!breakpoint.Armed) throw new InvalidOperationException(breakpoint.Name + " was not armed at its hit."); + if (!WriteByte(process, breakpoint.Address, breakpoint.OriginalByte)) + throw new InvalidOperationException("Could not restore " + breakpoint.Name + " before single-step."); + breakpoint.Armed = false; + IntPtr thread = OpenThread(ThreadGetContext | ThreadSetContext, false, threadId); + if (thread == IntPtr.Zero) throw new InvalidOperationException("OpenThread for " + breakpoint.Name + " failed: " + Marshal.GetLastWin32Error()); + try + { + SetFullX86Context(thread, ContextAtBreakpoint(stoppedContext, breakpoint.Address, true)); + } + finally { CloseHandle(thread); } + pending = new PendingStep { Breakpoint = breakpoint, ThreadId = threadId, + DeadlineUtc = DateTime.UtcNow.AddSeconds(5), RearmOnComplete = rearmOnComplete }; + } + + private static void FinishSingleStep(IntPtr process, uint threadId, PendingStep pending) + { + if (pending == null || pending.ThreadId != threadId) + throw new InvalidOperationException("Unexpected single-step thread."); + BreakpointInfo breakpoint = pending.Breakpoint; + if (pending.RearmOnComplete && !WriteByte(process, breakpoint.Address, 0xCC)) + throw new InvalidOperationException("Could not re-arm " + breakpoint.Name + " after single-step."); + IntPtr thread = OpenThread(ThreadGetContext | ThreadSetContext, false, threadId); + if (thread == IntPtr.Zero) throw new InvalidOperationException("OpenThread after single-step failed: " + Marshal.GetLastWin32Error()); + try + { + byte[] context = GetFullX86Context(thread); + int flags = BitConverter.ToInt32(context, ContextEflagsOffset) & ~0x100; + Buffer.BlockCopy(BitConverter.GetBytes(flags), 0, context, ContextEflagsOffset, 4); + SetFullX86Context(thread, context); + } + finally { CloseHandle(thread); } + breakpoint.Armed = pending.RearmOnComplete; + } + + private static void ResumeOriginalPresent(IntPtr process, RemoteReadbackSession session) + { + BreakpointInfo breakpoint = session.PresentBreakpoint; + if (breakpoint.Armed) + { + if (!WriteByte(process, breakpoint.Address, breakpoint.OriginalByte)) + throw new InvalidOperationException("Could not restore Ngi32 present call after readback."); + breakpoint.Armed = false; + } + IntPtr thread = OpenThread(ThreadGetContext | ThreadSetContext, false, session.ThreadId); + if (thread == IntPtr.Zero) throw new InvalidOperationException("OpenThread to resume Ngi32 present failed: " + Marshal.GetLastWin32Error()); + try + { + byte[] resumeContext = ContextAtBreakpoint(session.OriginalContext, breakpoint.Address, false); + SetFullX86Context(thread, resumeContext); + byte[] restoredContext = GetFullX86Context(thread); + uint expectedEsp = BitConverter.ToUInt32(session.OriginalContext, ContextEspOffset); + uint restoredEip = BitConverter.ToUInt32(restoredContext, ContextEipOffset); + uint restoredEsp = BitConverter.ToUInt32(restoredContext, ContextEspOffset); + Log("PRESENT_RESUME_CONTEXT expectedEip=0x" + breakpoint.Address.ToString("X8") + + " actualEip=0x" + restoredEip.ToString("X8") + + " expectedEsp=0x" + expectedEsp.ToString("X8") + + " actualEsp=0x" + restoredEsp.ToString("X8")); + if (restoredEip != breakpoint.Address || restoredEsp != expectedEsp) + throw new InvalidOperationException("Could not verify restored Ngi32 present EIP/ESP."); + } + finally { CloseHandle(thread); } + } + + private static bool CanStopCapture(bool captured, bool stopAfterAttempt, + bool remoteInFlight, bool singleStepInFlight, bool cameraRestorePending) + { + return (captured || stopAfterAttempt) && !remoteInFlight && !singleStepInFlight + && !cameraRestorePending; + } + + private static bool IsMatchingPerspectiveFrame(FrameSnapshot frame, uint threadId) + { + return CanArmPresentForFrame(frame, threadId) + && (!frame.SelectedCameraRequested || (frame.SelectedCamera != null + && frame.SelectedCamera.Applied && frame.SelectedCamera.ProjectionMatchesInput + && frame.SelectedCamera.CandidateProjectionVerified + && frame.SelectedCamera.Restored && frame.SelectedCamera.RestoreVerified + && frame.SelectedCamera.MatrixIntactAtReturn + && !frame.SelectedCamera.PixelAttributionInvalidated + && SelectedMatrixMatchesProjection(frame))); + } + + private static bool SelectedMatrixMatchesProjection(FrameSnapshot frame) + { + if (frame == null || !frame.SelectedCameraRequested) return true; + return frame.SelectedCamera != null && frame.SelectedCamera.Input != null + && frame.Camera != null && frame.Camera.CurrentAtProjectionVerified + && !frame.Camera.WordsChangedAtProjection + && ByteArraysEqual(frame.Camera.ProjectionMatrixBytes, frame.SelectedCamera.Input.MatrixBytes); + } + + private static bool CanArmPresentForFrame(FrameSnapshot frame, uint threadId) + { + return frame != null && frame.Camera != null && frame.Camera.LayoutVerified && frame.Camera.MatrixFinite + && frame.Camera.CurrentAtProjectionVerified + && frame.Projection != null && frame.Projection.Verified && frame.Projection.Mode != 0 + && frame.CameraGeneration != 0 && frame.ProjectionGeneration == frame.CameraGeneration + && frame.CameraThreadId == threadId && frame.ProjectionThreadId == threadId + && (!frame.SelectedCameraRequested || (frame.SelectedCamera != null + && frame.SelectedCamera.Applied && frame.SelectedCamera.ProjectionMatchesInput + && frame.SelectedCamera.CandidateProjectionVerified + && !frame.SelectedCamera.PixelAttributionInvalidated + && SelectedMatrixMatchesProjection(frame))); + } + + private static bool IsViewportInsideSurface(int[] viewport, uint width, uint height) + { + return viewport != null && viewport.Length == 4 && width > 0 && height > 0 + && viewport[0] >= 0 && viewport[1] >= 0 + && viewport[2] > viewport[0] && viewport[3] > viewport[1] + && (uint)viewport[2] <= width && (uint)viewport[3] <= height; + } + + private static string UsableFrameJson(string gamePath, uint world3dBase, uint ngiBase, + FrameSnapshot frame, SurfaceReadback surface, string pngPath, RemoteReadbackSession remote) + { + if (frame == null || frame.Camera == null || frame.Projection == null || surface == null) + throw new InvalidOperationException("A usable camera, projection, and pixel surface are required for the legacy input JSON."); + if (!IsMatchingPerspectiveFrame(frame, frame.ProjectionThreadId)) + throw new InvalidOperationException("Refusing to emit app-compatible JSON before camera restoration and pixel attribution are verified."); + CameraReadback camera = frame.Camera; + ProjectionReadback projection = frame.Projection; + if (!camera.LayoutVerified || !camera.MatrixFinite || !camera.CurrentAtProjectionVerified + || !projection.Verified || projection.Mode == 0) + throw new InvalidOperationException("Refusing to emit app-compatible JSON from an unverified camera or non-perspective projection."); + if (!IsViewportInsideSurface(projection.Viewport, surface.Width, surface.Height)) + throw new InvalidOperationException("Refusing to emit app-compatible JSON because the captured viewport lies outside the pixel surface."); + StringBuilder json = new StringBuilder(); + json.AppendLine("{"); + json.AppendLine(" \"schema\": \"fparkan-legacy-camera-v1\","); + json.AppendLine(" \"capture_status\": \"native-frame-captured\","); + json.AppendLine(" \"render_input_usable\": true,"); + json.AppendLine(" \"source\": \"GOG World3D stdRenderGame + Ngi32 D3D7 render target\","); + json.AppendLine(" \"scratch_executable\": " + JsonString(gamePath) + ","); + json.AppendLine(" \"world3d_module_base\": " + JsonString("0x" + world3dBase.ToString("X8")) + ","); + json.AppendLine(" \"ngi32_module_base\": " + JsonString("0x" + ngiBase.ToString("X8")) + ","); + json.AppendLine(" \"frame_generation\": " + frame.CameraGeneration.ToString(CultureInfo.InvariantCulture) + ","); + json.AppendLine(" \"camera_argument\": " + JsonString("0x" + camera.Camera.ToString("X8")) + ","); + json.AppendLine(" \"camera_vtable\": " + JsonString("0x" + camera.Vtable.ToString("X8")) + ","); + json.AppendLine(" \"selector_field_plus_0x10\": " + JsonString("0x" + camera.SelectorField.ToString("X8")) + ","); + json.AppendLine(" \"camera_words_sampled_at\": \"World3D+0x13CE4\","); + json.AppendLine(" \"camera_words_changed_since_render_entry\": " + (camera.WordsChangedAtProjection ? "true" : "false") + ","); + json.AppendLine(" \"camera_words_render_entry_sha256\": " + JsonString(camera.EntryMatrixSha256) + ","); + json.AppendLine(" \"camera_words_projection_boundary_sha256\": " + JsonString(camera.ProjectionMatrixSha256) + ","); + json.Append(" \"selector0_words\": ["); + for (int i = 0; i < camera.Words.Length; i++) + { + if (i != 0) json.Append(", "); + json.Append(camera.Words[i].ToString(CultureInfo.InvariantCulture)); + } + json.AppendLine("],"); + json.AppendLine(" \"viewport\": [" + String.Join(", ", projection.Viewport) + "],"); + json.AppendLine(" \"near_plane\": " + projection.Near.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(" \"projection_mode_byte\": " + projection.Mode.ToString(CultureInfo.InvariantCulture) + ","); + if (frame.SelectedCamera == null) json.AppendLine(" \"selected_camera\": null,"); + else + { + SelectedCameraState selected = frame.SelectedCamera; + json.AppendLine(" \"selected_camera\": {"); + json.AppendLine(" \"input_json\": " + JsonString(selected.Input.Path) + ","); + json.AppendLine(" \"requested_matrix_sha256\": " + JsonString(selected.Input.MatrixSha256) + ","); + json.AppendLine(" \"preflight_camera_argument\": " + JsonString("0x" + selected.CandidateCameraPointer.ToString("X8")) + ","); + json.AppendLine(" \"preflight_thread_id\": " + selected.CandidateThreadId.ToString(CultureInfo.InvariantCulture) + ","); + json.AppendLine(" \"preflight_render_generation\": " + selected.CandidateGeneration.ToString(CultureInfo.InvariantCulture) + ","); + json.AppendLine(" \"preflight_projection_verified\": " + (selected.CandidateProjectionVerified ? "true" : "false") + ","); + json.AppendLine(" \"original_matrix_sha256\": " + JsonString(selected.OriginalMatrixSha256) + ","); + json.AppendLine(" \"camera_argument\": " + JsonString("0x" + selected.CameraPointer.ToString("X8")) + ","); + json.AppendLine(" \"thread_id\": " + selected.ThreadId.ToString(CultureInfo.InvariantCulture) + ","); + json.AppendLine(" \"frame_generation\": " + selected.Generation.ToString(CultureInfo.InvariantCulture) + ","); + json.AppendLine(" \"render_entry_esp\": " + JsonString("0x" + selected.EntryEsp.ToString("X8")) + ","); + json.AppendLine(" \"render_return_address\": " + JsonString("0x" + selected.ReturnAddress.ToString("X8")) + ","); + json.AppendLine(" \"render_return_boundary_rva\": \"World3D+0x13D7B\","); + json.AppendLine(" \"native_setter_applied\": " + (selected.Applied ? "true" : "false") + ","); + json.AppendLine(" \"projection_matches_input\": " + (selected.ProjectionMatchesInput ? "true" : "false") + ","); + json.AppendLine(" \"matrix_intact_at_return\": " + (selected.MatrixIntactAtReturn ? "true" : "false") + ","); + json.AppendLine(" \"native_setter_restore_completed\": " + (selected.Restored ? "true" : "false") + ","); + json.AppendLine(" \"restore_matrix_verified\": " + (selected.RestoreVerified ? "true" : "false") + ","); + json.AppendLine(" \"pixel_attribution_invalidated\": " + (selected.PixelAttributionInvalidated ? "true" : "false")); + json.AppendLine(" },"); + } + json.AppendLine(" \"mission_path\": " + (String.IsNullOrEmpty(frame.MissionPath) ? "null" : JsonString(frame.MissionPath)) + ","); + json.AppendLine(" \"mission_identity\": " + (String.IsNullOrEmpty(frame.MissionPath) + ? "\"unknown\"" : JsonString("path-observed")) + ","); + json.AppendLine(" \"mission_identity_evidence\": " + + (String.IsNullOrEmpty(frame.MissionEvidence) ? "null" : JsonString(frame.MissionEvidence)) + ","); + json.AppendLine(" \"simulation_time_seconds\": null,"); + bool atmosphereVerified = IsVerifiedAtmosphereForFrame(frame, frame.ProjectionThreadId); + string atmosphereSeconds = atmosphereVerified + ? (((double)frame.Atmosphere.PhaseMilliseconds) / 1000.0).ToString("R", CultureInfo.InvariantCulture) + : "null"; + json.AppendLine(" \"atmosphere_seconds\": " + atmosphereSeconds + ","); + json.AppendLine(" \"atmosphere_phase\": {"); + json.AppendLine(" \"sample_rva\": \"Terrain+0x421DC\","); + json.AppendLine(" \"terrain_module_sha256\": " + (frame.Atmosphere == null + || String.IsNullOrEmpty(frame.Atmosphere.TerrainModuleSha256) ? "null" + : JsonString(frame.Atmosphere.TerrainModuleSha256)) + ","); + json.AppendLine(" \"terrain_sha256_verified\": " + + (frame.Atmosphere != null && frame.Atmosphere.TerrainModuleHashVerified ? "true" : "false") + ","); + json.AppendLine(" \"sample_thread_id\": " + (frame.Atmosphere == null ? "null" + : frame.Atmosphere.SampleThreadId.ToString(CultureInfo.InvariantCulture)) + ","); + json.AppendLine(" \"atmosphere_object_esi\": " + (frame.Atmosphere == null ? "null" + : JsonString("0x" + frame.Atmosphere.AtmosphereObject.ToString("X8"))) + ","); + json.AppendLine(" \"raw_clock_ebp\": " + (frame.Atmosphere == null ? "null" + : JsonString("0x" + frame.Atmosphere.RawClock.ToString("X8"))) + ","); + json.AppendLine(" \"phase_ms_edx\": " + (frame.Atmosphere == null ? "null" + : frame.Atmosphere.PhaseMilliseconds.ToString(CultureInfo.InvariantCulture)) + ","); + json.AppendLine(" \"origin_u32_esi_plus_0x144\": " + (frame.Atmosphere == null ? "null" + : frame.Atmosphere.Origin.ToString(CultureInfo.InvariantCulture)) + ","); + json.AppendLine(" \"period_ms_esi_plus_0x150\": " + (frame.Atmosphere == null ? "null" + : frame.Atmosphere.PeriodMilliseconds.ToString(CultureInfo.InvariantCulture)) + ","); + json.AppendLine(" \"recomputed_phase_ms\": " + (frame.Atmosphere == null ? "null" + : frame.Atmosphere.RecomputedPhaseMilliseconds.ToString(CultureInfo.InvariantCulture)) + ","); + json.AppendLine(" \"world_game_time_word_at_sample\": " + (frame.Atmosphere == null ? "null" + : JsonString("0x" + frame.Atmosphere.WorldGameTimeWord.ToString("X8"))) + ","); + json.AppendLine(" \"camera_generation_at_sample\": " + (frame.Atmosphere == null ? "null" + : frame.Atmosphere.CameraGenerationAtSample.ToString(CultureInfo.InvariantCulture)) + ","); + json.AppendLine(" \"camera_thread_id_at_sample\": " + (frame.Atmosphere == null ? "null" + : frame.Atmosphere.CameraThreadIdAtSample.ToString(CultureInfo.InvariantCulture)) + ","); + json.AppendLine(" \"candidate_render_generation\": " + (frame.Atmosphere == null ? "null" + : frame.Atmosphere.CandidateRenderGeneration.ToString(CultureInfo.InvariantCulture)) + ","); + json.AppendLine(" \"matched_render_generation\": " + (atmosphereVerified + ? frame.Atmosphere.MatchedRenderGeneration.ToString(CultureInfo.InvariantCulture) : "null") + ","); + json.AppendLine(" \"matched_render_thread_id\": " + (atmosphereVerified + ? frame.Atmosphere.MatchedRenderThreadId.ToString(CultureInfo.InvariantCulture) : "null") + ","); + json.AppendLine(" \"arithmetic_verified\": " + + (frame.Atmosphere != null && frame.Atmosphere.ArithmeticVerified ? "true" : "false") + ","); + json.AppendLine(" \"raw_clock_matches_world_time\": " + + (frame.Atmosphere != null && frame.Atmosphere.WorldTimeMatchesRawClock ? "true" : "false") + ","); + json.AppendLine(" \"same_generation_verified\": " + (atmosphereVerified ? "true" : "false")); + json.AppendLine(" },"); + json.AppendLine(" \"weather_state\": null,"); + json.AppendLine(" \"rng_state\": null,"); + json.AppendLine(" \"raw_world_game_time_word32A38\": " + JsonString("0x" + frame.WorldGameTimeWord.ToString("X8")) + ","); + json.AppendLine(" \"native_frame_png\": " + JsonString(Path.GetFileName(pngPath)) + ","); + json.AppendLine(" \"pixel_capture\": {"); + json.AppendLine(" \"width\": " + surface.Width.ToString(CultureInfo.InvariantCulture) + ","); + json.AppendLine(" \"height\": " + surface.Height.ToString(CultureInfo.InvariantCulture) + ","); + json.AppendLine(" \"pitch_bytes\": " + surface.Pitch.ToString(CultureInfo.InvariantCulture) + ","); + json.AppendLine(" \"bit_count\": " + surface.BitCount.ToString(CultureInfo.InvariantCulture) + ","); + json.AppendLine(" \"red_mask\": " + JsonString("0x" + surface.RedMask.ToString("X8")) + ","); + json.AppendLine(" \"green_mask\": " + JsonString("0x" + surface.GreenMask.ToString("X8")) + ","); + json.AppendLine(" \"blue_mask\": " + JsonString("0x" + surface.BlueMask.ToString("X8")) + ","); + json.AppendLine(" \"alpha_mask\": " + JsonString("0x" + surface.AlphaMask.ToString("X8")) + ","); + json.AppendLine(" \"rows_sha256\": " + JsonString(surface.Sha256) + ","); + json.AppendLine(" \"get_render_target_hresult\": " + JsonString("0x" + remote.GetHr.ToString("X8")) + ","); + json.AppendLine(" \"lock_hresult\": " + JsonString("0x" + remote.LockHr.ToString("X8")) + ","); + json.AppendLine(" \"unlock_hresult\": " + JsonString("0x" + remote.UnlockHr.ToString("X8")) + ","); + json.AppendLine(" \"release_result\": " + JsonString("0x" + remote.ReleaseResult.ToString("X8"))); + json.AppendLine(" }"); + json.AppendLine("}"); + return json.ToString(); + } + + private static int SelfCheck() + { + if (IntPtr.Size != 4) throw new InvalidOperationException("This helper must be compiled and run as x86."); + SelfCheckCameraInput(); + SelfCheckRenderInvocationGates(); + uint relocated = 0x10000000u + RelocatedGlobalRva; + if (relocated != 0x1079518Cu) throw new InvalidOperationException("relocation self-check failed"); + bool offsetsMatch = Marshal.SizeOf(typeof(X86ContextLayout)) == X86ContextSize + && Marshal.OffsetOf(typeof(X86ContextLayout), "Eip").ToInt32() == ContextEipOffset + && Marshal.OffsetOf(typeof(X86ContextLayout), "Esi").ToInt32() == ContextEsiOffset + && Marshal.OffsetOf(typeof(X86ContextLayout), "Ebp").ToInt32() == ContextEbpOffset + && Marshal.OffsetOf(typeof(X86ContextLayout), "Esp").ToInt32() == ContextEspOffset; + FrameSnapshot matched = new FrameSnapshot { CameraGeneration = 7, CameraThreadId = 11, + Camera = new CameraReadback { LayoutVerified = true, MatrixFinite = true, CurrentAtProjectionVerified = true }, + ProjectionGeneration = 7, ProjectionThreadId = 11, + Projection = new ProjectionReadback { Verified = true, Mode = 1 } }; + byte[] selectedMatrixBytes = new byte[64]; + selectedMatrixBytes[0] = 1; + byte[] changedSelectedMatrixBytes = (byte[])selectedMatrixBytes.Clone(); + changedSelectedMatrixBytes[0] = 2; + SelectedCameraInput selectedInput = new SelectedCameraInput { MatrixBytes = selectedMatrixBytes }; + FrameSnapshot selectedMatched = new FrameSnapshot + { + CameraGeneration = 8, CameraThreadId = 11, + Camera = new CameraReadback { LayoutVerified = true, MatrixFinite = true, CurrentAtProjectionVerified = true, + EntryMatrixBytes = (byte[])selectedMatrixBytes.Clone(), ProjectionMatrixBytes = (byte[])selectedMatrixBytes.Clone() }, + ProjectionGeneration = 8, ProjectionThreadId = 11, + Projection = new ProjectionReadback { Verified = true, Mode = 1 }, + SelectedCameraRequested = true, + SelectedCamera = new SelectedCameraState + { + Applied = true, ProjectionMatchesInput = true, CandidateProjectionVerified = true, + Restored = true, RestoreVerified = true, MatrixIntactAtReturn = true, Input = selectedInput + } + }; + FrameSnapshot selectedUnrestored = new FrameSnapshot + { + CameraGeneration = 8, CameraThreadId = 11, + Camera = selectedMatched.Camera, ProjectionGeneration = 8, ProjectionThreadId = 11, + Projection = selectedMatched.Projection, SelectedCameraRequested = true, + SelectedCamera = new SelectedCameraState + { + Applied = true, ProjectionMatchesInput = true, CandidateProjectionVerified = true, + MatrixIntactAtReturn = true, Input = selectedInput + } + }; + FrameSnapshot selectedIntervened = new FrameSnapshot + { + CameraGeneration = 8, CameraThreadId = 11, + Camera = selectedMatched.Camera, ProjectionGeneration = 8, ProjectionThreadId = 11, + Projection = selectedMatched.Projection, SelectedCameraRequested = true, + SelectedCamera = new SelectedCameraState + { + Applied = true, ProjectionMatchesInput = true, CandidateProjectionVerified = true, + Restored = true, RestoreVerified = true, MatrixIntactAtReturn = true, + PixelAttributionInvalidated = true, Input = selectedInput + } + }; + FrameSnapshot selectedMatrixChanged = new FrameSnapshot + { + CameraGeneration = 8, CameraThreadId = 11, + Camera = new CameraReadback { LayoutVerified = true, MatrixFinite = true, CurrentAtProjectionVerified = true, + EntryMatrixBytes = (byte[])selectedMatrixBytes.Clone(), ProjectionMatrixBytes = changedSelectedMatrixBytes }, + ProjectionGeneration = 8, ProjectionThreadId = 11, + Projection = new ProjectionReadback { Verified = true, Mode = 1 }, + SelectedCameraRequested = true, + SelectedCamera = new SelectedCameraState + { + Applied = true, ProjectionMatchesInput = true, CandidateProjectionVerified = true, + Restored = true, RestoreVerified = true, MatrixIntactAtReturn = true, Input = selectedInput + } + }; + byte[] pendingContext = new byte[X86ContextSize]; + const uint pendingEsp = 0x00100100; + Buffer.BlockCopy(BitConverter.GetBytes(0x00100100u), 0, pendingContext, ContextEspOffset, 4); + Buffer.BlockCopy(BitConverter.GetBytes(0xFFFFFFFFu), 0, pendingContext, ContextEflagsOffset, 4); + byte[] resumedContext = ContextAtBreakpoint(pendingContext, 0x12345678u, false); + uint wrappedPhase; + bool atmosphereArithmetic = ComputeAtmospherePhaseMilliseconds(0x00000010u, 0xFFFFFFF0u, + 0x00000100u, out wrappedPhase) && wrappedPhase == 32u; + uint ignoredPhase; + bool zeroPeriodRejected = !ComputeAtmospherePhaseMilliseconds(10u, 0u, 0u, out ignoredPhase); + FrameSnapshot atmosphereMatched = new FrameSnapshot + { + CameraGeneration = 7, + CameraThreadId = 11, + Camera = new CameraReadback { LayoutVerified = true, MatrixFinite = true, CurrentAtProjectionVerified = true }, + ProjectionGeneration = 7, + ProjectionThreadId = 11, + Projection = new ProjectionReadback { Verified = true, Mode = 1 }, + CameraWorldGameTimeWord = 0x10, + CameraWorldGameTimeWordReadable = true, + WorldGameTimeWord = 0x10, + WorldGameTimeWordReadable = true, + Atmosphere = new AtmosphereReadback + { + SampleThreadId = 11, + CameraGenerationAtSample = 7, + CameraThreadIdAtSample = 11, + RawClock = 0x10, + PhaseMilliseconds = 32, + Origin = 0xFFFFFFF0, + PeriodMilliseconds = 0x100, + RecomputedPhaseMilliseconds = 32, + WorldGameTimeWord = 0x10, + WorldGameTimeWordReadable = true, + TerrainModuleSha256 = ExpectedSha256[TerrainName], + TerrainModuleHashVerified = true, + ArithmeticVerified = true, + WorldTimeMatchesRawClock = true, + MatchedRenderGeneration = 7, + MatchedRenderThreadId = 11 + } + }; + FrameSnapshot atmosphereClockMismatch = new FrameSnapshot + { + CameraGeneration = 7, + CameraThreadId = 11, + Camera = atmosphereMatched.Camera, + ProjectionGeneration = 7, + ProjectionThreadId = 11, + Projection = atmosphereMatched.Projection, + CameraWorldGameTimeWord = 0x11, + CameraWorldGameTimeWordReadable = true, + WorldGameTimeWord = 0x11, + WorldGameTimeWordReadable = true, + Atmosphere = atmosphereMatched.Atmosphere + }; + bool atmosphereFrameValid = IsVerifiedAtmosphereForFrame(atmosphereMatched, 11); + bool atmosphereClockMismatchRejected = !IsVerifiedAtmosphereForFrame(atmosphereClockMismatch, 11); + bool cameraFrameValid = IsMatchingPerspectiveFrame(matched, 11); + bool wrongCameraThreadRejected = !IsMatchingPerspectiveFrame(matched, 12); + bool emptyCameraFrameRejected = !IsMatchingPerspectiveFrame(new FrameSnapshot(), 11); + bool selectedFrameValid = IsMatchingPerspectiveFrame(selectedMatched, 11); + bool selectedUnrestoredRejected = !IsMatchingPerspectiveFrame(selectedUnrestored, 11); + bool selectedUnrestoredCaptureEligible = CanArmPresentForFrame(selectedUnrestored, 11); + bool selectedIntervenedCaptureRejected = !CanArmPresentForFrame(selectedIntervened, 11); + bool selectedIntervenedRejected = !IsMatchingPerspectiveFrame(selectedIntervened, 11); + bool selectedChangedMatrixRejected = !CanArmPresentForFrame(selectedMatrixChanged, 11) + && !IsMatchingPerspectiveFrame(selectedMatrixChanged, 11); + bool stopAfterCapture = CanStopCapture(true, false, false, false, false); + bool stopAfterFailure = CanStopCapture(false, true, false, false, false); + bool waitsForRemote = !CanStopCapture(true, false, true, false, false); + bool waitsForStep = !CanStopCapture(true, false, false, true, false); + bool waitsForCameraRestore = !CanStopCapture(true, false, false, false, true); + bool resumedEipOk = BitConverter.ToUInt32(resumedContext, ContextEipOffset) == 0x12345678u; + bool resumedEspOk = BitConverter.ToUInt32(resumedContext, ContextEspOffset) == pendingEsp; + bool resumedTrapCleared = (BitConverter.ToUInt32(resumedContext, ContextEflagsOffset) & 0x100u) == 0; + if (!offsetsMatch + || ThreadQueryInformation != 0x00000040 + || ContextEbxOffset != 164 || ContextEdxOffset != 168 || ContextEcxOffset != 172 + || !atmosphereArithmetic || !zeroPeriodRejected + || !atmosphereFrameValid + || !atmosphereClockMismatchRejected + || !cameraFrameValid || !wrongCameraThreadRejected || !emptyCameraFrameRejected + || !selectedFrameValid || !selectedUnrestoredRejected || !selectedUnrestoredCaptureEligible + || !selectedIntervenedCaptureRejected || !selectedIntervenedRejected + || !selectedChangedMatrixRejected + || !stopAfterCapture || !stopAfterFailure || !waitsForRemote || !waitsForStep || !waitsForCameraRestore + || !resumedEipOk || !resumedEspOk || !resumedTrapCleared) + throw new InvalidOperationException("Self-check failed: offsets=" + offsetsMatch + + " atmosphereArithmetic=" + atmosphereArithmetic + " zeroPeriodRejected=" + zeroPeriodRejected + + " atmosphereFrameValid=" + atmosphereFrameValid + + " atmosphereClockMismatchRejected=" + atmosphereClockMismatchRejected + + " cameraFrameValid=" + cameraFrameValid + " wrongCameraThreadRejected=" + wrongCameraThreadRejected + + " emptyCameraFrameRejected=" + emptyCameraFrameRejected + " stopAfterCapture=" + stopAfterCapture + + " selectedFrameValid=" + selectedFrameValid + " selectedUnrestoredRejected=" + selectedUnrestoredRejected + + " selectedUnrestoredCaptureEligible=" + selectedUnrestoredCaptureEligible + + " selectedIntervenedCaptureRejected=" + selectedIntervenedCaptureRejected + + " selectedIntervenedRejected=" + selectedIntervenedRejected + + " selectedChangedMatrixRejected=" + selectedChangedMatrixRejected + + " stopAfterFailure=" + stopAfterFailure + " waitsForRemote=" + waitsForRemote + + " waitsForStep=" + waitsForStep + " waitsForCameraRestore=" + waitsForCameraRestore + + " resumedEipOk=" + resumedEipOk + + " resumedEspOk=" + resumedEspOk + " resumedTrapCleared=" + resumedTrapCleared + + " ThreadQueryInformation=" + ThreadQueryInformation + " Ebx=" + ContextEbxOffset + + " Edx=" + ContextEdxOffset + " Ecx=" + ContextEcxOffset); + Console.WriteLine("self-check: x86 CONTEXT layout, relocation RVA, validated camera JSON, post-prologue invocation identity/stack gates, camera/projection gates, wrapping atmosphere phase arithmetic/clock pairing, and recovery states OK"); + return SelfCheckReadback(); + } + + private static int Main(string[] args) + { + try + { + if (args.Length == 1 && args[0] == "--self-check") return SelfCheck(); + if (args.Length == 2 && args[0] == "--validate-camera-input") + { + SelectedCameraInput validated = LoadSelectedCameraInput(args[1]); + Console.WriteLine("camera input valid: matrixSha256=" + validated.MatrixSha256 + + " viewport=" + String.Join(",", validated.Viewport) + + " near=" + validated.Near.ToString("R", CultureInfo.InvariantCulture) + + " far=" + validated.Far.ToString("R", CultureInfo.InvariantCulture) + + " fov=" + validated.FieldOfView.ToString("R", CultureInfo.InvariantCulture) + + " mode=" + validated.ProjectionMode); + return 0; + } + bool hasCameraInput = args.Length == 6 && args[4] == "--camera-input"; + if ((args.Length != 4 && !hasCameraInput) || args[0] != "--capture") + { + Console.Error.WriteLine(" NativeFrameCapture.exe --capture "); + Console.Error.WriteLine(" [--camera-input ]"); + Console.Error.WriteLine(" NativeFrameCapture.exe --validate-camera-input "); + Console.Error.WriteLine(" NativeFrameCapture.exe --self-check"); + return 2; + } + if (IntPtr.Size != 4) throw new InvalidOperationException("Run the x86 build only."); + uint processId = UInt32.Parse(args[1], CultureInfo.InvariantCulture); + long expectedStartTicks = Int64.Parse(args[2], CultureInfo.InvariantCulture); + SelectedCameraInput selectedCameraInput = hasCameraInput ? LoadSelectedCameraInput(args[5]) : null; + if (selectedCameraInput != null) + Log("validated selected camera input path=" + selectedCameraInput.Path + + " matrixSha256=" + selectedCameraInput.MatrixSha256 + + " viewport=" + String.Join(",", selectedCameraInput.Viewport) + + " near=" + selectedCameraInput.Near.ToString("R", CultureInfo.InvariantCulture) + + " far=" + selectedCameraInput.Far.ToString("R", CultureInfo.InvariantCulture) + + " fov=" + selectedCameraInput.FieldOfView.ToString("R", CultureInfo.InvariantCulture) + + " mode=" + selectedCameraInput.ProjectionMode); + string repositoryRoot = FindRepositoryRoot(); + string expectedPath = Path.GetFullPath(VerifyScratchFiles(repositoryRoot)); + string outputPath = Path.GetFullPath(args[3]); + string targetRoot = Path.GetFullPath(Path.Combine(repositoryRoot, "target")) + Path.DirectorySeparatorChar; + if (!outputPath.StartsWith(targetRoot, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Output must be beneath the repository target directory: " + targetRoot); + if (!String.Equals(Path.GetExtension(outputPath), ".json", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Capture output must use a .json extension so PNG/log paths cannot overlap it."); + string outputPng = Path.ChangeExtension(outputPath, ".png"); + string outputLog = Path.ChangeExtension(outputPath, ".log"); + if (File.Exists(outputPath) || File.Exists(outputPng) || File.Exists(outputLog)) + throw new InvalidOperationException("Output JSON, PNG, or log already exists; choose a fresh basename: " + outputPath); + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + _logPath = outputLog; + + using (Process target = Process.GetProcessById((int)processId)) + { + IntPtr identityHandle = target.Handle; + if (identityHandle == IntPtr.Zero) throw new InvalidOperationException("Could not retain the target process identity handle."); + string actualPath = Path.GetFullPath(target.MainModule.FileName); + long actualStartTicks = target.StartTime.ToUniversalTime().Ticks; + if (target.ProcessName != "iron_3d" + || !String.Equals(actualPath, expectedPath, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Refusing PID: expected exact scratch image " + expectedPath + ", got " + actualPath); + if (actualStartTicks != expectedStartTicks) + throw new InvalidOperationException("Refusing stale PID; expected UTC start ticks " + + expectedStartTicks + ", got " + actualStartTicks); + Log("identity pid=" + processId + " path=" + actualPath + " startUtc=" + + target.StartTime.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture) + + " startTicks=" + actualStartTicks + " bits=" + (IntPtr.Size * 8)); + foreach (KeyValuePair expected in ExpectedSha256) + Log("verified sha256 " + expected.Key + "=" + expected.Value); + return AttachForFrame(processId, expectedPath, expectedStartTicks, outputPath, + selectedCameraInput, target, FrameCaptureTimeoutSeconds); + } + } + catch (Exception exception) + { + Log("ERROR " + exception); + Console.Error.WriteLine(exception.Message); + return 1; + } + } + + private static int AttachForFrame(uint processId, string expectedPath, long expectedStartTicks, + string outputPath, SelectedCameraInput selectedCameraInput, Process identityProcess, int timeoutSeconds) + { + const uint ProcessTerminate = 0x0001; + const uint ProcessVmRead = 0x0010; + const uint ProcessVmWrite = 0x0020; + const uint ProcessVmOperation = 0x0008; + const uint ProcessQueryInformation = 0x0400; + const uint Synchronize = 0x00100000; + IntPtr process = IntPtr.Zero; + IntPtr eventBuffer = IntPtr.Zero; + uint world3dBase = 0; + uint terrainBase = 0; + uint ngiBase = 0; + byte presentOriginalByte = 0; + bool presentBoundaryVerified = false; + bool attached = false; + bool detached = false; + bool breakpointsRestored = false; + bool fatal = false; + bool processExited = false; + bool processExitUnconfirmed = false; + bool captured = false; + SurfaceReadback completedSurface = null; + FrameSnapshot completedFrame = null; + RemoteReadbackSession completedReadback = null; + bool stopAfterAttempt = false; + string captureFailure = null; + DateTime deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + string expectedDirectory = Path.GetDirectoryName(expectedPath); + string expectedWorld3D = Path.Combine(expectedDirectory, World3DName); + string expectedTerrain = Path.Combine(expectedDirectory, TerrainName); + string expectedNgi32 = Path.Combine(expectedDirectory, Ngi32Name); + string expectedIron3d = Path.Combine(expectedDirectory, Iron3dName); + uint iron3dBase = 0; + string terrainModuleSha256 = null; + BreakpointInfo cameraBreakpoint = null; + BreakpointInfo projectionBreakpoint = null; + BreakpointInfo returnBreakpoint = null; + BreakpointInfo atmosphereBreakpoint = null; + BreakpointInfo presentBreakpoint = null; + BreakpointInfo missionBreakpoint = null; + int missionProbeAttempts = 0; + bool missionProbeFinished = false; + PendingStep pendingStep = null; + RemoteReadbackSession remote = null; + RemoteCameraSetterSession cameraSetter = null; + FrameSnapshot frame = new FrameSnapshot(); + frame.SelectedCameraRequested = selectedCameraInput != null; + SelectedCameraState selectedCamera = selectedCameraInput == null ? null + : new SelectedCameraState { Input = selectedCameraInput }; + try + { + if (identityProcess == null || identityProcess.HasExited + || identityProcess.StartTime.ToUniversalTime().Ticks != expectedStartTicks + || !String.Equals(Path.GetFullPath(identityProcess.MainModule.FileName), expectedPath, + StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("The retained process identity changed before debugger attach."); + if (!DebugActiveProcess(processId)) + throw new InvalidOperationException("DebugActiveProcess failed: " + Marshal.GetLastWin32Error()); + attached = true; + if (identityProcess.HasExited || identityProcess.StartTime.ToUniversalTime().Ticks != expectedStartTicks + || !String.Equals(Path.GetFullPath(identityProcess.MainModule.FileName), expectedPath, + StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("The retained process identity changed during debugger attach."); + if (!DebugSetProcessKillOnExit(false)) + throw new InvalidOperationException("DebugSetProcessKillOnExit(false) failed: " + Marshal.GetLastWin32Error()); + process = OpenProcess(ProcessTerminate | ProcessVmOperation | ProcessVmRead | ProcessVmWrite + | ProcessQueryInformation | Synchronize, false, processId); + if (process == IntPtr.Zero) throw new InvalidOperationException("OpenProcess failed: " + Marshal.GetLastWin32Error()); + eventBuffer = Marshal.AllocHGlobal(128); + Log("frame capture attached; waiting for verified camera/projection/present boundaries for " + + timeoutSeconds + " seconds"); + + while (!fatal && !CanStopCapture(captured, stopAfterAttempt, remote != null || cameraSetter != null, + pendingStep != null, selectedCamera != null && selectedCamera.Applied && !selectedCamera.RestoreVerified) + && (DateTime.UtcNow < deadline || remote != null || cameraSetter != null || pendingStep != null + || selectedCamera != null && selectedCamera.Applied && !selectedCamera.RestoreVerified)) + { + if (remote != null && DateTime.UtcNow >= remote.DeadlineUtc) + { + Log("REMOTE_COM_STALLED phase=" + remote.Phase + " tid=" + remote.ThreadId + + "; terminating only the path/tick/hash-verified scratch process"); + if (!TerminateProcess(process, 0xE001)) + Log("REMOTE_COM_TERMINATE_FAILED error=" + Marshal.GetLastWin32Error()); + fatal = true; + continue; + } + if (cameraSetter != null && DateTime.UtcNow >= cameraSetter.DeadlineUtc) + { + Log("CAMERA_SETTER_STALLED phase=" + cameraSetter.Phase + " tid=" + cameraSetter.ThreadId + + "; terminating only the path/tick/hash-verified scratch process"); + if (!TerminateProcess(process, 0xE00A)) + Log("CAMERA_SETTER_TERMINATE_FAILED error=" + Marshal.GetLastWin32Error()); + fatal = true; + continue; + } + if (selectedCamera != null && selectedCamera.Applied && !selectedCamera.RestoreVerified + && cameraSetter == null && selectedCamera.RestoreDeadlineUtc != DateTime.MinValue + && DateTime.UtcNow >= selectedCamera.RestoreDeadlineUtc) + { + Log("SELECTED_CAMERA_RESTORE_BOUND_EXPIRED; terminating only the verified scratch process"); + if (!TerminateProcess(process, 0xE00B)) + Log("SELECTED_CAMERA_RESTORE_TERMINATE_FAILED error=" + Marshal.GetLastWin32Error()); + fatal = true; + continue; + } + if (pendingStep != null && DateTime.UtcNow >= pendingStep.DeadlineUtc) + { + Log("SINGLE_STEP_STALLED boundary=" + pendingStep.Breakpoint.Name + " tid=" + pendingStep.ThreadId + + "; terminating only the path/tick/hash-verified scratch process"); + if (!TerminateProcess(process, 0xE002)) + Log("SINGLE_STEP_TERMINATE_FAILED error=" + Marshal.GetLastWin32Error()); + fatal = true; + continue; + } + if (!WaitForDebugEvent(eventBuffer, 1000)) + { + int waitError = Marshal.GetLastWin32Error(); + if (waitError == 121 || waitError == 258) continue; + throw new InvalidOperationException("WaitForDebugEvent failed: " + waitError); + } + + uint eventCode = unchecked((uint)Marshal.ReadInt32(eventBuffer, 0)); + uint eventPid = unchecked((uint)Marshal.ReadInt32(eventBuffer, 4)); + uint eventTid = unchecked((uint)Marshal.ReadInt32(eventBuffer, 8)); + uint continueStatus = DbgContinue; + bool shouldStop = false; + BreakpointInfo hitBreakpoint = null; + byte[] stoppedContext = null; + try + { + if (eventCode == CreateProcessDebugEvent) + { + IntPtr imageFile = Marshal.ReadIntPtr(eventBuffer, 12); + IntPtr processHandle = Marshal.ReadIntPtr(eventBuffer, 16); + IntPtr threadHandle = Marshal.ReadIntPtr(eventBuffer, 20); + string imagePath = PathForHandle(imageFile); + if (imagePath.Length != 0 && !String.Equals(imagePath, NormalizePath(expectedPath), StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("CREATE_PROCESS image path mismatch: " + imagePath); + if (processHandle != IntPtr.Zero) CloseHandle(processHandle); + if (threadHandle != IntPtr.Zero) CloseHandle(threadHandle); + if (imageFile != IntPtr.Zero) CloseHandle(imageFile); + Log("CREATE_PROCESS path=" + imagePath); + } + else if (eventCode == CreateThreadDebugEvent) + { + IntPtr threadHandle = Marshal.ReadIntPtr(eventBuffer, 12); + if (threadHandle != IntPtr.Zero) CloseHandle(threadHandle); + Log("CREATE_THREAD tid=" + eventTid + " handleClosed=" + (threadHandle != IntPtr.Zero)); + } + else if (eventCode == ExitThreadDebugEvent) + { + uint threadExitCode = unchecked((uint)Marshal.ReadInt32(eventBuffer, 12)); + Log("EXIT_THREAD tid=" + eventTid + " exitCode=0x" + threadExitCode.ToString("X8")); + } + else if (eventCode == LoadDllDebugEvent) + { + IntPtr imageFile = Marshal.ReadIntPtr(eventBuffer, 12); + try + { + uint imageBase = unchecked((uint)Marshal.ReadInt32(eventBuffer, 16)); + string imagePath = PathForHandle(imageFile); + if (imagePath.Length != 0 && String.Equals(imagePath, NormalizePath(expectedTerrain), StringComparison.OrdinalIgnoreCase)) + { + string hash = HashFile(imagePath); + if (!String.Equals(hash, ExpectedSha256[TerrainName], StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Loaded Terrain hash mismatch: " + hash); + terrainBase = imageBase; + terrainModuleSha256 = hash; + byte atmosphereByte; + string atmosphereEvidence; + if (!VerifyAtmospherePhaseBoundary(process, terrainBase, out atmosphereByte, out atmosphereEvidence)) + throw new InvalidOperationException("Terrain atmosphere phase boundary signature failed: " + atmosphereEvidence); + atmosphereBreakpoint = ArmBreakpoint(process, "Terrain.atmosphere-phase", + unchecked(terrainBase + AtmospherePhaseRva), atmosphereByte); + Log("Terrain verified base=0x" + terrainBase.ToString("X8") + " sha256=" + hash + + " " + atmosphereEvidence); + } + if (imagePath.Length != 0 && String.Equals(imagePath, NormalizePath(expectedIron3d), StringComparison.OrdinalIgnoreCase)) + { + string loadedPath = ModulePath(process, imageBase); + string hash = HashFile(loadedPath); + if (!String.Equals(hash, ExpectedSha256[Iron3dName], StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Loaded iron3d hash mismatch: " + hash); + byte missionByte; + string missionEvidence; + if (!VerifyMissionIdentityBoundary(process, imageBase, out missionByte, out missionEvidence)) + throw new InvalidOperationException("iron3d mission path boundary signature failed: " + missionEvidence); + iron3dBase = imageBase; + missionBreakpoint = ArmBreakpoint(process, "iron3d.mission-path-vtable-call", + unchecked(imageBase + MissionVtableCallRva), missionByte); + Log("iron3d verified sha256=" + hash + " " + missionEvidence); + } + if (imagePath.Length != 0 && String.Equals(imagePath, NormalizePath(expectedWorld3D), StringComparison.OrdinalIgnoreCase)) + { + string loadedPath = ModulePath(process, imageBase); + string hash = HashFile(loadedPath); + if (!String.Equals(hash, ExpectedSha256[World3DName], StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Loaded World3D hash mismatch: " + hash); + byte renderByte; + byte returnByte = 0; + byte projectionByte; + string renderEvidence; + string returnEvidence = ""; + string projectionEvidence; + if (!VerifyRenderEntry(process, imageBase, out renderByte, out renderEvidence)) + throw new InvalidOperationException("World3D render-entry signature failed: " + renderEvidence); + if (!VerifyProjectionBoundary(process, imageBase, out projectionByte, out projectionEvidence)) + throw new InvalidOperationException("World3D projection signature failed: " + projectionEvidence); + if (selectedCameraInput != null + && !VerifyRenderReturnBoundary(process, imageBase, out returnByte, out returnEvidence)) + throw new InvalidOperationException("World3D selected-camera restore boundary failed: " + returnEvidence); + world3dBase = imageBase; + cameraBreakpoint = ArmBreakpoint(process, "World3D.stdRenderGame", imageBase + RenderGameRva, renderByte); + projectionBreakpoint = ArmBreakpoint(process, "World3D.projection-snapshot", imageBase + ProjectionSnapshotRva, projectionByte); + if (selectedCameraInput != null) + returnBreakpoint = ArmBreakpoint(process, "World3D.stdRenderGame-restore-return", + imageBase + RenderReturnRva, returnByte); + Log("World3D verified sha256=" + hash + " " + renderEvidence + " " + projectionEvidence + + (selectedCameraInput == null ? "" : " " + returnEvidence)); + } + if (imagePath.Length != 0 && String.Equals(imagePath, NormalizePath(expectedNgi32), StringComparison.OrdinalIgnoreCase)) + { + string loadedPath = ModulePath(process, imageBase); + string hash = HashFile(loadedPath); + if (!String.Equals(hash, ExpectedSha256[Ngi32Name], StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Loaded Ngi32 hash mismatch: " + hash); + byte presentByte; + string presentEvidence; + if (!VerifyPresentBoundary(process, imageBase, out presentByte, out presentEvidence)) + throw new InvalidOperationException("Ngi32 present signature failed: " + presentEvidence); + ngiBase = imageBase; + presentOriginalByte = presentByte; + presentBoundaryVerified = true; + Log("Ngi32 verified sha256=" + hash + " " + presentEvidence); + if (IsMatchingPerspectiveFrame(frame, frame.CameraThreadId)) + EnsurePresentBreakpoint(process, ngiBase, frame, frame.CameraThreadId, + presentOriginalByte, ref presentBreakpoint); + } + } + finally { if (imageFile != IntPtr.Zero) CloseHandle(imageFile); } + } + else if (eventCode == ExceptionDebugEvent) + { + uint exceptionCode = unchecked((uint)Marshal.ReadInt32(eventBuffer, 12)); + uint exceptionAddress = unchecked((uint)Marshal.ReadInt32(eventBuffer, 24)); + if (cameraSetter != null) + { + bool expectedTrap = exceptionCode == 0x80000003 + && exceptionAddress == cameraSetter.Code.TrapAddress + && eventTid == cameraSetter.ThreadId; + if (!expectedTrap) + { + Log("CAMERA_SETTER_UNEXPECTED_EXCEPTION phase=" + cameraSetter.Phase + + " code=0x" + exceptionCode.ToString("X8") + + " address=0x" + exceptionAddress.ToString("X8") + " tid=" + eventTid + + "; terminating the verified scratch process"); + TerminateProcess(process, 0xE00B); + fatal = true; + } + else + { + string returnEvidence; + bool returnContextOk = VerifyCameraSetterReturnContext(process, cameraSetter, out returnEvidence); + byte[] actualMatrix = ReadCameraMatrix(process, cameraSetter.Camera); + bool matrixMatches = ByteArraysEqual(actualMatrix, cameraSetter.MatrixBytes); + string abiEvidence; + uint transformInterface; + bool abiStillValid = VerifyCameraSetterAbi(process, terrainBase, + cameraSetter.Camera, out transformInterface, out abiEvidence); + Log("CAMERA_SETTER_RETURN phase=" + cameraSetter.Phase + + " contextOk=" + returnContextOk + " matrixMatches=" + matrixMatches + + " abiStillValid=" + abiStillValid + " " + returnEvidence + " " + abiEvidence); + if (!returnContextOk || !matrixMatches || !abiStillValid) + { + captureFailure = "Native camera setter could not be verified; the isolated scratch process will be stopped."; + Log("CAMERA_SETTER_UNVERIFIED; terminating the verified scratch process before detach"); + TerminateProcess(process, 0xE00C); + fatal = true; + } + else if (cameraSetter.Phase == CameraSetterPhase.Apply) + { + CameraReadback appliedCamera = frame.Camera; + if (selectedCamera == null || appliedCamera == null || !appliedCamera.LayoutVerified + || appliedCamera.Camera != selectedCamera.CameraPointer + || !ByteArraysEqual(actualMatrix, selectedCamera.Input.MatrixBytes)) + { + captureFailure = "Native setter did not leave the requested selected-camera matrix in place."; + Log("CAMERA_SETTER_APPLY_READBACK_FAILED; terminating the verified scratch process"); + TerminateProcess(process, 0xE00D); + fatal = true; + } + else + { + appliedCamera.EntryMatrixBytes = (byte[])selectedCamera.Input.MatrixBytes.Clone(); + appliedCamera.EntryMatrixSha256 = selectedCamera.Input.MatrixSha256; + appliedCamera.ProjectionMatrixSha256 = null; + appliedCamera.CurrentAtProjectionVerified = false; + appliedCamera.WordsChangedAtProjection = false; + appliedCamera.Words = (uint[])selectedCamera.Input.MatrixWords.Clone(); + appliedCamera.MatrixFinite = true; + selectedCamera.Applied = true; + selectedCamera.RestoreDeadlineUtc = DateTime.UtcNow.AddSeconds(5); + frame.SelectedCamera = selectedCamera; + frame.Camera = appliedCamera; + Log("CAMERA_SETTER_APPLY_VERIFIED generation=" + selectedCamera.Generation + + " camera=0x" + selectedCamera.CameraPointer.ToString("X8") + + " matrixSha256=" + selectedCamera.Input.MatrixSha256 + + " originalSha256=" + selectedCamera.OriginalMatrixSha256); + BeginSingleStep(process, eventTid, cameraSetter.ResumeBreakpoint, + cameraSetter.OriginalContext, out pendingStep, cameraSetter.RearmOnStep); + Log("SINGLE_STEP_STARTED boundary=" + cameraSetter.ResumeBreakpoint.Name + " tid=" + eventTid); + if (VirtualFreeEx(process, Ptr(cameraSetter.Region), UIntPtr.Zero, 0x8000)) + Log("CAMERA_SETTER_REGION_FREED phase=Apply"); + else Log("CAMERA_SETTER_REGION_FREE_FAILED phase=Apply error=" + Marshal.GetLastWin32Error()); + cameraSetter = null; + } + } + else + { + if (selectedCamera == null || !ByteArraysEqual(actualMatrix, selectedCamera.OriginalMatrixBytes)) + { + captureFailure = "The original native camera matrix was not restored exactly."; + Log("CAMERA_SETTER_RESTORE_READBACK_FAILED; terminating the verified scratch process"); + TerminateProcess(process, 0xE00E); + fatal = true; + } + else + { + selectedCamera.Restored = true; + selectedCamera.RestoreVerified = true; + selectedCamera.RestoreDeadlineUtc = DateTime.MinValue; + Log("CAMERA_SETTER_RESTORE_VERIFIED camera=0x" + selectedCamera.CameraPointer.ToString("X8") + + " matrixSha256=" + selectedCamera.OriginalMatrixSha256 + + " returnBoundary=World3D+0x13D7B"); + BeginSingleStep(process, eventTid, cameraSetter.ResumeBreakpoint, + cameraSetter.OriginalContext, out pendingStep, cameraSetter.RearmOnStep); + Log("SINGLE_STEP_STARTED boundary=" + cameraSetter.ResumeBreakpoint.Name + " tid=" + eventTid); + if (VirtualFreeEx(process, Ptr(cameraSetter.Region), UIntPtr.Zero, 0x8000)) + Log("CAMERA_SETTER_REGION_FREED phase=Restore"); + else Log("CAMERA_SETTER_REGION_FREE_FAILED phase=Restore error=" + Marshal.GetLastWin32Error()); + cameraSetter = null; + } + } + } + } + else if (remote != null) + { + uint expectedTrap = remote.Phase == RemoteReadbackPhase.Acquire + ? remote.Acquire.TrapAddress : remote.Cleanup.TrapAddress; + if (exceptionCode != 0x80000003 || exceptionAddress != expectedTrap || eventTid != remote.ThreadId) + { + Log("REMOTE_STUB_UNEXPECTED_EXCEPTION code=0x" + exceptionCode.ToString("X8") + + " address=0x" + exceptionAddress.ToString("X8") + " tid=" + eventTid); + TerminateProcess(process, 0xE003); + fatal = true; + } + else if (remote.Phase == RemoteReadbackPhase.Acquire) + { + uint data = remote.Acquire.DataBase; + remote.Status = ReadU32Exact(process, data + RemoteDataStatusOffset); + remote.GetHr = ReadU32Exact(process, data + RemoteDataGetHrOffset); + remote.LockHr = ReadU32Exact(process, data + RemoteDataLockHrOffset); + remote.ReleaseResult = ReadU32Exact(process, data + RemoteDataReleaseCountOffset); + Log("REMOTE_ACQUIRE_DONE status=" + remote.Status + " getHR=0x" + remote.GetHr.ToString("X8") + + " lockHR=0x" + remote.LockHr.ToString("X8") + " release=0x" + remote.ReleaseResult.ToString("X8")); + remote.PresentStackArgumentsIntact = remote.PresentStackArgumentsIntact + && VerifyPresentStackArguments(process, remote, "after-get-render-target-and-lock"); + remote.RemoteContextIntact = remote.RemoteContextIntact + && VerifyRemoteStubContext(process, remote, unchecked(remote.Acquire.TrapAddress + 1), + "after-get-render-target-and-lock"); + if (remote.Status == 1) + { + try { remote.Surface = ReadSurfaceRows(process, data + RemoteDataDescOffset); } + catch (Exception pixelError) { remote.PixelFailure = pixelError.Message; } + BeginRemoteCleanup(process, remote); + } + else if (!remote.PresentStackArgumentsIntact || !remote.RemoteContextIntact) + { + captureFailure = "Present call stack arguments changed during D3D7 readback."; + Log("PRESENT_STACK_CORRUPTION_UNRECOVERED; terminating the verified scratch process before detach"); + fatal = true; + } + else + { + captureFailure = "D3D7 render-target readback returned status " + remote.Status + + " (GetRenderTarget 0x" + remote.GetHr.ToString("X8") + + ", Lock 0x" + remote.LockHr.ToString("X8") + ")."; + Log("REMOTE_CAPTURE_RETRY " + captureFailure); + ResumeOriginalPresent(process, remote); + VirtualFreeEx(process, Ptr(remote.Region), UIntPtr.Zero, 0x8000); + remote = null; + } + } + else + { + uint data = remote.Cleanup.DataBase; + remote.Status = ReadU32Exact(process, data + RemoteDataStatusOffset); + remote.UnlockHr = ReadU32Exact(process, data + RemoteDataUnlockHrOffset); + remote.ReleaseResult = ReadU32Exact(process, data + RemoteDataReleaseCountOffset); + Log("REMOTE_CLEANUP_DONE status=" + remote.Status + " unlockHR=0x" + remote.UnlockHr.ToString("X8") + + " release=0x" + remote.ReleaseResult.ToString("X8")); + bool unlocked = remote.Status == 6 && unchecked((int)remote.UnlockHr) >= 0; + remote.PresentStackArgumentsIntact = remote.PresentStackArgumentsIntact + && VerifyPresentStackArguments(process, remote, "after-unlock-and-release"); + remote.RemoteContextIntact = remote.RemoteContextIntact + && VerifyRemoteStubContext(process, remote, unchecked(remote.Cleanup.TrapAddress + 1), + "after-unlock-and-release"); + bool outputStaged = false; + if (!unlocked) + { + captureFailure = "D3D7 Unlock failed; the render target may remain locked (status " + + remote.Status + ", HRESULT 0x" + remote.UnlockHr.ToString("X8") + ")."; + Log("FRAME_CAPTURE_NOT_USABLE " + (remote.PixelFailure ?? captureFailure)); + Log("UNLOCK_FAILURE_UNRECOVERED; terminating the verified scratch process before detach"); + fatal = true; + // Keep the session non-null. The final recovery path terminates this + // exact scratch process and waits for exit before it detaches. + } + else if (!remote.PresentStackArgumentsIntact || !remote.RemoteContextIntact) + { + captureFailure = "Present call stack arguments changed during D3D7 readback."; + Log("PRESENT_STACK_CORRUPTION_UNRECOVERED; terminating the verified scratch process before detach"); + fatal = true; + } + else + { + if (remote.Surface != null) + { + if (completedSurface != null || completedFrame != null || completedReadback != null) + { + captureFailure = "A second frame readback completed before the staged frame could be safely finalized."; + Log("FRAME_OUTPUT_STAGE_REJECTED " + captureFailure); + stopAfterAttempt = true; + } + else + { + completedSurface = remote.Surface; + completedFrame = remote.Frame; + completedReadback = remote; + outputStaged = true; + Log("FRAME_OUTPUT_STAGED generation=" + remote.Generation + + " size=" + remote.Surface.Width + "x" + remote.Surface.Height + + " bitCount=" + remote.Surface.BitCount + " rowsSha256=" + remote.Surface.Sha256); + } + } + else + { + captureFailure = remote.PixelFailure ?? "D3D7 Lock succeeded, but pixel rows were not available."; + Log("FRAME_CAPTURE_NOT_USABLE " + captureFailure); + stopAfterAttempt = true; + } + + ResumeOriginalPresent(process, remote); + if (VirtualFreeEx(process, Ptr(remote.Region), UIntPtr.Zero, 0x8000)) + Log("REMOTE_READBACK_REGION_FREED"); + else Log("REMOTE_READBACK_REGION_FREE_FAILED error=" + Marshal.GetLastWin32Error()); + remote = null; + if (outputStaged) + { + captured = true; + Log("FRAME_CAPTURE_CONTEXT_RESTORED; awaiting camera restore and safe detach before writing outputs"); + } + } + } + } + else if (exceptionCode == 0x80000004 && pendingStep != null) + { + FinishSingleStep(process, eventTid, pendingStep); + Log("SINGLE_STEP_COMPLETE boundary=" + pendingStep.Breakpoint.Name + " tid=" + eventTid); + pendingStep = null; + } + else if (exceptionCode == 0x80000003) + { + if (cameraBreakpoint != null && cameraBreakpoint.Armed && exceptionAddress == cameraBreakpoint.Address) + hitBreakpoint = cameraBreakpoint; + else if (projectionBreakpoint != null && projectionBreakpoint.Armed && exceptionAddress == projectionBreakpoint.Address) + hitBreakpoint = projectionBreakpoint; + else if (returnBreakpoint != null && returnBreakpoint.Armed && exceptionAddress == returnBreakpoint.Address) + hitBreakpoint = returnBreakpoint; + else if (atmosphereBreakpoint != null && atmosphereBreakpoint.Armed && exceptionAddress == atmosphereBreakpoint.Address) + hitBreakpoint = atmosphereBreakpoint; + else if (missionBreakpoint != null && missionBreakpoint.Armed && exceptionAddress == missionBreakpoint.Address) + hitBreakpoint = missionBreakpoint; + else if (presentBreakpoint != null && presentBreakpoint.Armed && exceptionAddress == presentBreakpoint.Address) + hitBreakpoint = presentBreakpoint; + + if (hitBreakpoint != null) + { + IntPtr thread = OpenThread(ThreadGetContext | ThreadSetContext, false, eventTid); + if (thread == IntPtr.Zero) throw new InvalidOperationException("OpenThread at " + hitBreakpoint.Name + " failed: " + Marshal.GetLastWin32Error()); + try { stoppedContext = GetFullX86Context(thread); } + finally { CloseHandle(thread); } + if (unchecked(BitConverter.ToUInt32(stoppedContext, ContextEipOffset)) != unchecked(hitBreakpoint.Address + 1)) + throw new InvalidOperationException(hitBreakpoint.Name + " breakpoint EIP did not point past INT3."); + + if (hitBreakpoint == cameraBreakpoint) + { + if (selectedCamera != null && selectedCamera.Applied) + { + if (!selectedCamera.PixelAttributionInvalidated) + { + selectedCamera.PixelAttributionInvalidated = true; + selectedCamera.PixelAttributionFailure = "An additional World3D.stdRenderGame invocation occurred before the next present boundary."; + captureFailure = selectedCamera.PixelAttributionFailure; + stopAfterAttempt = true; + Log("SELECTED_PIXEL_ATTRIBUTION_INVALIDATED tid=" + eventTid + + " esp=0x" + BitConverter.ToUInt32(stoppedContext, ContextEspOffset).ToString("X8") + + " selectedGeneration=" + selectedCamera.Generation + + " restoreVerified=" + selectedCamera.RestoreVerified); + } + Log("CAMERA_ENTRY_SKIPPED_SELECTED_FRAME_PENDING tid=" + eventTid); + } + else + { + frame.CameraGeneration++; + frame.CameraThreadId = eventTid; + frame.CameraWorldGameTimeWordReadable = false; + if (world3dBase != 0) + { + try + { + frame.CameraWorldGameTimeWord = ReadU32Exact(process, unchecked(world3dBase + 0x32A38)); + frame.CameraWorldGameTimeWordReadable = true; + } + catch (Exception clockError) + { + Log("CAMERA_WORLD_GAME_TIME_UNREADABLE " + clockError.Message); + } + } + frame.Projection = null; + frame.ProjectionGeneration = 0; + try { frame.Camera = ReadCameraSnapshot(process, eventTid, terrainBase); } + catch (Exception cameraError) { frame.Camera = null; Log("CAMERA_SNAPSHOT_FAILED " + cameraError.Message); } + Log("CAMERA_GENERATION " + frame.CameraGeneration + " tid=" + eventTid + + " pointer=" + (frame.Camera == null ? "unreadable" : "0x" + frame.Camera.Camera.ToString("X8")) + + " layoutVerified=" + (frame.Camera != null && frame.Camera.LayoutVerified) + + " matrixSha256=" + (frame.Camera == null ? "unreadable" : frame.Camera.EntryMatrixSha256) + + " entryEsp=0x" + (frame.Camera == null ? 0 : frame.Camera.EntryEsp).ToString("X8") + + " return=0x" + (frame.Camera == null ? 0 : frame.Camera.ReturnAddress).ToString("X8")); + + if (selectedCamera != null && !selectedCamera.Applied && frame.Camera != null + && frame.Camera.LayoutVerified) + { + if (selectedCamera.CandidateProjectionVerified + && frame.Camera.Camera == selectedCamera.CandidateCameraPointer + && eventTid == selectedCamera.CandidateThreadId + && frame.CameraGeneration != selectedCamera.CandidateGeneration) + { + if (frame.Camera.EntryEsp < RenderFunctionStackFrameBytes) + throw new InvalidOperationException("Selected camera render stack pointer underflowed the verified prologue size."); + selectedCamera.CameraPointer = frame.Camera.Camera; + selectedCamera.ThreadId = eventTid; + selectedCamera.Generation = frame.CameraGeneration; + selectedCamera.EntryEsp = frame.Camera.EntryEsp; + selectedCamera.FunctionStackEsp = frame.Camera.EntryEsp - RenderFunctionStackFrameBytes; + selectedCamera.ReturnAddress = frame.Camera.ReturnAddress; + selectedCamera.OriginalMatrixBytes = (byte[])frame.Camera.EntryMatrixBytes.Clone(); + selectedCamera.OriginalMatrixSha256 = frame.Camera.EntryMatrixSha256; + frame.SelectedCamera = selectedCamera; + cameraSetter = StartCameraSetter(process, eventTid, terrainBase, + selectedCamera.CameraPointer, selectedCamera.Input.MatrixBytes, + stoppedContext, cameraBreakpoint, true, CameraSetterPhase.Apply, + selectedCamera.EntryEsp, selectedCamera.ReturnAddress); + Log("SELECTED_CAMERA_APPLY_STARTED generation=" + selectedCamera.Generation + + " candidateGeneration=" + selectedCamera.CandidateGeneration + + " camera=0x" + selectedCamera.CameraPointer.ToString("X8") + + " entryEsp=0x" + selectedCamera.EntryEsp.ToString("X8") + + " return=0x" + selectedCamera.ReturnAddress.ToString("X8") + + " originalSha256=" + selectedCamera.OriginalMatrixSha256 + + " requestedSha256=" + selectedCamera.Input.MatrixSha256); + } + else if (!selectedCamera.CandidateProjectionVerified) + { + selectedCamera.CandidateCameraPointer = frame.Camera.Camera; + selectedCamera.CandidateThreadId = eventTid; + selectedCamera.CandidateGeneration = frame.CameraGeneration; + frame.SelectedCamera = selectedCamera; + Log("SELECTED_CAMERA_PREFLIGHT_CANDIDATE generation=" + selectedCamera.CandidateGeneration + + " camera=0x" + selectedCamera.CandidateCameraPointer.ToString("X8") + + " tid=" + selectedCamera.CandidateThreadId + + " matrixSha256=" + frame.Camera.EntryMatrixSha256); + } + else + { + Log("SELECTED_CAMERA_WAITING_FOR_MATCHING_CANDIDATE tid=" + eventTid + + " camera=0x" + frame.Camera.Camera.ToString("X8") + + " candidateTid=" + selectedCamera.CandidateThreadId + + " candidateCamera=0x" + selectedCamera.CandidateCameraPointer.ToString("X8")); + } + } + else if (selectedCamera != null && !selectedCamera.Applied && frame.Camera != null) + Log("SELECTED_CAMERA_CANDIDATE_REJECTED tid=" + eventTid + + " layoutVerified=" + frame.Camera.LayoutVerified + + " matrixSha256=" + frame.Camera.EntryMatrixSha256 + + " requestedSha256=" + selectedCamera.Input.MatrixSha256); + } + } + else if (hitBreakpoint == projectionBreakpoint) + { + string candidateProjectionEvidence = "candidate preconditions not met"; + string selectedProjectionEvidence = "selected invocation was not checked"; + bool selectedCandidateScope = selectedCamera != null && !selectedCamera.Applied + && !selectedCamera.CandidateProjectionVerified + && frame.Camera != null + && frame.Camera.Camera == selectedCamera.CandidateCameraPointer + && frame.CameraGeneration == selectedCamera.CandidateGeneration + && eventTid == selectedCamera.CandidateThreadId + && frame.Camera.EntryEsp >= RenderFunctionStackFrameBytes + && IsRenderInvocation(process, stoppedContext, eventTid, + selectedCamera.CandidateThreadId, + frame.Camera.Camera, frame.Camera.EntryEsp, + frame.Camera.EntryEsp - RenderFunctionStackFrameBytes, + frame.Camera.ReturnAddress, unchecked(projectionBreakpoint.Address + 1), + true, + out candidateProjectionEvidence); + bool selectedProjectionScope = selectedCamera == null + ? !frame.SelectedCameraRequested + : (selectedCamera.Applied + ? IsSelectedRenderInvocation(process, stoppedContext, eventTid, + selectedCamera, unchecked(projectionBreakpoint.Address + 1), + true, + out selectedProjectionEvidence) + : selectedCandidateScope); + if (!selectedProjectionScope) + { + if (selectedCamera != null && !selectedCamera.ProjectionGateDiagnosticLogged) + { + selectedCamera.ProjectionGateDiagnosticLogged = true; + Log("PROJECTION_SKIPPED_OUTSIDE_SELECTED_RENDER_INVOCATION tid=" + eventTid + + " applied=" + selectedCamera.Applied + + " candidateGeneration=" + selectedCamera.CandidateGeneration + + " candidateTid=" + selectedCamera.CandidateThreadId + + " candidateCamera=0x" + selectedCamera.CandidateCameraPointer.ToString("X8") + + " selectedGeneration=" + selectedCamera.Generation + + " selectedTid=" + selectedCamera.ThreadId + + " selectedCamera=0x" + selectedCamera.CameraPointer.ToString("X8") + + " candidateEvidence=" + candidateProjectionEvidence + + " selectedEvidence=" + selectedProjectionEvidence); + } + } + else + { + frame.Projection = null; + frame.ProjectionGeneration = 0; + if (frame.Camera != null && frame.CameraThreadId == eventTid && ngiBase != 0) + { + bool cameraWordsCurrent = RefreshCameraWordsAtProjection(process, frame.Camera, + terrainBase, frame.CameraGeneration); + frame.Projection = ReadProjection(process, ngiBase); + frame.ProjectionThreadId = eventTid; + frame.ProjectionGeneration = frame.CameraGeneration; + frame.WorldGameTimeWordReadable = false; + try + { + frame.WorldGameTimeWord = ReadU32Exact(process, unchecked(world3dBase + 0x32A38)); + frame.WorldGameTimeWordReadable = true; + } + catch (Exception clockError) + { + Log("PROJECTION_WORLD_GAME_TIME_UNREADABLE " + clockError.Message); + } + PairAtmosphereToProjection(frame, eventTid); + bool cameraMatrixMatchesInput = selectedCamera == null || !selectedCamera.Applied + || SelectedMatrixMatchesProjection(frame); + bool projectionMatchesInput = selectedCamera == null + || ProjectionMatchesCameraInput(frame.Projection, selectedCamera.Input) + && cameraMatrixMatchesInput; + if (selectedCamera != null) + { + if (selectedCamera.Applied) + selectedCamera.ProjectionMatchesInput = projectionMatchesInput; + else + { + selectedCamera.CandidateProjectionVerified = cameraWordsCurrent + && frame.Projection.Verified && frame.Projection.Mode != 0 + && projectionMatchesInput; + Log("SELECTED_CAMERA_PREFLIGHT_PROJECTION verified=" + + selectedCamera.CandidateProjectionVerified + + " generation=" + selectedCamera.CandidateGeneration + + " camera=0x" + selectedCamera.CandidateCameraPointer.ToString("X8") + + " tid=" + selectedCamera.CandidateThreadId); + } + Log("SELECTED_CAMERA_PROJECTION_MATCH " + projectionMatchesInput + + " cameraMatrixMatchesInput=" + cameraMatrixMatchesInput + + " inputViewport=" + String.Join(",", selectedCamera.Input.Viewport) + + " actualViewport=" + (frame.Projection.Viewport == null ? "unreadable" : String.Join(",", frame.Projection.Viewport)) + + " inputNear=" + selectedCamera.Input.Near.ToString("R", CultureInfo.InvariantCulture) + + " actualNear=" + frame.Projection.Near.ToString("R", CultureInfo.InvariantCulture) + + " inputFar=" + selectedCamera.Input.Far.ToString("R", CultureInfo.InvariantCulture) + + " actualFar=" + frame.Projection.Far.ToString("R", CultureInfo.InvariantCulture) + + " inputFov=" + selectedCamera.Input.FieldOfView.ToString("R", CultureInfo.InvariantCulture) + + " actualFov=" + frame.Projection.Fov.ToString("R", CultureInfo.InvariantCulture)); + } + Log("PROJECTION_GENERATION " + frame.ProjectionGeneration + " tid=" + eventTid + + " renderer=0x" + frame.Projection.Renderer.ToString("X8") + + " viewport=" + (frame.Projection.Viewport == null ? "unreadable" : String.Join(",", frame.Projection.Viewport)) + + " mode=" + frame.Projection.Mode + " cameraWordsCurrent=" + cameraWordsCurrent + + " usable=" + (cameraWordsCurrent && frame.Projection.Verified && frame.Projection.Mode != 0 + && projectionMatchesInput) + + " rawWorldTime=0x" + frame.WorldGameTimeWord.ToString("X8")); + if (cameraWordsCurrent && frame.Projection.Verified && frame.Projection.Mode != 0 + && projectionMatchesInput && ngiBase != 0 && presentBoundaryVerified) + EnsurePresentBreakpoint(process, ngiBase, frame, eventTid, + presentOriginalByte, ref presentBreakpoint); + } + } + if (frame.Camera == null || frame.CameraThreadId != eventTid || ngiBase == 0) + Log("PROJECTION_SKIPPED without loaded renderer and same-thread camera snapshot tid=" + eventTid); + } + else if (hitBreakpoint == returnBreakpoint) + { + if (selectedCamera != null && selectedCamera.Applied && !selectedCamera.Restored) + { + string returnEvidence; + // World3D+0x13D47 repurposes ESI for registry traversal before this epilogue. + bool returnInvocationMatches = IsSelectedRenderInvocation(process, + stoppedContext, eventTid, selectedCamera, + unchecked(returnBreakpoint.Address + 1), false, out returnEvidence); + if (!returnInvocationMatches && !selectedCamera.ReturnGateDiagnosticLogged) + { + selectedCamera.ReturnGateDiagnosticLogged = true; + Log("SELECTED_CAMERA_RETURN_GATE_REJECTED " + returnEvidence + + " selectedGeneration=" + selectedCamera.Generation + + " selectedTid=" + selectedCamera.ThreadId + + " selectedCamera=0x" + selectedCamera.CameraPointer.ToString("X8")); + } + if (returnInvocationMatches) + { + try + { + byte[] selectedAtReturn = ReadCameraMatrix(process, selectedCamera.CameraPointer); + selectedCamera.MatrixIntactAtReturn = ByteArraysEqual(selectedAtReturn, + selectedCamera.Input.MatrixBytes); + } + catch (Exception matrixReadError) + { + selectedCamera.MatrixIntactAtReturn = false; + Log("SELECTED_CAMERA_RETURN_MATRIX_READ_FAILED " + matrixReadError.Message); + } + if (!selectedCamera.MatrixIntactAtReturn) + { + selectedCamera.PixelAttributionInvalidated = true; + selectedCamera.PixelAttributionFailure = "Selected camera matrix changed or became unreadable before its verified render return."; + captureFailure = selectedCamera.PixelAttributionFailure; + stopAfterAttempt = true; + Log("SELECTED_CAMERA_CHANGED_BEFORE_RETURN; restoring the original native matrix but rejecting pixels"); + } + cameraSetter = StartCameraSetter(process, eventTid, terrainBase, + selectedCamera.CameraPointer, selectedCamera.OriginalMatrixBytes, + stoppedContext, returnBreakpoint, false, CameraSetterPhase.Restore, + selectedCamera.EntryEsp, selectedCamera.ReturnAddress); + Log("SELECTED_CAMERA_RESTORE_STARTED generation=" + selectedCamera.Generation + + " camera=0x" + selectedCamera.CameraPointer.ToString("X8") + + " returnEsp=0x" + BitConverter.ToUInt32(stoppedContext, ContextEspOffset).ToString("X8") + + " matrixIntactAtReturn=" + selectedCamera.MatrixIntactAtReturn); + } + } + } + else if (hitBreakpoint == atmosphereBreakpoint) + { + try + { + AtmosphereReadback sample = CaptureAtmospherePhase(process, terrainBase, + world3dBase, stoppedContext, eventTid, terrainModuleSha256, frame); + frame.AtmosphereByThread[eventTid] = sample; + Log("ATMOSPHERE_PHASE_SAMPLE tid=" + eventTid + + " cameraGenerationAtSample=" + sample.CameraGenerationAtSample + + " cameraThreadAtSample=" + sample.CameraThreadIdAtSample + + " esi=0x" + sample.AtmosphereObject.ToString("X8") + + " ebpRaw=0x" + sample.RawClock.ToString("X8") + + " edxPhaseMs=" + sample.PhaseMilliseconds + + " origin=[esi+0x144]=" + sample.Origin + + " periodMs=[esi+0x150]=" + sample.PeriodMilliseconds + + " recomputedPhaseMs=" + sample.RecomputedPhaseMilliseconds + + " worldGameTime=0x" + sample.WorldGameTimeWord.ToString("X8") + + " arithmeticVerified=" + sample.ArithmeticVerified + + " rawMatchesWorldTime=" + sample.WorldTimeMatchesRawClock + + " terrainHashVerified=" + sample.TerrainModuleHashVerified); + } + catch (Exception atmosphereError) + { + Log("ATMOSPHERE_PHASE_SAMPLE_REJECTED tid=" + eventTid + " " + atmosphereError.Message); + } + } + else if (hitBreakpoint == missionBreakpoint) + { + missionProbeAttempts++; + string missionPath; + string missionEvidence; + bool found = CaptureMissionIdentityAtCall(process, iron3dBase, stoppedContext, + eventTid, out missionPath, out missionEvidence); + if (found) + { + frame.MissionPath = missionPath; + frame.MissionEvidence = missionEvidence; + missionProbeFinished = true; + } + else if (missionProbeAttempts >= MissionProbeAttemptLimit) + { + missionProbeFinished = true; + frame.MissionEvidence = "verified callsite reached " + missionProbeAttempts + + " times, but no validated mission path was found; " + missionEvidence; + } + else frame.MissionEvidence = "verified callsite reached " + missionProbeAttempts + + " times; no validated mission path yet; " + missionEvidence; + Log("MISSION_IDENTITY_PROBE attempt=" + missionProbeAttempts + "/" + + MissionProbeAttemptLimit + " found=" + found + + " path=" + (missionPath ?? "unknown") + " evidence=" + missionEvidence); + } + else if (hitBreakpoint == presentBreakpoint) + { + if (CanArmPresentForFrame(frame, eventTid)) + { + try { remote = StartRemoteReadback(process, eventTid, ngiBase, presentBreakpoint, frame, outputPath); } + catch (Exception startError) + { + captureFailure = startError.Message; + Log("REMOTE_CAPTURE_START_FAILED " + startError); + stopAfterAttempt = true; + } + } + else Log("PRESENT_SKIPPED without matching verified perspective camera/projection"); + } + + if (remote == null && cameraSetter == null) + { + bool rearmAfterStep = hitBreakpoint != presentBreakpoint + && !(hitBreakpoint == missionBreakpoint && missionProbeFinished); + BeginSingleStep(process, eventTid, hitBreakpoint, stoppedContext, + out pendingStep, rearmAfterStep); + Log("SINGLE_STEP_STARTED boundary=" + hitBreakpoint.Name + " tid=" + eventTid); + } + } + else + { + continueStatus = DbgContinue; + } + } + else + { + continueStatus = DbgExceptionNotHandled; + } + } + else if (eventCode == ExitProcessDebugEvent) + { + uint exitCode = unchecked((uint)Marshal.ReadInt32(eventBuffer, 12)); + Log("PROCESS_EXIT while capturing exitCode=0x" + exitCode.ToString("X8")); + processExited = true; + remote = null; + shouldStop = true; + } + } + catch (Exception eventError) + { + Log("FRAME_EVENT_ERROR " + eventError); + if (remote != null || cameraSetter != null) + { + TerminateProcess(process, cameraSetter != null ? 0xE00Cu : 0xE004u); + fatal = true; + } + else if (hitBreakpoint != null && stoppedContext != null) + { + try + { + if (hitBreakpoint.Armed) + { + if (!WriteByte(process, hitBreakpoint.Address, hitBreakpoint.OriginalByte)) + throw new InvalidOperationException("Could not restore failed boundary byte."); + hitBreakpoint.Armed = false; + } + IntPtr thread = OpenThread(ThreadGetContext | ThreadSetContext, false, eventTid); + if (thread == IntPtr.Zero) throw new InvalidOperationException("OpenThread for failed boundary recovery failed."); + try { SetFullX86Context(thread, ContextAtBreakpoint(stoppedContext, hitBreakpoint.Address, false)); } + finally { CloseHandle(thread); } + captureFailure = eventError.Message; + stopAfterAttempt = true; + Log("FAILED_BOUNDARY_RECOVERED boundary=" + hitBreakpoint.Name); + } + catch (Exception recoveryError) + { + Log("FATAL_BOUNDARY_RECOVERY_FAILED " + recoveryError); + TerminateProcess(process, 0xE005); + fatal = true; + } + } + else + { + continueStatus = DbgContinue; + captureFailure = eventError.Message; + stopAfterAttempt = true; + Log("FRAME_EVENT_ABORT_RECOVERED eventCode=" + eventCode + " error=" + eventError.Message); + } + } + + if (!captured && !fatal && !processExited && DateTime.UtcNow >= deadline + && remote == null && cameraSetter == null && pendingStep == null) + { + captureFailure = captureFailure ?? "No matching native camera/projection/present frame completed before the bounded timeout."; + Log("NO_FRAME_CAPTURE bounded timeout at stopped debug event"); + if (!RestoreArmedBreakpointsWhileStopped(process, + cameraBreakpoint, projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, presentBreakpoint, missionBreakpoint)) + { + Log("FATAL_TIMEOUT_BREAKPOINT_RESTORE_FAILED; terminating own verified scratch process"); + TerminateProcess(process, 0xE007); + fatal = true; + } + else + { + stopAfterAttempt = true; + } + } + + if (CanStopCapture(captured, stopAfterAttempt, remote != null || cameraSetter != null, + pendingStep != null, selectedCamera != null && selectedCamera.Applied && !selectedCamera.RestoreVerified) + && !fatal && !processExited + && (cameraBreakpoint != null && cameraBreakpoint.Armed + || projectionBreakpoint != null && projectionBreakpoint.Armed + || returnBreakpoint != null && returnBreakpoint.Armed + || atmosphereBreakpoint != null && atmosphereBreakpoint.Armed + || presentBreakpoint != null && presentBreakpoint.Armed + || missionBreakpoint != null && missionBreakpoint.Armed)) + { + if (!RestoreArmedBreakpointsWhileStopped(process, + cameraBreakpoint, projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, presentBreakpoint, missionBreakpoint)) + { + Log("FATAL_STOP_BREAKPOINT_RESTORE_FAILED; terminating own verified scratch process"); + TerminateProcess(process, 0xE008); + fatal = true; + } + } + + if (!ContinueDebugEvent(eventPid, eventTid, continueStatus)) + { + fatal = true; + Log("FATAL ContinueDebugEvent failed=" + Marshal.GetLastWin32Error()); + if (process != IntPtr.Zero && !TerminateProcess(process, 0xE009)) + Log("FATAL ContinueDebugEvent recovery terminate failed=" + Marshal.GetLastWin32Error()); + break; + } + if (shouldStop || CanStopCapture(captured, stopAfterAttempt, remote != null || cameraSetter != null, + pendingStep != null, selectedCamera != null && selectedCamera.Applied && !selectedCamera.RestoreVerified)) break; + } + if (!captured && !fatal && !stopAfterAttempt) + { + captureFailure = captureFailure ?? "No matching native camera/projection/present frame completed before the bounded timeout."; + Log("NO_FRAME_CAPTURE bounded timeout"); + } + } + finally + { + bool cameraMutationUnrestored = selectedCamera != null && selectedCamera.Applied && !selectedCamera.RestoreVerified; + if (!processExited && process != IntPtr.Zero && (remote != null || cameraSetter != null || pendingStep != null + || cameraMutationUnrestored + || cameraBreakpoint != null && cameraBreakpoint.Armed + || projectionBreakpoint != null && projectionBreakpoint.Armed + || returnBreakpoint != null && returnBreakpoint.Armed + || atmosphereBreakpoint != null && atmosphereBreakpoint.Armed + || presentBreakpoint != null && presentBreakpoint.Armed + || missionBreakpoint != null && missionBreakpoint.Armed)) + { + string state = remote != null ? "REMOTE_STUB_IN_FLIGHT" : (cameraSetter != null ? "CAMERA_SETTER_IN_FLIGHT" + : (cameraMutationUnrestored ? "SELECTED_CAMERA_MUTATION_NOT_RESTORED" + : (pendingStep != null ? "SINGLE_STEP_IN_FLIGHT" : "ARMED_BREAKPOINTS_WITHOUT_STOPPED_EVENT"))); + Log("FINAL_" + state + "; terminating only the path/tick/hash-verified scratch process before detach"); + if (!TerminateProcess(process, 0xE006)) + Log("FINAL_TERMINATE_FAILED error=" + Marshal.GetLastWin32Error()); + if (WaitForSingleObject(process, 2000) == 0) processExited = true; + else + { + fatal = true; + processExitUnconfirmed = true; + Log("FATAL scratch termination was not confirmed before detach"); + } + } + if (attached && !processExitUnconfirmed) + { + detached = DebugActiveProcessStop(processId); + if (detached) Log("DEBUG_DETACHED"); + else + { + fatal = true; + int detachError = Marshal.GetLastWin32Error(); + Log("FATAL DebugActiveProcessStop failed=" + detachError); + if (process != IntPtr.Zero) + { + uint waitBeforeRecovery = WaitForSingleObject(process, 0); + int waitBeforeError = waitBeforeRecovery == 0xFFFFFFFF ? Marshal.GetLastWin32Error() : 0; + uint exitBeforeRecovery; + bool exitRead = GetExitCodeProcess(process, out exitBeforeRecovery); + int exitBeforeError = exitRead ? 0 : Marshal.GetLastWin32Error(); + Log("PROCESS_STATE_AFTER_DETACH_FAILURE waitResult=0x" + waitBeforeRecovery.ToString("X8") + + " exitCode=" + (exitRead ? "0x" + exitBeforeRecovery.ToString("X8") : "unreadable") + + " waitError=" + waitBeforeError + " exitError=" + exitBeforeError); + if (waitBeforeRecovery == 0x00000102 && exitRead && exitBeforeRecovery == 0x00000103) + { + Log("DETACH_FAILURE_TARGET_STILL_ACTIVE; terminating only exact verified scratch after state snapshot"); + if (!TerminateProcess(process, 0xE00A)) + { + int terminateError = Marshal.GetLastWin32Error(); + Log("DETACH_FAILURE_TERMINATE_FAILED error=" + terminateError); + } + uint waitAfterRecovery = WaitForSingleObject(process, 2000); + int waitAfterError = waitAfterRecovery == 0xFFFFFFFF ? Marshal.GetLastWin32Error() : 0; + uint exitAfterRecovery; + bool exitAfterRead = GetExitCodeProcess(process, out exitAfterRecovery); + int exitAfterError = exitAfterRead ? 0 : Marshal.GetLastWin32Error(); + Log("PROCESS_STATE_AFTER_DETACH_FAILURE_RECOVERY waitResult=0x" + waitAfterRecovery.ToString("X8") + + " exitCode=" + (exitAfterRead ? "0x" + exitAfterRecovery.ToString("X8") : "unreadable") + + " waitError=" + waitAfterError + " exitError=" + exitAfterError); + if (waitAfterRecovery == 0) processExited = true; + else processExitUnconfirmed = true; + } + } + } + } + else if (attached) + { + Log("FATAL_DEBUG_DETACH_SKIPPED_PROCESS_STILL_RUNNING"); + } + if (detached && process != IntPtr.Zero) + { + uint waitResult = WaitForSingleObject(process, 2000); + uint exitCode; + if (GetExitCodeProcess(process, out exitCode)) + Log((waitResult == 0 ? "PROCESS_EXIT_AFTER_DETACH" : "PROCESS_STATE_AFTER_DETACH") + + " waitResult=0x" + waitResult.ToString("X8") + " exitCode=0x" + exitCode.ToString("X8")); + else Log("PROCESS_STATE_AFTER_DETACH unreadable error=" + Marshal.GetLastWin32Error()); + } + breakpointsRestored = processExited || AreBreakpointsRestored(cameraBreakpoint, + projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, presentBreakpoint, missionBreakpoint); + Log("FINAL_BREAKPOINT_STATE restored=" + breakpointsRestored + " processExited=" + processExited); + if (eventBuffer != IntPtr.Zero) Marshal.FreeHGlobal(eventBuffer); + if (process != IntPtr.Zero) CloseHandle(process); + } + + if (!detached) throw new InvalidOperationException("Debug detach not confirmed; inspect the frame log before reuse."); + if (fatal) throw new InvalidOperationException("The native frame capture could not recover safely; inspect the frame log."); + if (captured) + { + bool pngCreated = false; + bool jsonCreated = false; + string outputPng = Path.ChangeExtension(outputPath, ".png"); + try + { + if (!breakpointsRestored) + throw new InvalidOperationException("The staged pixels cannot be published because breakpoint restoration was not confirmed."); + if (completedSurface == null || completedFrame == null || completedReadback == null) + throw new InvalidOperationException("The capture was marked ready without a staged frame and readback record."); + if (!IsMatchingPerspectiveFrame(completedFrame, completedReadback.ThreadId)) + throw new InvalidOperationException("The staged pixels lost their verified camera or projection attribution before finalization."); + string json = UsableFrameJson(expectedPath, world3dBase, ngiBase, completedFrame, + completedSurface, outputPng, completedReadback); + SaveSurfacePng(completedSurface, outputPng); + pngCreated = true; + WriteTextCreateNew(outputPath, json); + jsonCreated = true; + } + catch (Exception outputError) + { + if (jsonCreated) try { File.Delete(outputPath); } catch { } + if (pngCreated) try { File.Delete(outputPng); } catch { } + captureFailure = "Could not safely finalize the detached frame output: " + outputError.Message; + stopAfterAttempt = true; + captured = false; + Log("FRAME_OUTPUT_FAILED " + outputError); + } + if (captured) + { + Log("FRAME_OUTPUT_COMPLETE generation=" + completedReadback.Generation + " png=" + outputPng + + " json=" + outputPath + " size=" + completedSurface.Width + "x" + completedSurface.Height + + " bitCount=" + completedSurface.BitCount + " rowsSha256=" + completedSurface.Sha256 + + " afterContextRestore=true breakpointsRestored=true detached=true"); + Console.WriteLine("native frame captured: " + outputPath + " and " + outputPng); + return 0; + } + } + if (captureFailure != null) Console.Error.WriteLine(captureFailure); + return stopAfterAttempt ? 4 : 3; + } + + private static BreakpointInfo ArmBreakpoint(IntPtr process, string name, uint address, byte expectedByte) + { + byte[] current = new byte[1]; + if (!Read(process, address, current) || current[0] != expectedByte) + throw new InvalidOperationException(name + " signature byte changed before arming at 0x" + address.ToString("X8")); + BreakpointInfo result = new BreakpointInfo(); + result.Name = name; + result.Address = address; + result.OriginalByte = current[0]; + if (!WriteByte(process, address, 0xCC)) throw new InvalidOperationException("Could not arm " + name + " at 0x" + address.ToString("X8")); + result.Armed = true; + Log("BREAKPOINT_ARMED " + name + " address=0x" + address.ToString("X8") + " original=0x" + current[0].ToString("X2")); + return result; + } + + private static void EnsurePresentBreakpoint(IntPtr process, uint ngiBase, FrameSnapshot frame, + uint threadId, byte expectedByte, ref BreakpointInfo presentBreakpoint) + { + if (!CanArmPresentForFrame(frame, threadId)) return; + uint address = unchecked(ngiBase + PresentBoundaryRva); + if (presentBreakpoint == null) + { + presentBreakpoint = ArmBreakpoint(process, "Ngi32.windowed-present", address, expectedByte); + return; + } + if (presentBreakpoint.Address != address || presentBreakpoint.OriginalByte != expectedByte) + throw new InvalidOperationException("The verified Ngi32 present boundary changed during this capture."); + if (presentBreakpoint.Armed) return; + byte[] current = new byte[1]; + if (!Read(process, address, current) || current[0] != presentBreakpoint.OriginalByte) + throw new InvalidOperationException("Ngi32 present signature changed before re-arming."); + if (!WriteByte(process, address, 0xCC)) + throw new InvalidOperationException("Could not re-arm Ngi32 present boundary."); + presentBreakpoint.Armed = true; + Log("BREAKPOINT_REARMED Ngi32.windowed-present address=0x" + address.ToString("X8")); + } + + private static bool RestoreArmedBreakpointsWhileStopped(IntPtr process, + BreakpointInfo cameraBreakpoint, BreakpointInfo projectionBreakpoint, BreakpointInfo returnBreakpoint, + BreakpointInfo atmosphereBreakpoint, BreakpointInfo presentBreakpoint, BreakpointInfo missionBreakpoint) + { + return RestoreBreakpointWhileStopped(process, cameraBreakpoint) + && RestoreBreakpointWhileStopped(process, projectionBreakpoint) + && RestoreBreakpointWhileStopped(process, returnBreakpoint) + && RestoreBreakpointWhileStopped(process, atmosphereBreakpoint) + && RestoreBreakpointWhileStopped(process, presentBreakpoint) + && RestoreBreakpointWhileStopped(process, missionBreakpoint); + } + + private static bool AreBreakpointsRestored(BreakpointInfo cameraBreakpoint, + BreakpointInfo projectionBreakpoint, BreakpointInfo returnBreakpoint, + BreakpointInfo atmosphereBreakpoint, BreakpointInfo presentBreakpoint, + BreakpointInfo missionBreakpoint) + { + return (cameraBreakpoint == null || !cameraBreakpoint.Armed) + && (projectionBreakpoint == null || !projectionBreakpoint.Armed) + && (returnBreakpoint == null || !returnBreakpoint.Armed) + && (atmosphereBreakpoint == null || !atmosphereBreakpoint.Armed) + && (presentBreakpoint == null || !presentBreakpoint.Armed) + && (missionBreakpoint == null || !missionBreakpoint.Armed); + } + + private static bool RestoreBreakpointWhileStopped(IntPtr process, BreakpointInfo breakpoint) + { + if (breakpoint == null || !breakpoint.Armed) return true; + if (!WriteByte(process, breakpoint.Address, breakpoint.OriginalByte)) + { + Log("FATAL breakpoint restore failed while target stopped: " + breakpoint.Name + + " error=" + Marshal.GetLastWin32Error()); + return false; + } + breakpoint.Armed = false; + Log("BREAKPOINT_RESTORED_WHILE_STOPPED " + breakpoint.Name + " address=0x" + breakpoint.Address.ToString("X8")); + return true; + } +} diff --git a/tools/native-frame-capture/README.md b/tools/native-frame-capture/README.md new file mode 100644 index 0000000..4a9a979 --- /dev/null +++ b/tools/native-frame-capture/README.md @@ -0,0 +1,85 @@ +# Native frame capture + +This x86 helper reads one live frame from the isolated GOG-compatible scratch copy of `Parkan - Iron Strategy`. It writes a PNG and a `fparkan-legacy-camera-v1` JSON file. The 64-byte camera matrix is 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. + +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: + +```powershell +$originalGame = 'C:\GOG Games\Parkan - Iron Strategy' +$scratchGame = 'target\shadow-probe\Parkan - Iron Strategy' +New-Item -ItemType Directory -Force -Path (Split-Path $scratchGame) | Out-Null +robocopy $originalGame $scratchGame /E /COPY:DAT /R:1 /W:1 +if ($LASTEXITCODE -ge 8) { throw 'Scratch copy failed; inspect robocopy output.' } +``` + +Build the viewer from the repository root with `cargo build --release -p fparkan-game`. No package installation is needed. + +Build and run the ABI self-check from the repository root: + +```powershell +& 'C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe' /nologo /platform:x86 /r:System.Drawing.dll /r:System.Web.Extensions.dll /out:target\native-frame-capture.exe tools\native-frame-capture\NativeFrameCapture.cs tools\native-frame-capture\NativeFrameCapture.Readback.cs +if ($LASTEXITCODE -ne 0) { throw 'Native capture helper build failed; inspect compiler output.' } +& 'target\native-frame-capture.exe' --self-check +if ($LASTEXITCODE -ne 0) { throw 'Native capture helper self-check failed; inspect its output.' } +``` + +Start the scratch executable from its own directory, then pass its PID and UTC start ticks to the helper. Keep the game visible while it waits; the helper does not synthesize input. It waits up to five minutes for a live world to reach the verified camera, projection, and Present boundaries. The default startup can remain at the shell until a mission begins, so a timeout is not a successful capture. The helper rejects other executable paths, stale PIDs, and mismatched game DLLs. Output must remain under `target`. + +```powershell +$game = (Resolve-Path 'target\shadow-probe\Parkan - Iron Strategy').Path +$process = Start-Process -FilePath (Join-Path $game 'iron_3d.exe') -WorkingDirectory $game -PassThru +& 'target\native-frame-capture.exe' --capture $process.Id $process.StartTime.ToUniversalTime().Ticks target\native-frame-capture\frame.json +if ($LASTEXITCODE -ne 0) { throw 'Native frame capture failed; inspect its output and choose fresh output paths before retrying.' } +``` + +After a passive capture succeeds, its JSON can serve as the optional selected-camera input. The following example uses the matrix and projection values from `frame.json` and writes a new basename: + +```powershell +$cameraInput = 'target\native-frame-capture\frame.json' +$selectedFrame = 'target\native-frame-capture\selected-frame.json' +& 'target\native-frame-capture.exe' --validate-camera-input $cameraInput +if ($LASTEXITCODE -ne 0) { throw 'Camera JSON is not a usable fparkan-legacy-camera-v1 input.' } +& 'target\native-frame-capture.exe' --capture $process.Id $process.StartTime.ToUniversalTime().Ticks $selectedFrame --camera-input $cameraInput +if ($LASTEXITCODE -ne 0) { throw 'Selected-camera capture failed; inspect its log before retrying.' } +``` + +`--validate-camera-input` parses and checks the JSON before opening a game process. The input must contain a valid `fparkan-legacy-camera-v1` matrix and projection values. At runtime the helper first observes an unmodified camera render and requires its viewport, near/far planes, and FOV to match the input. It applies only the 64-byte camera matrix to that same camera on the next same-thread render and reads the resulting surface at the verified Present boundary while the selected matrix is still active. At the projection boundary it verifies that the active matrix still exactly matches the requested input, as well as the projection values. It then restores the original matrix through the verified setter at that render invocation's return boundary. Pixels remain in memory until the original Present context, camera matrix, armed breakpoints, and debugger attachment have all been restored; the helper creates the PNG and JSON only after confirmed detach. It rejects output if the projection or requested matrix no longer matches, the selected matrix changes before return, or another render invalidates pixel attribution. Two repeated captures verified the selected matrix, projection, exact restoration, readback, and detach. + +This verifies repeatable camera handling, not deterministic whole-scene pixels. The helper has no setter for FOV, near/far planes, or fog, and does not freeze simulation state; `atmosphere_seconds` remains an observed phase sample rather than a restored simulation clock. If the callsite probe cannot verify a mission path, `mission_path` remains `null` and `mission_identity` remains `unknown`. + +For a second capture, reuse the still-running PID only after the first run detached successfully, and choose a new output basename. Reuse the same validated camera input if comparing the same pose. The helper rejects any existing JSON, PNG, or log with the chosen basename and never overwrites those files. After a successful one-shot capture it detaches and leaves the scratch game running. If recovery requires termination, it stops only the exact path/tick/hash-verified scratch process. + +To compare a viewer frame with a native capture, use the same phase JSON for the camera and the PNG conversion. When `atmosphere_seconds` is present, the viewer uses it unless `--atmosphere-seconds` is given, and holds the atmosphere schedule at that sample for the fixed-camera readback. Choose fresh `viewer.bin` and `viewer.png` paths because the viewer writes the raw file and conversion refuses to overwrite an existing PNG. + +```powershell +$cameraJson = 'target\native-frame-capture\frame.json' +$viewerRaw = 'target\native-frame-capture\viewer.bin' +$viewerPng = 'target\native-frame-capture\viewer.png' + +cargo build --release -p fparkan-game +if ($LASTEXITCODE -ne 0) { throw 'Viewer build failed; inspect cargo output.' } +& '.\tools\native-frame-capture\convert-viewer-readback.ps1' -SelfTest +& 'target\release\fparkan-game.exe' ` + --root 'target\shadow-probe\Parkan - Iron Strategy' ` + --mission 'MISSIONS\Autodemo.00\data.tma' ` + --legacy-camera-capture $cameraJson ` + --frames 3 ` + --readback-out $viewerRaw +if ($LASTEXITCODE -ne 0) { throw 'Viewer readback failed; inspect its output before converting any raw file.' } +& '.\tools\native-frame-capture\convert-viewer-readback.ps1' ` + -InputRaw $viewerRaw ` + -CameraJson $cameraJson ` + -VkFormat 37 ` + -OutputPng $viewerPng +``` + +The converter derives raw image dimensions from the `viewport` rectangle. If +`pixel_capture` is present, its width and height describe the full native PNG; +the viewport must fit within those bounds, but can be a smaller crop with a +nonzero origin. The viewer readback itself contains only the viewport extent. +The converter checks the exact raw byte length (up to 64 MiB), refuses outputs +outside `target` or existing PNG paths, and accepts VkFormat `37`/`43` for RGBA +or `44`/`50` for BGRA. Pass the viewer log's `readback_format` value; `37` is +the verified value for the run above, not a universal surface format. The +converter uses the installed `System.Drawing` PNG encoder and adds no package +dependency. diff --git a/tools/native-frame-capture/convert-viewer-readback.ps1 b/tools/native-frame-capture/convert-viewer-readback.ps1 new file mode 100644 index 0000000..3bafe14 --- /dev/null +++ b/tools/native-frame-capture/convert-viewer-readback.ps1 @@ -0,0 +1,346 @@ +[CmdletBinding(DefaultParameterSetName = 'Convert')] +param( + [Parameter(Mandatory = $true, ParameterSetName = 'Convert')] + [string]$InputRaw, + + [Parameter(Mandatory = $true, ParameterSetName = 'Convert')] + [string]$CameraJson, + + [Parameter(Mandatory = $true, ParameterSetName = 'Convert')] + [ValidateSet('37', '43', '44', '50')] + [int]$VkFormat, + + [Parameter(Mandatory = $true, ParameterSetName = 'Convert')] + [string]$OutputPng, + + [Parameter(Mandatory = $true, ParameterSetName = 'SelfTest')] + [switch]$SelfTest +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing + +function Assert-ReadbackByteLength { + param( + [long]$ActualLength, + [int]$Width, + [int]$Height + ) + + $maximumLength = [long]64 * 1024 * 1024 + if ($Width -le 0 -or $Height -le 0) { + throw [System.IO.InvalidDataException]::new('Readback dimensions must be positive.') + } + $expectedLength = [long]$Width * [long]$Height * 4 + if ($expectedLength -gt $maximumLength) { + throw [System.IO.InvalidDataException]::new("Readback exceeds the 64 MiB limit: $expectedLength bytes.") + } + if ($ActualLength -ne $expectedLength) { + throw [System.IO.InvalidDataException]::new( + "Readback must contain exactly width * height * 4 bytes; got $ActualLength for ${Width}x${Height} (expected $expectedLength)." + ) + } +} + +function ConvertTo-ViewportCoordinate { + param( + [object]$Value, + [string]$Name + ) + + try { + $number = [System.Convert]::ToDecimal($Value, [System.Globalization.CultureInfo]::InvariantCulture) + } + catch { + throw "Camera JSON viewport $Name must be an integer." + } + if ($number -ne [decimal]::Truncate($number) -or + $number -lt [int]::MinValue -or $number -gt [int]::MaxValue) { + throw "Camera JSON viewport $Name must be an in-range 32-bit integer." + } + return [int]$number +} + +function Convert-ReadbackToBgra { + param( + [byte[]]$Source, + [int]$Format, + [int]$Width, + [int]$Height + ) + + Assert-ReadbackByteLength -ActualLength $Source.Length -Width $Width -Height $Height + + # VK_FORMAT_R8G8B8A8_* (37, 43) needs R/B swapped for System.Drawing's + # little-endian Format32bppArgb storage. VK_FORMAT_B8G8R8A8_* (44, 50) + # already has the byte order expected by that bitmap format. + if ($Format -eq 44 -or $Format -eq 50) { + return ,$Source + } + + $converted = New-Object byte[] $Source.Length + for ($offset = 0; $offset -lt $Source.Length; $offset += 4) { + $converted[$offset] = $Source[$offset + 2] + $converted[$offset + 1] = $Source[$offset + 1] + $converted[$offset + 2] = $Source[$offset] + $converted[$offset + 3] = $Source[$offset + 3] + } + + return ,$converted +} + +function Assert-ViewportWithinCapture { + param( + [int]$Left, + [int]$Top, + [int]$Right, + [int]$Bottom, + [int]$CaptureWidth, + [int]$CaptureHeight + ) + + if ($CaptureWidth -le 0 -or $CaptureHeight -le 0) { + throw [System.IO.InvalidDataException]::new('Camera JSON pixel_capture dimensions must be positive.') + } + if ($Left -lt 0 -or $Top -lt 0 -or $Right -gt $CaptureWidth -or $Bottom -gt $CaptureHeight) { + throw [System.IO.InvalidDataException]::new('Camera JSON viewport must fit within the full pixel_capture image bounds.') + } +} + +function Save-BgraPngToStream { + param( + [byte[]]$Pixels, + [int]$Width, + [int]$Height, + [System.IO.Stream]$Stream + ) + + $bitmap = New-Object System.Drawing.Bitmap( + $Width, + $Height, + [System.Drawing.Imaging.PixelFormat]::Format32bppArgb + ) + $rectangle = New-Object System.Drawing.Rectangle(0, 0, $Width, $Height) + $bitmapData = $null + try { + $bitmapData = $bitmap.LockBits( + $rectangle, + [System.Drawing.Imaging.ImageLockMode]::WriteOnly, + [System.Drawing.Imaging.PixelFormat]::Format32bppArgb + ) + + $rowBytes = $Width * 4 + if ([Math]::Abs($bitmapData.Stride) -lt $rowBytes) { + throw 'Bitmap stride is shorter than a pixel row.' + } + + for ($row = 0; $row -lt $Height; $row++) { + $destination = [IntPtr]::Add($bitmapData.Scan0, $row * $bitmapData.Stride) + [System.Runtime.InteropServices.Marshal]::Copy( + $Pixels, + $row * $rowBytes, + $destination, + $rowBytes + ) + } + + $bitmap.UnlockBits($bitmapData) + $bitmapData = $null + $bitmap.Save($Stream, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + if ($null -ne $bitmapData) { + $bitmap.UnlockBits($bitmapData) + } + $bitmap.Dispose() + } +} + +function Get-CameraExtentFromObject { + param([object]$Camera) + + $camera = $Camera + if ($camera.schema -ne 'fparkan-legacy-camera-v1' -or $null -eq $camera.viewport -or $camera.viewport.Count -ne 4) { + throw 'Camera JSON must be fparkan-legacy-camera-v1 and contain a four-value viewport.' + } + + $left = ConvertTo-ViewportCoordinate -Value $camera.viewport[0] -Name 'left' + $top = ConvertTo-ViewportCoordinate -Value $camera.viewport[1] -Name 'top' + $right = ConvertTo-ViewportCoordinate -Value $camera.viewport[2] -Name 'right' + $bottom = ConvertTo-ViewportCoordinate -Value $camera.viewport[3] -Name 'bottom' + $widthLong = [long]$right - [long]$left + $heightLong = [long]$bottom - [long]$top + if ($widthLong -gt [int]::MaxValue -or $heightLong -gt [int]::MaxValue) { + throw 'Camera JSON viewport extent exceeds 32-bit dimensions.' + } + $width = [int]$widthLong + $height = [int]$heightLong + if ($width -le 0 -or $height -le 0) { + throw 'Camera JSON viewport has an empty or inverted extent.' + } + + $expectedBytes = [long]$width * [long]$height * 4 + if ($expectedBytes -gt ([long]64 * 1024 * 1024)) { + throw "Camera JSON viewport exceeds the 64 MiB readback limit: $expectedBytes bytes." + } + if ($null -ne $camera.pixel_capture) { + $captureWidth = ConvertTo-ViewportCoordinate -Value $camera.pixel_capture.width -Name 'pixel_capture.width' + $captureHeight = ConvertTo-ViewportCoordinate -Value $camera.pixel_capture.height -Name 'pixel_capture.height' + Assert-ViewportWithinCapture -Left $left -Top $top -Right $right -Bottom $bottom -CaptureWidth $captureWidth -CaptureHeight $captureHeight + } + + return [pscustomobject]@{ Width = $width; Height = $height } +} + +function Get-CameraExtent { + param([string]$Path) + + $camera = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + return Get-CameraExtentFromObject -Camera $camera +} + +function Invoke-SelfTest { + $rgba = [byte[]]@( + 255, 0, 0, 255, 0, 255, 0, 255, + 0, 0, 255, 255, 255, 255, 255, 255 + ) + $expectedBgra = [byte[]]@( + 0, 0, 255, 255, 0, 255, 0, 255, + 255, 0, 0, 255, 255, 255, 255, 255 + ) + + $rgbaResult = Convert-ReadbackToBgra -Source $rgba -Format 37 -Width 2 -Height 2 + $bgraResult = Convert-ReadbackToBgra -Source $expectedBgra -Format 44 -Width 2 -Height 2 + for ($index = 0; $index -lt $expectedBgra.Length; $index++) { + if ($rgbaResult[$index] -ne $expectedBgra[$index] -or $bgraResult[$index] -ne $expectedBgra[$index]) { + throw 'Self-check failed: RGBA/BGRA channel mapping.' + } + } + + $mismatchRejected = $false + try { + Assert-ReadbackByteLength -ActualLength 15 -Width 2 -Height 2 + } + catch [System.IO.InvalidDataException] { + $mismatchRejected = $true + } + if (-not $mismatchRejected) { + throw 'Self-check failed: wrong byte length was accepted.' + } + + Assert-ReadbackByteLength -ActualLength ([long]64 * 1024 * 1024) -Width 4096 -Height 4096 + $oversizeRejected = $false + try { + Assert-ReadbackByteLength -ActualLength ([long]64 * 1024 * 1024) -Width 4097 -Height 4096 + } + catch [System.IO.InvalidDataException] { + $oversizeRejected = $true + } + if (-not $oversizeRejected) { + throw 'Self-check failed: readback over the 64 MiB boundary was accepted.' + } + + foreach ($invalidCoordinate in @([double]1.5, [long]2147483648)) { + $coordinateRejected = $false + try { + ConvertTo-ViewportCoordinate -Value $invalidCoordinate -Name 'self-check' + } + catch { + $coordinateRejected = $true + } + if (-not $coordinateRejected) { + throw 'Self-check failed: fractional or out-of-range viewport coordinate was accepted.' + } + } + + # A crop can be smaller than the full native PNG and start away from (0,0). + # Raw viewer readback is the crop only, so its dimensions stay 4x4 here. + $cropCamera = [pscustomobject]@{ + schema = 'fparkan-legacy-camera-v1' + viewport = @(4, 6, 8, 10) + pixel_capture = [pscustomobject]@{ width = 12; height = 16 } + } + $cropExtent = Get-CameraExtentFromObject -Camera $cropCamera + if ($cropExtent.Width -ne 4 -or $cropExtent.Height -ne 4) { + throw 'Self-check failed: cropped readback extent was not derived from the viewport rectangle.' + } + Assert-ReadbackByteLength -ActualLength 64 -Width 4 -Height 4 + $cropOutsideCaptureRejected = $false + try { + $cropCamera.viewport = @(4, 6, 13, 10) + Get-CameraExtentFromObject -Camera $cropCamera | Out-Null + } + catch [System.IO.InvalidDataException] { + $cropOutsideCaptureRejected = $true + } + if (-not $cropOutsideCaptureRejected) { + throw 'Self-check failed: viewport extending beyond the full pixel_capture image was accepted.' + } + + $stream = New-Object System.IO.MemoryStream + $decoded = $null + try { + Save-BgraPngToStream -Pixels $expectedBgra -Width 2 -Height 2 -Stream $stream + $stream.Position = 0 + $decoded = [System.Drawing.Bitmap]::new($stream) + $expectedColors = @( + [System.Drawing.Color]::Red, + [System.Drawing.Color]::FromArgb(255, 0, 255, 0), + [System.Drawing.Color]::Blue, + [System.Drawing.Color]::White + ) + $points = @(@(0, 0), @(1, 0), @(0, 1), @(1, 1)) + for ($index = 0; $index -lt $points.Count; $index++) { + $point = $points[$index] + $actualColor = $decoded.GetPixel($point[0], $point[1]) + if ($actualColor.ToArgb() -ne $expectedColors[$index].ToArgb()) { + throw "Self-check failed: PNG pixel $index expected $($expectedColors[$index]) but read $actualColor." + } + } + } + finally { + if ($null -ne $decoded) { $decoded.Dispose() } + $stream.Dispose() + } + + 'Self-check passed (2x2 RGBA/BGRA PNG, byte-length/64 MiB limits, viewport-coordinate bounds, and cropped pixel_capture bounds).' +} + +if ($SelfTest) { + Invoke-SelfTest + exit 0 +} + +$repositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$targetRoot = [System.IO.Path]::GetFullPath((Join-Path $repositoryRoot 'target')) +$rawPath = (Resolve-Path -LiteralPath $InputRaw).Path +$cameraPath = (Resolve-Path -LiteralPath $CameraJson).Path + +if (-not (Test-Path -LiteralPath $rawPath -PathType Leaf)) { throw "Raw readback is not a file: $rawPath" } +if (-not (Test-Path -LiteralPath $cameraPath -PathType Leaf)) { throw "Camera JSON is not a file: $cameraPath" } + +$outputPath = [System.IO.Path]::GetFullPath($OutputPng) +$targetPrefix = $targetRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar +if (-not $outputPath.StartsWith($targetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Output must be beneath the repository target directory: $targetRoot" +} +if ([System.IO.Path]::GetExtension($outputPath) -ine '.png') { throw 'Output filename must end in .png.' } +if (Test-Path -LiteralPath $outputPath) { throw "Refusing to overwrite existing output: $outputPath" } +$outputDirectory = [System.IO.Path]::GetDirectoryName($outputPath) +if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) { + throw "Output directory does not exist: $outputDirectory" +} + +$extent = Get-CameraExtent -Path $cameraPath +$rawInfo = Get-Item -LiteralPath $rawPath +Assert-ReadbackByteLength -ActualLength $rawInfo.Length -Width $extent.Width -Height $extent.Height +$source = [System.IO.File]::ReadAllBytes($rawPath) +$pixels = Convert-ReadbackToBgra -Source $source -Format $VkFormat -Width $extent.Width -Height $extent.Height +$stream = [System.IO.File]::Open($outputPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) +try { + Save-BgraPngToStream -Pixels $pixels -Width $extent.Width -Height $extent.Height -Stream $stream +} +finally { + $stream.Dispose() +} +"Wrote $($extent.Width)x$($extent.Height) PNG to $outputPath (VkFormat $VkFormat)."