From 6e6496eebf4057ceb4a09869b0df21b1f0260e81 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 07:29:31 +0400 Subject: [PATCH] feat(render): upload original TEXM to Vulkan --- adapters/fparkan-render-vulkan/src/ffi.rs | 9 +- .../src/ffi/resources.rs | 273 ++++++++++++++++++ .../fparkan-render-vulkan/src/ffi/smoke.rs | 42 ++- .../src/ffi/smoke_types.rs | 49 +++- apps/fparkan-vulkan-smoke/src/main.rs | 140 ++++++++- crates/fparkan-inspection/src/lib.rs | 31 ++ docs/rendering/renderer_truth_table.md | 4 +- fixtures/acceptance/coverage.tsv | 2 +- 8 files changed, 529 insertions(+), 21 deletions(-) diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs index 9433759..2ee3168 100644 --- a/adapters/fparkan-render-vulkan/src/ffi.rs +++ b/adapters/fparkan-render-vulkan/src/ffi.rs @@ -54,8 +54,9 @@ pub use self::instance::{ use self::instance::{cstring_vec, ensure_instance_extensions_available}; use self::resources::{ color_subresource_range, create_command_pool, create_frame_sync, - create_static_mesh_index_buffer, create_static_mesh_vertex_buffer, destroy_allocated_buffer, - VulkanAllocatedBuffer, VulkanFrameSync, + create_static_mesh_index_buffer, create_static_mesh_vertex_buffer, create_static_texture_image, + destroy_allocated_buffer, destroy_allocated_image, VulkanAllocatedBuffer, VulkanAllocatedImage, + VulkanFrameSync, }; pub use self::runtime::{ create_vulkan_logical_device_probe, create_vulkan_logical_device_probe_for_request, @@ -64,8 +65,8 @@ pub use self::runtime::{ pub use self::smoke_types::{ VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererError, - VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanStaticMesh, VulkanStaticVertex, - VulkanValidationReport, + VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanStaticMesh, VulkanStaticTexture, + VulkanStaticVertex, VulkanValidationReport, }; #[cfg(test)] use self::surface::extension_name; diff --git a/adapters/fparkan-render-vulkan/src/ffi/resources.rs b/adapters/fparkan-render-vulkan/src/ffi/resources.rs index e5d725b..f3726f5 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/resources.rs @@ -4,6 +4,7 @@ use ash::vk; use super::{ VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanStaticMesh, + VulkanStaticTexture, }; pub(super) struct VulkanAllocatedBuffer { @@ -11,6 +12,12 @@ pub(super) struct VulkanAllocatedBuffer { pub(super) memory: vk::DeviceMemory, } +pub(super) struct VulkanAllocatedImage { + pub(super) image: vk::Image, + pub(super) memory: vk::DeviceMemory, + pub(super) view: vk::ImageView, +} + pub(super) struct VulkanFrameSync { pub(super) image_available: vk::Semaphore, pub(super) render_finished: vk::Semaphore, @@ -70,6 +77,272 @@ pub(super) fn create_static_mesh_index_buffer( ) } +#[allow(clippy::too_many_lines)] +pub(super) fn create_static_texture_image( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + command_pool: vk::CommandPool, + texture: &VulkanStaticTexture, +) -> Result { + let staging = create_host_visible_buffer( + instance, + device, + &texture.rgba8, + vk::BufferUsageFlags::TRANSFER_SRC, + "static texture staging buffer", + )?; + let image_info = vk::ImageCreateInfo::default() + .image_type(vk::ImageType::TYPE_2D) + .format(vk::Format::R8G8B8A8_UNORM) + .extent(vk::Extent3D { + width: texture.width, + height: texture.height, + depth: 1, + }) + .mip_levels(1) + .array_layers(1) + .samples(vk::SampleCountFlags::TYPE_1) + .tiling(vk::ImageTiling::OPTIMAL) + .usage(vk::ImageUsageFlags::TRANSFER_DST | vk::ImageUsageFlags::SAMPLED) + .sharing_mode(vk::SharingMode::EXCLUSIVE) + .initial_layout(vk::ImageLayout::UNDEFINED); + // SAFETY: The create info only contains stack-owned values and a validated non-zero extent. + let image = unsafe { device.device().create_image(&image_info, None) }.map_err(|result| { + destroy_allocated_buffer(device, &staging); + VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateImage(static texture)", + result, + } + })?; + // SAFETY: The image belongs to this device and is queried immediately after creation. + let requirements = unsafe { device.device().get_image_memory_requirements(image) }; + let Some(memory_type_index) = find_memory_type( + instance, + device.physical_device(), + requirements.memory_type_bits, + vk::MemoryPropertyFlags::DEVICE_LOCAL, + ) else { + // SAFETY: Both resources were created on this device and are being rolled back once. + unsafe { device.device().destroy_image(image, None) }; + destroy_allocated_buffer(device, &staging); + return Err(VulkanSmokeRendererError::MissingMemoryType { + context: "static texture image", + }); + }; + let allocate_info = vk::MemoryAllocateInfo::default() + .allocation_size(requirements.size) + .memory_type_index(memory_type_index); + // SAFETY: The allocation matches the image memory requirements queried above. + let memory = { + // SAFETY: The allocation matches the image memory requirements queried above. + unsafe { device.device().allocate_memory(&allocate_info, None) } + } + .map_err(|result| { + // SAFETY: Both resources were created on this device and are being rolled back once. + unsafe { device.device().destroy_image(image, None) }; + destroy_allocated_buffer(device, &staging); + VulkanSmokeRendererError::VulkanOperation { + context: "vkAllocateMemory(static texture)", + result, + } + })?; + // SAFETY: The allocation matches this image's queried requirements. + if let Err(result) = unsafe { device.device().bind_image_memory(image, memory, 0) } { + // SAFETY: Both resources were created on this device and are being rolled back once. + unsafe { + device.device().destroy_image(image, None); + device.device().free_memory(memory, None); + }; + destroy_allocated_buffer(device, &staging); + return Err(VulkanSmokeRendererError::VulkanOperation { + context: "vkBindImageMemory(static texture)", + result, + }); + } + if let Err(error) = upload_static_texture( + device, + command_pool, + staging.buffer, + image, + texture.width, + texture.height, + ) { + // SAFETY: Both resources were created on this device and are being rolled back once. + unsafe { + device.device().destroy_image(image, None); + device.device().free_memory(memory, None); + }; + destroy_allocated_buffer(device, &staging); + return Err(error); + } + destroy_allocated_buffer(device, &staging); + let view_info = vk::ImageViewCreateInfo::default() + .image(image) + .view_type(vk::ImageViewType::TYPE_2D) + .format(vk::Format::R8G8B8A8_UNORM) + .subresource_range(color_subresource_range()); + // SAFETY: The image is live, initialized and has the stated color subresource. + let view = { + // SAFETY: The image is live, initialized and has the stated color subresource. + unsafe { device.device().create_image_view(&view_info, None) } + } + .map_err(|result| { + // SAFETY: Both resources were created on this device and are being rolled back once. + unsafe { + device.device().destroy_image(image, None); + device.device().free_memory(memory, None); + }; + VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateImageView(static texture)", + result, + } + })?; + Ok(VulkanAllocatedImage { + image, + memory, + view, + }) +} + +#[allow(clippy::too_many_lines)] +fn upload_static_texture( + device: &VulkanLogicalDeviceProbe, + command_pool: vk::CommandPool, + staging_buffer: vk::Buffer, + image: vk::Image, + width: u32, + height: u32, +) -> Result<(), VulkanSmokeRendererError> { + let allocate_info = vk::CommandBufferAllocateInfo::default() + .command_pool(command_pool) + .level(vk::CommandBufferLevel::PRIMARY) + .command_buffer_count(1); + // SAFETY: The command pool is live and owned by the current logical device. + let command_buffer = unsafe { device.device().allocate_command_buffers(&allocate_info) } + .map_err(|result| VulkanSmokeRendererError::VulkanOperation { + context: "vkAllocateCommandBuffers(texture upload)", + result, + })?[0]; + let begin = + vk::CommandBufferBeginInfo::default().flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT); + // SAFETY: The command buffer is freshly allocated from the current pool. + if let Err(result) = unsafe { device.device().begin_command_buffer(command_buffer, &begin) } { + // SAFETY: The command buffer belongs to this pool and is released on failure. + unsafe { + device + .device() + .free_command_buffers(command_pool, &[command_buffer]); + }; + return Err(VulkanSmokeRendererError::VulkanOperation { + context: "vkBeginCommandBuffer(texture upload)", + result, + }); + } + let range = color_subresource_range(); + let to_transfer = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::UNDEFINED) + .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) + .src_access_mask(vk::AccessFlags::empty()) + .dst_access_mask(vk::AccessFlags::TRANSFER_WRITE) + .image(image) + .subresource_range(range); + let region = vk::BufferImageCopy::default() + .image_subresource( + vk::ImageSubresourceLayers::default() + .aspect_mask(vk::ImageAspectFlags::COLOR) + .mip_level(0) + .base_array_layer(0) + .layer_count(1), + ) + .image_extent(vk::Extent3D { + width, + height, + depth: 1, + }); + let to_sampled = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) + .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) + .src_access_mask(vk::AccessFlags::TRANSFER_WRITE) + .dst_access_mask(vk::AccessFlags::SHADER_READ) + .image(image) + .subresource_range(range); + // SAFETY: The commands operate on live, exclusively owned resources and the stated color subresource. + unsafe { + device.device().cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::TOP_OF_PIPE, + vk::PipelineStageFlags::TRANSFER, + vk::DependencyFlags::empty(), + &[], + &[], + &[to_transfer], + ); + device.device().cmd_copy_buffer_to_image( + command_buffer, + staging_buffer, + image, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + &[region], + ); + device.device().cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::TRANSFER, + vk::PipelineStageFlags::FRAGMENT_SHADER, + vk::DependencyFlags::empty(), + &[], + &[], + &[to_sampled], + ); + } + // SAFETY: Command recording is complete on the current command buffer. + if let Err(result) = unsafe { device.device().end_command_buffer(command_buffer) } { + // SAFETY: The command buffer belongs to this pool and is released on failure. + unsafe { + device + .device() + .free_command_buffers(command_pool, &[command_buffer]); + }; + return Err(VulkanSmokeRendererError::VulkanOperation { + context: "vkEndCommandBuffer(texture upload)", + result, + }); + } + let command_buffers = [command_buffer]; + let submit = [vk::SubmitInfo::default().command_buffers(&command_buffers)]; + // SAFETY: The graphics queue and submitted command buffer are live for the duration of the wait. + let submit_result = unsafe { + device + .device() + .queue_submit(device.graphics_queue(), &submit, vk::Fence::null()) + }; + let result = submit_result.and_then(|()| { + // SAFETY: The graphics queue remains live until the synchronous idle wait completes. + unsafe { device.device().queue_wait_idle(device.graphics_queue()) } + }); + // SAFETY: Submission completed or failed synchronously and the command buffer is no longer needed. + unsafe { + device + .device() + .free_command_buffers(command_pool, &[command_buffer]); + }; + result.map_err(|result| VulkanSmokeRendererError::VulkanOperation { + context: "vkQueueSubmit/WaitIdle(texture upload)", + result, + }) +} + +pub(super) fn destroy_allocated_image( + device: &VulkanLogicalDeviceProbe, + image: &VulkanAllocatedImage, +) { + // SAFETY: The image, view and allocation belong to this device and are destroyed once after idle. + unsafe { + device.device().destroy_image_view(image.view, None); + device.device().destroy_image(image.image, None); + device.device().free_memory(image.memory, None); + }; +} + fn create_host_visible_buffer( instance: &VulkanInstanceProbe, device: &VulkanLogicalDeviceProbe, diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index 06f1182..a57394e 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -4,15 +4,15 @@ use ash::vk; use super::{ create_command_pool, create_frame_sync, create_static_mesh_index_buffer, - create_static_mesh_vertex_buffer, create_swapchain_resources, create_validation_messenger, - create_vulkan_instance_probe, create_vulkan_logical_device_probe_for_request, - create_vulkan_surface_probe, create_vulkan_swapchain_probe_for_extent, - destroy_allocated_buffer, destroy_swapchain_resources, plan_vulkan_surface, - VulkanAllocatedBuffer, VulkanInstanceConfig, VulkanInstanceProbe, VulkanLogicalDeviceProbe, - VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, - VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport, - VulkanSurfaceProbe, VulkanSwapchainProbe, VulkanSwapchainResources, VulkanValidationMessenger, - VulkanValidationReport, + create_static_mesh_vertex_buffer, create_static_texture_image, create_swapchain_resources, + create_validation_messenger, create_vulkan_instance_probe, + create_vulkan_logical_device_probe_for_request, create_vulkan_surface_probe, + create_vulkan_swapchain_probe_for_extent, destroy_allocated_buffer, destroy_allocated_image, + destroy_swapchain_resources, plan_vulkan_surface, VulkanAllocatedBuffer, VulkanInstanceConfig, + VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, + VulkanSmokeRendererCreateInfo, VulkanSmokeRendererError, VulkanSmokeRendererReport, + VulkanSmokeShutdownReport, VulkanSurfaceProbe, VulkanSwapchainProbe, VulkanSwapchainResources, + VulkanValidationMessenger, VulkanValidationReport, }; use crate::policy::KHR_PORTABILITY_SUBSET_EXTENSION; use crate::shader_manifest::{triangle_shader_manifest, validate_shader_manifest}; @@ -109,6 +109,11 @@ impl VulkanSmokeRenderer { .mesh .validate() .map_err(|context| VulkanSmokeRendererError::InvalidStaticMesh { context })?; + if let Some(texture) = &create_info.texture { + texture + .validate() + .map_err(|context| VulkanSmokeRendererError::InvalidStaticTexture { context })?; + } let bootstrap_progress = create_info.bootstrap_progress.as_ref(); let shader_manifest = validate_shader_manifest(&triangle_shader_manifest()) .map_err(VulkanSmokeRendererError::ShaderManifest)?; @@ -176,6 +181,21 @@ impl VulkanSmokeRenderer { return Err(error); } }; + let texture = match create_info.texture.as_ref() { + None => None, + Some(texture) => { + match create_static_texture_image(&instance, &device, command_pool, texture) { + Ok(image) => Some(image), + Err(error) => { + // SAFETY: These resources belong to this live device and are rolled back before it drops. + unsafe { device.device().destroy_command_pool(command_pool, None) }; + destroy_allocated_buffer(&device, &index_buffer); + destroy_allocated_buffer(&device, &vertex_buffer); + return Err(error); + } + } + } + }; let mut renderer = Self { instance: Some(instance), validation, @@ -186,6 +206,7 @@ impl VulkanSmokeRenderer { swapchain_resources: None, vertex_buffer: Some(vertex_buffer), index_buffer: Some(index_buffer), + texture, index_count: u32::try_from(create_info.mesh.indices.len()).unwrap_or(u32::MAX), frame_sync: Vec::new(), images_in_flight: Vec::new(), @@ -640,6 +661,9 @@ impl VulkanSmokeRenderer { fn destroy_device_owned_resources(&mut self) { self.destroy_swapchain_resources(); if let Some(device) = self.device.as_ref() { + if let Some(texture) = self.texture.take() { + destroy_allocated_image(device, &texture); + } if let Some(buffer) = self.index_buffer.take() { // SAFETY: Buffer and memory belong to this device and are destroyed once after the device has been idled and frame work has been torn down. unsafe { diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index 31a2065..165633d 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -4,9 +4,9 @@ use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::Arc; use super::{ - VulkanAllocatedBuffer, VulkanFrameSync, VulkanInstanceError, VulkanInstanceProbe, - VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanSurfaceError, VulkanSurfaceProbe, - VulkanSwapchainProbe, VulkanSwapchainProbeError, VulkanSwapchainResources, + VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanFrameSync, VulkanInstanceError, + VulkanInstanceProbe, VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanSurfaceError, + VulkanSurfaceProbe, VulkanSwapchainProbe, VulkanSwapchainProbeError, VulkanSwapchainResources, VulkanValidationMessenger, }; use crate::shader_manifest::VulkanShaderManifestError; @@ -29,6 +29,8 @@ pub struct VulkanSmokeRendererCreateInfo { /// This initial bridge keeps positions in clip-space. MSH transforms, /// materials, and textures are deliberately higher-level Stage 3 work. pub mesh: VulkanStaticMesh, + /// Optional RGBA8 texture uploaded before the first live frame. + pub texture: Option, /// Optional shared bootstrap progress tracker for failure evidence. pub bootstrap_progress: Option>, } @@ -51,6 +53,38 @@ pub struct VulkanStaticMesh { pub indices: Vec, } +/// Decoded RGBA8 image accepted by the initial Vulkan texture upload path. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VulkanStaticTexture { + /// Image width in texels. + pub width: u32, + /// Image height in texels. + pub height: u32, + /// Row-major RGBA8 pixels. + pub rgba8: Vec, +} + +impl VulkanStaticTexture { + pub(super) fn validate(&self) -> Result<(), &'static str> { + let pixels = usize::try_from(self.width) + .ok() + .and_then(|width| { + usize::try_from(self.height) + .ok() + .and_then(|height| width.checked_mul(height)) + }) + .and_then(|pixels| pixels.checked_mul(4)); + match pixels { + None => Err("static texture dimensions overflow address space"), + Some(0) => Err("static texture has zero extent"), + Some(expected) if expected != self.rgba8.len() => { + Err("static texture rgba8 byte count does not match extent") + } + Some(_) => Ok(()), + } + } +} + impl VulkanStaticMesh { /// Returns the compatibility triangle used by the native Stage 0 smoke app. #[must_use] @@ -293,6 +327,11 @@ pub enum VulkanSmokeRendererError { /// Validation failure detail. context: &'static str, }, + /// The submitted static texture cannot be represented by this path. + InvalidStaticTexture { + /// Validation failure detail. + context: &'static str, + }, /// Internal smoke renderer state was unexpectedly absent. InvariantViolation { /// Missing state context. @@ -315,6 +354,9 @@ impl std::fmt::Display for VulkanSmokeRendererError { write!(f, "{context}: no compatible Vulkan memory type") } Self::InvalidStaticMesh { context } => write!(f, "invalid static mesh: {context}"), + Self::InvalidStaticTexture { context } => { + write!(f, "invalid static texture: {context}") + } Self::InvariantViolation { context } => { write!(f, "renderer invariant violated: {context}") } @@ -335,6 +377,7 @@ pub struct VulkanSmokeRenderer { pub(super) swapchain_resources: Option, pub(super) vertex_buffer: Option, pub(super) index_buffer: Option, + pub(super) texture: Option, pub(super) index_count: u32, pub(super) frame_sync: Vec, pub(super) images_in_flight: Vec, diff --git a/apps/fparkan-vulkan-smoke/src/main.rs b/apps/fparkan-vulkan-smoke/src/main.rs index 90c59c9..baa678b 100644 --- a/apps/fparkan-vulkan-smoke/src/main.rs +++ b/apps/fparkan-vulkan-smoke/src/main.rs @@ -16,7 +16,7 @@ use fparkan_platform_winit::{window_native_handles, WinitWindowPlan}; use fparkan_render_vulkan::{ project_msh_to_static_mesh, VulkanSmokeBootstrapProgress, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererReport, - VulkanSmokeShutdownReport, VulkanStaticMesh, VulkanValidationReport, + VulkanSmokeShutdownReport, VulkanStaticMesh, VulkanStaticTexture, VulkanValidationReport, }; use serde::Serialize; use std::path::PathBuf; @@ -56,6 +56,7 @@ fn main() { fn run(args: &[String]) -> Result { let options = SmokeOptions::parse(args)?; let mesh = options.load_mesh()?; + let texture = options.load_texture()?; remove_stale_output(&options)?; let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?; event_loop.set_control_flow(ControlFlow::Poll); @@ -66,7 +67,7 @@ fn run(args: &[String]) -> Result { Arc::clone(&completed), Arc::clone(&progress), ); - let mut app = SmokeApp::new(options, mesh, completed, progress); + let mut app = SmokeApp::new(options, mesh, texture, completed, progress); if let Err(err) = event_loop.run_app(&mut app) { app.error = Some(format!("winit event loop: {err}")); } @@ -112,6 +113,14 @@ struct SmokeOptions { resize_frame: u32, timeout_seconds: u64, mesh_input: MeshInput, + texture_input: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ResourceInput { + root: PathBuf, + archive: String, + name: String, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -148,6 +157,7 @@ impl MeshInput { } impl SmokeOptions { + #[allow(clippy::too_many_lines)] fn parse(args: &[String]) -> Result { let mut out = None; let mut frames = DEFAULT_TARGET_FRAMES; @@ -156,6 +166,9 @@ impl SmokeOptions { let mut model_root = None; let mut model_archive = None; let mut model_name = None; + let mut texture_root = None; + let mut texture_archive = None; + let mut texture_name = None; let mut iter = args.iter(); while let Some(arg) = iter.next() { match arg.as_str() { @@ -208,6 +221,27 @@ impl SmokeOptions { .ok_or_else(|| "--model-name requires a value".to_string())?, ); } + "--texture-root" => { + texture_root = Some( + iter.next() + .map(PathBuf::from) + .ok_or_else(|| "--texture-root requires a path".to_string())?, + ); + } + "--texture-archive" => { + texture_archive = Some( + iter.next() + .cloned() + .ok_or_else(|| "--texture-archive requires a value".to_string())?, + ); + } + "--texture-name" => { + texture_name = Some( + iter.next() + .cloned() + .ok_or_else(|| "--texture-name requires a value".to_string())?, + ); + } _ => return Err(format!("unknown native smoke option: {arg}")), } } @@ -234,12 +268,27 @@ impl SmokeOptions { ); } }; + let texture_input = match (texture_root, texture_archive, texture_name) { + (None, None, None) => None, + (Some(root), Some(archive), Some(name)) => Some(ResourceInput { + root, + archive, + name, + }), + _ => { + return Err( + "--texture-root, --texture-archive and --texture-name must be supplied together" + .to_string(), + ); + } + }; Ok(Self { out, frames, resize_frame, timeout_seconds, mesh_input, + texture_input, }) } @@ -256,11 +305,27 @@ impl SmokeOptions { } } } + + fn load_texture(&self) -> Result, String> { + self.texture_input.as_ref().map_or(Ok(None), |input| { + let image = fparkan_inspection::load_texture_mip0_rgba8_from_root( + &input.root, + &input.archive, + &input.name, + )?; + Ok(Some(VulkanStaticTexture { + width: image.width, + height: image.height, + rgba8: image.rgba8, + })) + }) + } } struct SmokeApp { options: SmokeOptions, mesh: VulkanStaticMesh, + texture: Option, completed: Arc, progress: Arc, window_id: Option, @@ -305,12 +370,14 @@ impl SmokeApp { fn new( options: SmokeOptions, mesh: VulkanStaticMesh, + texture: Option, completed: Arc, progress: Arc, ) -> Self { Self { options, mesh, + texture, completed, progress, window_id: None, @@ -407,6 +474,23 @@ impl SmokeApp { mesh_name: self.options.mesh_input.name(), mesh_vertex_count: self.mesh.vertices.len(), mesh_index_count: self.mesh.indices.len(), + texture_source: self + .options + .texture_input + .as_ref() + .map_or("none", |_| "original-texm"), + texture_archive: self + .options + .texture_input + .as_ref() + .map_or("", |input| input.archive.as_str()), + texture_name: self + .options + .texture_input + .as_ref() + .map_or("", |input| input.name.as_str()), + texture_width: self.texture.as_ref().map_or(0, |texture| texture.width), + texture_height: self.texture.as_ref().map_or(0, |texture| texture.height), shader_manifest_hash: renderer .as_ref() .map_or("", |snapshot| snapshot.report.shader_manifest_hash.as_str()), @@ -611,6 +695,20 @@ fn render_timeout_failure_report( mesh_name: options.mesh_input.name(), mesh_vertex_count: 0, mesh_index_count: 0, + texture_source: options + .texture_input + .as_ref() + .map_or("none", |_| "original-texm"), + texture_archive: options + .texture_input + .as_ref() + .map_or("", |input| input.archive.as_str()), + texture_name: options + .texture_input + .as_ref() + .map_or("", |input| input.name.as_str()), + texture_width: 0, + texture_height: 0, shader_manifest_hash: "", vulkan_loader_status: if bootstrap.loader_available { "available" @@ -706,6 +804,7 @@ impl ApplicationHandler for SmokeApp { render_request: RenderRequest::conservative(), enable_validation: true, mesh: self.mesh.clone(), + texture: self.texture.clone(), bootstrap_progress: Some(Arc::clone(&self.progress.bootstrap)), }) { Ok(renderer) => renderer, @@ -838,6 +937,11 @@ struct SmokeReport<'a> { mesh_name: &'a str, mesh_vertex_count: usize, mesh_index_count: usize, + texture_source: &'a str, + texture_archive: &'a str, + texture_name: &'a str, + texture_width: u32, + texture_height: u32, shader_manifest_hash: &'a str, vulkan_loader_status: &'a str, vulkan_instance_status: &'a str, @@ -1080,6 +1184,7 @@ mod tests { resize_frame: DEFAULT_RESIZE_FRAME, timeout_seconds: DEFAULT_TIMEOUT_SECONDS, mesh_input: MeshInput::SmokeTriangle, + texture_input: None, }) ); } @@ -1116,6 +1221,7 @@ mod tests { resize_frame: DEFAULT_RESIZE_FRAME, timeout_seconds: 45, mesh_input: MeshInput::SmokeTriangle, + texture_input: None, }) ); } @@ -1175,6 +1281,28 @@ mod tests { ); } + #[test] + fn parses_original_texm_input_as_one_atomic_request() { + let parsed = SmokeOptions::parse(&[ + "--out".to_string(), + "target/report.json".to_string(), + "--texture-root".to_string(), + "C:/GOG".to_string(), + "--texture-archive".to_string(), + "Textures.lib".to_string(), + "--texture-name".to_string(), + "DEFAULT.0".to_string(), + ]); + + assert!(matches!( + parsed, + Ok(SmokeOptions { + texture_input: Some(ResourceInput { archive, name, .. }), + .. + }) if archive == "Textures.lib" && name == "DEFAULT.0" + )); + } + #[test] fn rejects_deprecated_self_assertion_flags() { for flag in [ @@ -1239,6 +1367,11 @@ mod tests { mesh_name: "MTCHECK.MSH", mesh_vertex_count: 128, mesh_index_count: 252, + texture_source: "original-texm", + texture_archive: "Textures.lib", + texture_name: "DEFAULT.0", + texture_width: 16, + texture_height: 16, shader_manifest_hash: "deadbeef", vulkan_loader_status: "available", vulkan_instance_status: "created", @@ -1264,6 +1397,7 @@ mod tests { assert!(json.contains("\"vulkan_device_name\": \"Windows test GPU\"")); assert!(json.contains("\"runner_architecture\": \"x86_64\"")); assert!(json.contains("\"mesh_source\": \"original-msh\"")); + assert!(json.contains("\"texture_source\": \"original-texm\"")); } #[test] @@ -1286,8 +1420,10 @@ mod tests { resize_frame: DEFAULT_RESIZE_FRAME, timeout_seconds: 7, mesh_input: MeshInput::SmokeTriangle, + texture_input: None, }, mesh: VulkanStaticMesh::smoke_triangle(), + texture: None, completed: Arc::new(AtomicBool::new(false)), progress: Arc::new(SharedSmokeProgress::default()), window_id: None, diff --git a/crates/fparkan-inspection/src/lib.rs b/crates/fparkan-inspection/src/lib.rs index 983505a..ccef1b7 100644 --- a/crates/fparkan-inspection/src/lib.rs +++ b/crates/fparkan-inspection/src/lib.rs @@ -331,6 +331,37 @@ pub fn inspect_texture_from_root( }) } +/// Loads a decoded TEXM document through repository-backed lookup. +/// +/// # Errors +/// +/// Returns a string error when the resource cannot be resolved or parsed as a +/// valid TEXM payload. +pub fn load_texture_from_root( + root: &Path, + archive: &str, + resource: &str, +) -> Result { + let bytes = read_resource_bytes_diagnostic(root, archive, resource) + .map_err(|err| render_human(&err))?; + decode_texm(bytes).map_err(|err| err.to_string()) +} + +/// Loads and decodes TEXM mip 0 as RGBA8 through repository-backed lookup. +/// +/// # Errors +/// +/// Returns a string error when the resource cannot be resolved, decoded, or +/// converted to the shared RGBA8 upload representation. +pub fn load_texture_mip0_rgba8_from_root( + root: &Path, + archive: &str, + resource: &str, +) -> Result { + let document = load_texture_from_root(root, archive, resource)?; + fparkan_texm::decode_mip_rgba8(&document, 0).map_err(|err| err.to_string()) +} + /// Inspects a terrain land file by path. /// /// # Errors diff --git a/docs/rendering/renderer_truth_table.md b/docs/rendering/renderer_truth_table.md index 9de5f71..2d0130d 100644 --- a/docs/rendering/renderer_truth_table.md +++ b/docs/rendering/renderer_truth_table.md @@ -8,7 +8,7 @@ | Path | Native window / swapchain | Draws pixels | Uses original assets | Acceptance class | Что доказывает | Чего не доказывает | | --- | --- | --- | --- | --- | --- | --- | -| `fparkan-vulkan-smoke` / `VulkanSmokeRenderer` | Yes | Yes | Static MSH only | `covered-gpu` for Stage 0 smoke and explicit MSH bridge IDs | Loader, instance, surface, swapchain, submit/present, validation-clean triangle path; original MSH vertex/index upload and indexed draw | Texture sampling, descriptors, terrain, gameplay rendering | +| `fparkan-vulkan-smoke` / `VulkanSmokeRenderer` | Yes | Yes | Static MSH plus TEXM upload | `covered-gpu` for Stage 0 smoke and explicit MSH/TEXM bridge IDs | Loader, instance, surface, swapchain, submit/present, validation-clean triangle path; original MSH indexed draw; TEXM RGBA8 staging upload and `SHADER_READ_ONLY_OPTIMAL` transition | Texture sampling, descriptors, terrain, gameplay rendering | | `VulkanPlanningBackend` | No | No | Optional CPU-side IDs only | `covered-planning` | Deterministic command validation, canonical capture, frame submission planning | Любой live GPU draw, pixel parity, validation-clean asset frame | | `RecordingBackend` | No | No | Optional CPU-side IDs only | `covered-planning` | Stable command capture for backend-neutral tests | Native window, Vulkan, GPU resource lifetime, pixels | | `NullBackend` | No | No | Optional CPU-side IDs only | Usually `covered` for validation-only rows | Command stream framing and bounds validation | Capture stability, GPU execution, pixels | @@ -29,7 +29,7 @@ ## Current repository status -- Реальный Vulkan в репозитории имеет smoke triangle path и узкий static-MSH bridge: `fparkan-vulkan-smoke --model-root --model-archive system.rlb --model-name MTCHECK.MSH` загружает validated original MSH, разворачивает batch `base_vertex`, нормализует XZ viewer plane и выполняет real indexed draw. Это не является texture/material/terrain renderer. +- Реальный Vulkan в репозитории имеет smoke triangle path и узкий static asset bridge: `fparkan-vulkan-smoke --model-root --model-archive system.rlb --model-name MTCHECK.MSH --texture-root --texture-archive Textures.lib --texture-name DEFAULT.0` загружает validated original MSH, разворачивает batch `base_vertex`, нормализует XZ viewer plane, выполняет real indexed draw и загружает decoded TEXM mip-0 в device-local image. Image переходится в sampling layout, но ещё не связан descriptor set-ом или fragment shader; это не texture/material/terrain renderer. - `apps/fparkan-game` сейчас выдает `render-planning` JSON report поверх synthetic window descriptor и `VulkanPlanningBackend`. - `apps/fparkan-viewer` сейчас inspection-only CLI и не открывает live Vulkan diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv index ba080ed..892fd94 100644 --- a/fixtures/acceptance/coverage.tsv +++ b/fixtures/acceptance/coverage.tsv @@ -295,7 +295,7 @@ S2-PLANNING-RENDER-007 covered cargo test -p fparkan-render --offline capture_is S2-PLANNING-RENDER-008 covered-planning cargo test -p fparkan-render --offline recording_backend_stores_captures S2-PLANNING-RENDER-009 covered cargo xtask policy S3-VK-MESH-UPLOAD-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-mtcheck-msh.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root --model-archive system.rlb --model-name MTCHECK.MSH; original MSH reports 128 vertices and 252 indices -S3-VK-TEXM-UPLOAD-001 blocked awaits Stage 3 Vulkan texture upload path +S3-VK-TEXM-UPLOAD-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-mtcheck-texm-upload.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root --model-archive system.rlb --model-name MTCHECK.MSH --texture-root --texture-archive Textures.lib --texture-name DEFAULT.0; original TEXM 16x16 decoded RGBA8, staged into device-local image and transitioned to SHADER_READ_ONLY_OPTIMAL S3-VK-DESCRIPTOR-001 blocked awaits Stage 3 descriptor set contract implementation S3-VK-PIPELINE-001 blocked awaits Stage 3 graphics pipeline key and cache implementation S3-VK-DRAW-MODEL-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-mtcheck-msh.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root --model-archive system.rlb --model-name MTCHECK.MSH; live Win32 Vulkan indexed MSH draw/present