diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index f7c72b6..e41f0f9 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -235,6 +235,7 @@ impl VulkanSmokeRenderer { frame_sync: Vec::new(), images_in_flight: Vec::new(), current_frame: 0, + last_readback_image_index: None, depth_request: create_info.render_request.depth, pending_extent: None, swapchain_recreate_count: 0, @@ -519,6 +520,7 @@ impl VulkanSmokeRenderer { })?; if !self.resources_ref()?.readback_buffers.is_empty() { self.report.readback_copy_count = self.report.readback_copy_count.saturating_add(1); + self.last_readback_image_index = Some(image_index_usize); } let present_wait = [render_finished]; @@ -621,6 +623,7 @@ impl VulkanSmokeRenderer { let resources = resources.commit(); self.images_in_flight = vec![vk::Fence::null(); resources.image_views.len()]; self.frame_sync = frame_sync; + self.last_readback_image_index = None; self.report.swapchain_extent = swapchain_extent; self.report.swapchain_image_count = swapchain_image_count; self.report.swapchain_image_format = self.swapchain_ref()?.report.plan.format.format; @@ -941,13 +944,19 @@ impl VulkanSmokeRenderer { if resources.readback_buffers.is_empty() { return Ok(None); } - let mut artifact = - Vec::with_capacity(byte_len.saturating_mul(resources.readback_buffers.len())); - for buffer in &resources.readback_buffers { - let bytes = readback_buffer_bytes(device, buffer, byte_len)?; - artifact.extend_from_slice(&bytes); - } - Ok(Some(artifact)) + let Some(image_index) = completed_readback_buffer_index( + self.last_readback_image_index, + resources.readback_buffers.len(), + )? + else { + return Ok(None); + }; + let buffer = resources.readback_buffers.get(image_index).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "last readback image index", + }, + )?; + Ok(Some(readback_buffer_bytes(device, buffer, byte_len)?)) } fn teardown(&mut self) { @@ -968,6 +977,19 @@ impl VulkanSmokeRenderer { } } +fn completed_readback_buffer_index( + last_image_index: Option, + buffer_count: usize, +) -> Result, VulkanSmokeRendererError> { + match last_image_index { + None => Ok(None), + Some(index) if index < buffer_count => Ok(Some(index)), + Some(_) => Err(VulkanSmokeRendererError::InvariantViolation { + context: "last readback image index", + }), + } +} + fn matrix_bytes(matrix: [f32; 16]) -> [u8; 64] { let mut bytes = [0; 64]; for (index, value) in matrix.into_iter().enumerate() { @@ -991,8 +1013,8 @@ impl Drop for VulkanSmokeRenderer { #[cfg(test)] mod tests { use super::{ - take_runtime_children_with_validation_snapshot, take_runtime_owners_in_dependency_order, - RollbackOnDrop, + completed_readback_buffer_index, take_runtime_children_with_validation_snapshot, + take_runtime_owners_in_dependency_order, RollbackOnDrop, }; use std::cell::RefCell; use std::rc::Rc; @@ -1075,6 +1097,13 @@ mod tests { ); } + #[test] + fn completed_readback_selects_only_the_last_submitted_image() { + assert_eq!(completed_readback_buffer_index(None, 2), Ok(None)); + assert_eq!(completed_readback_buffer_index(Some(1), 2), Ok(Some(1))); + assert!(completed_readback_buffer_index(Some(2), 2).is_err()); + } + #[test] fn runtime_owners_drop_remaining_children_after_partial_init_failures() { let cases = [ diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index 1db0996..4686eaf 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -603,7 +603,7 @@ pub struct VulkanValidationReport { pub vuids: Vec, } -/// CPU-owned copy of the final completed swapchain readback buffers. +/// CPU-owned copy of the final completed swapchain image readback. #[derive(Clone, Debug, Eq, PartialEq)] pub struct VulkanReadbackArtifact { /// Vulkan swapchain format as a raw enum value. @@ -612,7 +612,8 @@ pub struct VulkanReadbackArtifact { pub width: u32, /// Height of each image in pixels. pub height: u32, - /// Concatenated raw four-byte-per-pixel images in swapchain order. + /// One raw four-byte-per-pixel image: the last successfully submitted + /// swapchain image before synchronized teardown. pub bytes: Vec, } @@ -735,6 +736,10 @@ pub struct VulkanSmokeRenderer { pub(super) frame_sync: Vec, pub(super) images_in_flight: Vec, pub(super) current_frame: usize, + /// Last swapchain image whose color attachment was copied after a + /// successful graphics submission. Reset whenever swapchain resources are + /// replaced so a teardown never reads a buffer from an old swapchain. + pub(super) last_readback_image_index: Option, pub(super) depth_request: fparkan_platform::DepthStencilSupport, pub(super) pending_extent: Option<(u32, u32)>, pub(super) swapchain_recreate_count: u32, diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 520b137..a30b9df 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -877,6 +877,14 @@ bytes с зафиксированным reference capture. ### First synchronized Vulkan pixel-readback artifact +The current artifact contract is one final swapchain image, not a +concatenation of every swapchain allocation. After the final successful +graphics submission, the renderer retains that image index; synchronized +teardown reads only its matching buffer. The raw payload is therefore exactly +`width * height * 4` bytes for the current format-50 path. Historical +multi-image byte counts below describe the superseded concatenation behavior +and are not comparable to this final-image contract. + Stage 3 static viewer теперь выполняет фактический readback для surface с `TRANSFER_SRC`: на каждый swapchain image создаётся host-visible coherent `TRANSFER_DST` buffer. После render pass command buffer переводит image из @@ -895,8 +903,11 @@ static viewer. Он ещё не захватывает original DirectDraw frame fixed original camera и не сравнивает два изображения, поэтому pixel-parity acceptance остаётся blocked. -Smoke также сохраняет raw artifact рядом с JSON: `.readback-vkformat-.raw`. -Это concatenated current-swapchain images in Vulkan order, каждый с dimensions +The following paragraph records the superseded multi-image export only as +historical bootstrap evidence; it is not the current artifact contract. + +Smoke также сохранял raw artifact рядом с JSON: `.readback-vkformat-.raw`. +Это были concatenated current-swapchain images in Vulkan order, каждый с dimensions из JSON и четырьмя bytes per pixel; format намеренно указан в имени файла, потому что bytes не перекодируются; JSON содержит actual raw enum. GOG selected `50` and produced a 4,147,200-byte file for two 960x540 images. Артефакт остаётся локальным output и не попадает в Git. @@ -1536,13 +1547,12 @@ not yet pixel parity, because the static bridge still lacks the full runtime camera-selection timing, terrain composition, culling, lighting, UI and FX. For visual regression work, `fparkan-game --backend static-vulkan` also accepts -`--readback-out `. It writes the final synchronized Vulkan readback bytes -only after the renderer has completed its normal teardown evidence; the JSON -report records the raw Vulkan format, byte count, hash, and requested path. A -three-frame diagnostic AutoDemo run wrote 7,372,800 bytes at format `50` -(`VK_FORMAT_B8G8R8A8_UNORM`) with the same frame hash reported by the renderer. -The artifact is a Vulkan-side comparison input, not an asserted original-frame -image. +`--readback-out `. It writes the final synchronized Vulkan swapchain +image only after the renderer has completed its normal teardown evidence; the +JSON report records its raw Vulkan format, byte count, hash, and requested +path. At 1280x720, format `50` (`VK_FORMAT_B8G8R8A8_UNORM`) produces exactly +3,686,400 bytes. The artifact is a Vulkan-side comparison input, not an +asserted original-frame image. The first bounded launch showed that repeated fingerprinting, rather than Vulkan initialization, was the load-path bottleneck: each new MAT0/TEXM request