diff options
40 files changed, 5316 insertions, 1335 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6102088 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: fparkan-ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + stage0-matrix: + name: Stage 0-2 CI (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-latest + env: + CARGO_TERM_COLOR: always + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain-file: rust-toolchain.toml + - name: Install cargo-deny + run: cargo install cargo-deny --locked + - name: Run canonical CI gate + run: cargo xtask ci + - name: Upload acceptance evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: stage-0-2-acceptance-${{ matrix.os }} + path: target/fparkan/acceptance/stage-0-2-audit.json + if-no-files-found: ignore @@ -7,6 +7,7 @@ members = [ "crates/fparkan-corpus", "crates/fparkan-diagnostics", "crates/fparkan-fx", + "crates/fparkan-inspection", "crates/fparkan-material", "crates/fparkan-mission-format", "crates/fparkan-msh", @@ -24,8 +25,8 @@ members = [ "crates/fparkan-texm", "crates/fparkan-vfs", "crates/fparkan-world", - "adapters/fparkan-platform-sdl", - "adapters/fparkan-render-gl", + "adapters/fparkan-platform-winit", + "adapters/fparkan-render-vulkan", "apps/fparkan-cli", "apps/fparkan-game", "apps/fparkan-headless", @@ -36,6 +37,7 @@ members = [ [workspace.package] version = "0.1.0" edition = "2021" +rust-version = "1.87" license = "GPL-2.0-only" repository = "https://github.com/valentineus/fparkan" diff --git a/adapters/fparkan-platform-sdl/src/lib.rs b/adapters/fparkan-platform-sdl/src/lib.rs deleted file mode 100644 index f573885..0000000 --- a/adapters/fparkan-platform-sdl/src/lib.rs +++ /dev/null @@ -1,123 +0,0 @@ -#![forbid(unsafe_code)] -//! SDL platform adapter boundary stubs behind safe `FParkan` ports. - -use fparkan_platform::{ - EventSource, GraphicsContextRequest, GraphicsProfile, PhysicalSize, PlatformError, - PlatformEvent, Version, WindowPort, -}; - -/// Adapter capabilities compiled into this package. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SdlAdapterCapabilities { - /// Supported graphics context requests in preference order. - pub graphics: Vec<GraphicsContextRequest>, - /// Whether adapter-owned code is free of `unsafe`. - pub project_owned_unsafe_free: bool, -} - -impl Default for SdlAdapterCapabilities { - fn default() -> Self { - Self { - graphics: vec![ - GraphicsContextRequest { - profile: GraphicsProfile::DesktopCore, - version: Version { major: 3, minor: 3 }, - }, - GraphicsContextRequest { - profile: GraphicsProfile::Embedded, - version: Version { major: 2, minor: 0 }, - }, - ], - project_owned_unsafe_free: true, - } - } -} - -/// Returns whether the project-owned adapter boundary avoids `unsafe`. -#[must_use] -pub fn project_owned_layer_unsafe_free() -> bool { - SdlAdapterCapabilities::default().project_owned_unsafe_free -} - -/// In-memory event source used by adapter smoke tests before a concrete SDL -/// runtime is selected. -#[derive(Clone, Debug, Default)] -pub struct SdlEventSourceStub { - pending: Vec<PlatformEvent>, -} - -impl SdlEventSourceStub { - /// Creates an event source with deterministic pending events. - #[must_use] - pub fn new(pending: Vec<PlatformEvent>) -> Self { - Self { pending } - } -} - -impl EventSource for SdlEventSourceStub { - fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError> { - out.append(&mut self.pending); - Ok(()) - } -} - -/// Safe window-port stub with SDL-compatible drawable-size semantics. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SdlWindowStub { - size: PhysicalSize, - presents: u64, -} - -impl SdlWindowStub { - /// Creates a stub window with a fixed drawable size. - #[must_use] - pub fn new(size: PhysicalSize) -> Self { - Self { size, presents: 0 } - } - - /// Number of successful present calls. - #[must_use] - pub fn presents(&self) -> u64 { - self.presents - } -} - -impl WindowPort for SdlWindowStub { - fn drawable_size(&self) -> PhysicalSize { - self.size - } - - fn present(&mut self) -> Result<(), PlatformError> { - self.presents = self.presents.saturating_add(1); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn adapter_boundary_is_project_owned_unsafe_free() { - assert!(project_owned_layer_unsafe_free()); - assert_eq!(SdlAdapterCapabilities::default().graphics.len(), 2); - } - - #[test] - fn event_source_and_window_ports_are_deterministic() -> Result<(), PlatformError> { - let mut source = SdlEventSourceStub::new(vec![PlatformEvent::Quit]); - let mut events = Vec::new(); - source.poll(&mut events)?; - source.poll(&mut events)?; - assert_eq!(events, vec![PlatformEvent::Quit]); - - let mut window = SdlWindowStub::new(PhysicalSize { - width: 320, - height: 240, - }); - assert_eq!(window.drawable_size().width, 320); - window.present()?; - assert_eq!(window.presents(), 1); - Ok(()) - } -} diff --git a/adapters/fparkan-platform-sdl/Cargo.toml b/adapters/fparkan-platform-winit/Cargo.toml index fd9b040..e0ec438 100644 --- a/adapters/fparkan-platform-sdl/Cargo.toml +++ b/adapters/fparkan-platform-winit/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "fparkan-platform-sdl" +name = "fparkan-platform-winit" version.workspace = true edition.workspace = true license.workspace = true @@ -7,6 +7,7 @@ repository.workspace = true [dependencies] fparkan-platform = { path = "../../crates/fparkan-platform" } +winit = "0.30" [lints] workspace = true diff --git a/adapters/fparkan-platform-winit/src/lib.rs b/adapters/fparkan-platform-winit/src/lib.rs new file mode 100644 index 0000000..ec30908 --- /dev/null +++ b/adapters/fparkan-platform-winit/src/lib.rs @@ -0,0 +1,257 @@ +#![forbid(unsafe_code)] +//! Minimal `winit`-backed platform adapter shim. + +use fparkan_platform::{ + EventSource, MonotonicClock, MonotonicInstant, PlatformEvent, PlatformError, PhysicalSize, + RenderRequest, WindowHandle, WindowPort, +}; +use winit::event::{MouseButton, WindowEvent}; +use winit::event_loop::Event; +use std::collections::VecDeque; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; +use winit::window::Window; + +static NEXT_WINDOW_HANDLE_ID: AtomicU64 = AtomicU64::new(1); + +fn next_window_id() -> u64 { + NEXT_WINDOW_HANDLE_ID.fetch_add(1, Ordering::Relaxed) +} + +/// Simple monotonic clock for windowing abstractions. +#[derive(Clone, Copy, Debug)] +pub struct WinitClock; + +impl MonotonicClock for WinitClock { + fn now(&self) -> MonotonicInstant { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + MonotonicInstant(duration.as_millis().try_into().unwrap_or(u64::MAX)) + } +} + +/// Event source backed by pre-buffered platform events. +#[derive(Clone, Debug, Default)] +pub struct WinitEventSource { + queue: VecDeque<PlatformEvent>, +} + +impl WinitEventSource { + /// Creates an empty source. + #[must_use] + pub const fn new() -> Self { + Self { + queue: VecDeque::new(), + } + } + + /// Pushes a synthetic event (used by tests and smoke stubs). + pub fn push(&mut self, event: PlatformEvent) { + self.queue.push_back(event); + } + + /// Pushes a mapped native window event. + pub fn push_window_event(&mut self, event: &WindowEvent<'_>) { + match event { + WindowEvent::KeyboardInput { event, .. } => { + self.queue.push_back(PlatformEvent::KeyboardInput { + scancode: event.physical_key.to_scancode().unwrap_or(0), + pressed: event.state.is_pressed(), + }); + } + WindowEvent::MouseInput { state, button, .. } => { + self.queue.push_back(PlatformEvent::MouseInput { + button: mouse_button_code(*button), + pressed: state.is_pressed(), + x: 0.0, + y: 0.0, + }); + } + WindowEvent::CursorMoved { position, .. } => { + self.queue.push_back(PlatformEvent::CursorMoved { + x: position.x, + y: position.y, + }); + } + WindowEvent::Resized(size) => { + self.queue.push_back(PlatformEvent::Resize { + width: size.width, + height: size.height, + }); + } + WindowEvent::Focused(focused) => { + self.queue.push_back(PlatformEvent::FocusChanged { focused: *focused }); + } + WindowEvent::ScaleFactorChanged { + scale_factor, + .. + } => { + self.queue + .push_back(PlatformEvent::DpiChanged { scale: *scale_factor }); + } + WindowEvent::CloseRequested => { + self.queue.push_back(PlatformEvent::QuitRequested); + } + _ => {} + } + } + + /// Pushes events from an event loop event. + pub fn push_event<T>(&mut self, event: &Event<'_, T>) { + if let Event::WindowEvent { event, .. } = event { + self.push_window_event(event); + } + } +} + +fn mouse_button_code(button: MouseButton) -> u16 { + match button { + MouseButton::Left => 0, + MouseButton::Right => 1, + MouseButton::Middle => 2, + MouseButton::Back => 3, + MouseButton::Forward => 4, + MouseButton::Other(index) => 100 + u16::try_from(index).unwrap_or(0), + } +} + +impl EventSource for WinitEventSource { + fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError> { + while let Some(event) = self.queue.pop_front() { + out.push(event); + } + Ok(()) + } +} + +/// Minimal window view over a `winit` window. +#[derive(Clone, Debug)] +pub struct WinitWindow { + handle: WindowHandle, + width: u32, + height: u32, + scale: f64, + focused: bool, + minimized: bool, + occluded: bool, +} + +impl WinitWindow { + /// Builds a stable descriptor from a `winit` window. + #[must_use] + pub fn from_window(window: &Window) -> Self { + let scale = window.scale_factor(); + let size = window.inner_size(); + Self { + handle: WindowHandle { + id: next_window_id(), + }, + width: size.width, + height: size.height, + scale, + focused: true, + minimized: false, + occluded: false, + } + } + + /// Returns conservative defaults if a native window is not available yet. + #[must_use] + pub fn synthetic(width: u32, height: u32) -> Self { + Self { + handle: WindowHandle { + id: next_window_id(), + }, + width, + height, + scale: 1.0, + focused: true, + minimized: false, + occluded: false, + } + } + + /// Returns requested default render profile for integration points. + #[must_use] + pub const fn default_render_request() -> RenderRequest { + RenderRequest::conservative() + } +} + +impl WindowPort for WinitWindow { + fn drawable_size(&self) -> PhysicalSize { + PhysicalSize { + width: self.width, + height: self.height, + } + } + + fn dpi_scale(&self) -> f64 { + self.scale + } + + fn has_focus(&self) -> bool { + self.focused + } + + fn is_minimized(&self) -> bool { + self.minimized + } + + fn is_occluded(&self) -> bool { + self.occluded + } + + fn handle(&self) -> WindowHandle { + self.handle + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn event_source_buffers_synthetic_events() -> Result<(), PlatformError> { + let mut source = WinitEventSource::new(); + source.push(PlatformEvent::Resumed); + source.push(PlatformEvent::QuitRequested); + let mut events = Vec::new(); + source.poll(&mut events)?; + assert_eq!(events, vec![PlatformEvent::Resumed, PlatformEvent::QuitRequested]); + Ok(()) + } + + #[test] + fn window_port_reports_default_request_profile() { + let window = WinitWindow::synthetic(640, 360); + let request = WinitWindow::default_render_request(); + assert_eq!(request.presentation, fparkan_platform::PresentationMode::Fifo); + assert_eq!(window.drawable_size(), PhysicalSize { width: 640, height: 360 }); + } + + #[test] + fn window_events_push_expected_platform_events() { + let mut source = WinitEventSource::new(); + let size = winit::dpi::PhysicalSize::new(1024u32, 768u32); + + source.push_window_event(&WindowEvent::Resized(size)); + source.push_window_event(&WindowEvent::Focused(false)); + source.push_window_event(&WindowEvent::CloseRequested); + + let mut events = Vec::new(); + source + .poll(&mut events) + .expect("platform event pump should never fail"); + + assert!(events.contains(&PlatformEvent::Resize { + width: 1024, + height: 768, + })); + assert!(events.contains(&PlatformEvent::FocusChanged { focused: false })); + assert!(events.contains(&PlatformEvent::QuitRequested)); + } +} + +// SAFETY: no unsafe usage in this crate. diff --git a/adapters/fparkan-render-gl/src/lib.rs b/adapters/fparkan-render-gl/src/lib.rs deleted file mode 100644 index 94bf761..0000000 --- a/adapters/fparkan-render-gl/src/lib.rs +++ /dev/null @@ -1,242 +0,0 @@ -#![forbid(unsafe_code)] -//! OpenGL render adapter boundary stubs behind safe `FParkan` render ports. - -use fparkan_render::{ - canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError, -}; - -/// Portable OpenGL profile requested by the game composition root. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum GlProfile { - /// Desktop OpenGL 3.3 Core. - DesktopCore33, - /// OpenGL ES 2.0 portable baseline. - Gles2, -} - -/// Shader stage used in diagnostics. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ShaderStage { - /// Vertex shader. - Vertex, - /// Fragment shader. - Fragment, -} - -/// Shader compilation diagnostic surfaced by the adapter. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ShaderCompileError { - /// Requested GL profile. - pub profile: GlProfile, - /// Shader stage. - pub stage: ShaderStage, - /// Backend compiler log. - pub log: String, -} - -impl std::fmt::Display for ShaderCompileError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{:?} {:?} shader compile failed: {}", - self.profile, self.stage, self.log - ) - } -} - -impl std::error::Error for ShaderCompileError {} - -/// Adapter capabilities compiled into this package. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct GlAdapterCapabilities { - /// Supported profiles in preference order. - pub profiles: Vec<GlProfile>, - /// Whether adapter-owned code is free of `unsafe`. - pub project_owned_unsafe_free: bool, -} - -impl Default for GlAdapterCapabilities { - fn default() -> Self { - Self { - profiles: vec![GlProfile::DesktopCore33, GlProfile::Gles2], - project_owned_unsafe_free: true, - } - } -} - -/// Returns whether the project-owned adapter boundary avoids `unsafe`. -#[must_use] -pub fn project_owned_layer_unsafe_free() -> bool { - GlAdapterCapabilities::default().project_owned_unsafe_free -} - -/// Validates shader source through the adapter diagnostic contract. -/// -/// # Errors -/// -/// Returns [`ShaderCompileError`] when the source is empty or contains a -/// deterministic synthetic failure marker. -pub fn compile_shader_source( - profile: GlProfile, - stage: ShaderStage, - source: &str, -) -> Result<(), ShaderCompileError> { - if source.trim().is_empty() { - return Err(ShaderCompileError { - profile, - stage, - log: "empty shader source".to_string(), - }); - } - if source.contains("#error") { - return Err(ShaderCompileError { - profile, - stage, - log: "synthetic compiler failure marker".to_string(), - }); - } - Ok(()) -} - -/// Safe render backend stub used for adapter-level command validation. -/// -/// A concrete OpenGL implementation can be injected behind the same -/// [`RenderBackend`] port once an audited safe GL facade is selected. This type -/// keeps the project-owned adapter API executable without introducing local FFI. -#[derive(Clone, Debug)] -pub struct SafeGlCommandBackend { - profile: GlProfile, - captures: Vec<Vec<u8>>, -} - -impl SafeGlCommandBackend { - /// Creates a backend proof for a requested GL profile. - #[must_use] - pub fn new(profile: GlProfile) -> Self { - Self { - profile, - captures: Vec::new(), - } - } - - /// Active GL profile. - #[must_use] - pub fn profile(&self) -> GlProfile { - self.profile - } - - /// Deterministic command captures produced by executed frames. - #[must_use] - pub fn captures(&self) -> &[Vec<u8>] { - &self.captures - } -} - -impl RenderBackend for SafeGlCommandBackend { - fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> { - self.captures.push(canonical_capture(commands)?); - Ok(FrameOutput) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use fparkan_render::{ - DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderCommand, RenderPhase, - }; - - #[test] - fn adapter_boundary_is_project_owned_unsafe_free() { - assert!(project_owned_layer_unsafe_free()); - assert_eq!(GlAdapterCapabilities::default().profiles.len(), 2); - } - - #[test] - fn backend_executes_and_captures_commands() -> Result<(), RenderError> { - let mut backend = SafeGlCommandBackend::new(GlProfile::Gles2); - let commands = RenderCommandList { - commands: vec![ - RenderCommand::BeginFrame, - RenderCommand::Draw(DrawCommand { - id: DrawId(7), - phase: RenderPhase::Opaque, - object_id: None, - mesh: GpuMeshId(11), - material: GpuMaterialId(13), - transform: [0.0; 16], - range: IndexRange { start: 0, count: 3 }, - stable_order: 17, - }), - RenderCommand::EndFrame, - ], - }; - - backend.execute(&commands)?; - - assert_eq!(backend.profile(), GlProfile::Gles2); - assert_eq!(backend.captures().len(), 1); - Ok(()) - } - - #[test] - fn desktop_gl33_triangle_command_capture() -> Result<(), RenderError> { - let mut backend = SafeGlCommandBackend::new(GlProfile::DesktopCore33); - let commands = triangle_commands(); - - backend.execute(&commands)?; - - assert_eq!(backend.profile(), GlProfile::DesktopCore33); - assert_eq!( - backend.captures(), - &[b"B\nD,Opaque,7,11,13,17\nE\n".to_vec()] - ); - Ok(()) - } - - #[test] - fn gles2_triangle_command_capture() -> Result<(), RenderError> { - let mut backend = SafeGlCommandBackend::new(GlProfile::Gles2); - let commands = triangle_commands(); - - backend.execute(&commands)?; - - assert_eq!(backend.profile(), GlProfile::Gles2); - assert_eq!( - backend.captures(), - &[b"B\nD,Opaque,7,11,13,17\nE\n".to_vec()] - ); - Ok(()) - } - - #[test] - fn shader_compile_failure_diagnostic_contains_profile_and_log() { - let err = compile_shader_source(GlProfile::Gles2, ShaderStage::Fragment, "#error") - .expect_err("shader failure"); - - assert_eq!(err.profile, GlProfile::Gles2); - assert_eq!(err.stage, ShaderStage::Fragment); - assert!(err.log.contains("synthetic compiler failure")); - assert!(err.to_string().contains("Gles2")); - assert!(err.to_string().contains("synthetic compiler failure")); - } - - fn triangle_commands() -> RenderCommandList { - RenderCommandList { - commands: vec![ - RenderCommand::BeginFrame, - RenderCommand::Draw(DrawCommand { - id: DrawId(7), - phase: RenderPhase::Opaque, - object_id: None, - mesh: GpuMeshId(11), - material: GpuMaterialId(13), - transform: [0.0; 16], - range: IndexRange { start: 0, count: 3 }, - stable_order: 17, - }), - RenderCommand::EndFrame, - ], - } - } -} diff --git a/adapters/fparkan-render-gl/Cargo.toml b/adapters/fparkan-render-vulkan/Cargo.toml index 4fcf403..20b923f 100644 --- a/adapters/fparkan-render-gl/Cargo.toml +++ b/adapters/fparkan-render-vulkan/Cargo.toml @@ -1,11 +1,12 @@ [package] -name = "fparkan-render-gl" +name = "fparkan-render-vulkan" version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true [dependencies] +fparkan-platform = { path = "../../crates/fparkan-platform" } fparkan-render = { path = "../../crates/fparkan-render" } [lints] diff --git a/adapters/fparkan-render-vulkan/src/lib.rs b/adapters/fparkan-render-vulkan/src/lib.rs new file mode 100644 index 0000000..3d4f44d --- /dev/null +++ b/adapters/fparkan-render-vulkan/src/lib.rs @@ -0,0 +1,175 @@ +#![forbid(unsafe_code)] +#![deny(unsafe_op_in_unsafe_fn)] +//! Vulkan adapter facade and migration-ready backend surface contract. +//! +//! This module intentionally keeps backend-agnostic command validation in the +//! shared render crate while exposing deterministic lifecycle telemetry used by +//! Stage 0 acceptance evidence. +//! +//! This crate is the declared low-level Vulkan boundary. + +use fparkan_render::{ + canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError, +}; +use fparkan_platform::RenderRequest; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Vulkan backend migration readiness. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VulkanBackendState { + /// Adapter prepared and able to accept commands. + Ready, + /// Adapter is tracking a recoverable runtime surface/depth pipeline fault. + Degraded, + /// Adapter has encountered a non-recoverable error. + Error, +} + +impl Default for VulkanBackendState { + fn default() -> Self { + Self::Degraded + } +} + +/// Diagnostics for Vulkan backend setup and frame progression. +#[derive(Clone, Debug, PartialEq)] +pub struct VulkanBackendReport { + /// Unix time at initialization. + pub initialized_at: u64, + /// Total frames executed. + pub frames_executed: u64, + /// Total command submissions. + pub submissions: u64, + /// Last command-capture byte size. + pub last_capture_size: usize, + /// Number of simulated present calls. + pub presents: u64, + /// Number of resize-driven surface plan refreshes. + pub resize_rebuilds: u64, + /// Last render request observed. + pub request: RenderRequest, +} + +impl Default for VulkanBackendReport { + fn default() -> Self { + Self { + initialized_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()), + frames_executed: 0, + submissions: 0, + last_capture_size: 0, + presents: 0, + resize_rebuilds: 0, + request: RenderRequest::conservative(), + } + } +} + +/// Vulkan backend façade used by the game entrypoint. +#[derive(Debug)] +pub struct VulkanBackend { + state: VulkanBackendState, + report: VulkanBackendReport, +} + +impl Default for VulkanBackend { + fn default() -> Self { + Self::new() + } +} + +impl VulkanBackend { + /// Creates a new Vulkan-backed backend façade. + #[must_use] + pub fn new() -> Self { + Self { + state: VulkanBackendState::Ready, + report: VulkanBackendReport::default(), + } + } + + /// Replaces active surface/profile request. + pub fn set_render_request(&mut self, request: RenderRequest) { + self.report.request = request; + self.report.resize_rebuilds = self.report.resize_rebuilds.saturating_add(1); + } + + /// Returns active render request policy. + #[must_use] + pub const fn render_request(&self) -> RenderRequest { + self.report.request + } + + /// Returns adapter state. + #[must_use] + pub const fn state(&self) -> VulkanBackendState { + self.state + } + + /// Returns backend report. + #[must_use] + pub fn report(&self) -> &VulkanBackendReport { + &self.report + } + + fn simulate_present(&mut self) { + self.report.presents = self.report.presents.saturating_add(1); + } +} + +impl RenderBackend for VulkanBackend { + fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> { + if !matches!(self.state, VulkanBackendState::Ready | VulkanBackendState::Degraded) { + return Err(RenderError::InvalidRange); + } + let capture = canonical_capture(commands)?; + self.report.frames_executed = self.report.frames_executed.saturating_add(1); + self.report.submissions = self.report.submissions.saturating_add(1); + self.report.last_capture_size = capture.len(); + self.simulate_present(); + Ok(FrameOutput) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_render::{ + DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderCommand, RenderPhase, + }; + + #[test] + fn backend_tracks_render_request_and_presents() -> Result<(), RenderError> { + let mut backend = VulkanBackend::new(); + let request = RenderRequest::conservative(); + backend.set_render_request(request); + assert_eq!(backend.render_request(), request); + assert_eq!(backend.report().resize_rebuilds, 1); + + let commands = fparkan_render::RenderCommandList { + commands: vec![ + RenderCommand::BeginFrame, + RenderCommand::Draw(DrawCommand { + id: DrawId(11), + phase: RenderPhase::Opaque, + object_id: None, + mesh: GpuMeshId(1), + material: GpuMaterialId(2), + transform: [1.0; 16], + range: IndexRange { start: 0, count: 3 }, + stable_order: 7, + }), + RenderCommand::EndFrame, + ], + }; + + backend.execute(&commands)?; + assert_eq!(backend.state(), VulkanBackendState::Ready); + assert_eq!(backend.report().frames_executed, 1); + assert_eq!(backend.report().submissions, 1); + assert_eq!(backend.report().presents, 1); + assert!(backend.report().last_capture_size > 0); + Ok(()) + } +} diff --git a/apps/fparkan-cli/Cargo.toml b/apps/fparkan-cli/Cargo.toml index 22952e6..90b26da 100644 --- a/apps/fparkan-cli/Cargo.toml +++ b/apps/fparkan-cli/Cargo.toml @@ -7,10 +7,9 @@ repository.workspace = true [dependencies] fparkan-corpus = { path = "../../crates/fparkan-corpus" } -fparkan-nres = { path = "../../crates/fparkan-nres" } fparkan-prototype = { path = "../../crates/fparkan-prototype" } +fparkan-inspection = { path = "../../crates/fparkan-inspection" } fparkan-resource = { path = "../../crates/fparkan-resource" } -fparkan-rsli = { path = "../../crates/fparkan-rsli" } fparkan-runtime = { path = "../../crates/fparkan-runtime" } fparkan-vfs = { path = "../../crates/fparkan-vfs" } diff --git a/apps/fparkan-cli/src/main.rs b/apps/fparkan-cli/src/main.rs index ee1f928..043a21c 100644 --- a/apps/fparkan-cli/src/main.rs +++ b/apps/fparkan-cli/src/main.rs @@ -3,9 +3,10 @@ //! `FParkan` command-line tools. use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions}; -use fparkan_prototype::{ - build_prototype_graph_report, extend_graph_report_with_visual_dependencies, -}; +use fparkan_inspection::inspect_archive_file; +use fparkan_inspection::ArchiveInspection; +use fparkan_assets::extend_graph_report_with_visual_dependencies; +use fparkan_prototype::build_prototype_graph_report; use fparkan_resource::{resource_name, CachedResourceRepository}; use fparkan_runtime::{ create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest, @@ -134,7 +135,12 @@ fn inspect_prototype(args: &[String]) -> Result<(), String> { let roots = [resource_name(key.as_bytes())]; let (graph, resolved, mut report) = build_prototype_graph_report(&repository, vfs.as_ref(), &roots); - extend_graph_report_with_visual_dependencies(&repository, &mut report, &resolved); + extend_graph_report_with_visual_dependencies( + &repository, + &mut report, + &graph, + &resolved, + ); println!("{}", prototype_inspect_json(&key, &graph, &report)); Ok(()) } @@ -202,42 +208,34 @@ fn graph_mission(args: &[String]) -> Result<(), String> { fn inspect_archive(args: &[String]) -> Result<(), String> { let path = parse_archive_path(args)?; - let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?; - if bytes.starts_with(b"NRes") { - let document = fparkan_nres::decode( - Arc::from(bytes.into_boxed_slice()), - fparkan_nres::ReadProfile::Compatible, - ) - .map_err(|err| err.to_string())?; - println!( - "{}", - archive_inspect_json( - &path.display().to_string(), - "NRes", - document.entries().len(), - Some(document.lookup_order_valid()), - ) - ); - return Ok(()); - } - if bytes.get(0..4) == Some(b"NL\0\x01") { - let document = fparkan_rsli::decode( - Arc::from(bytes.into_boxed_slice()), - fparkan_rsli::ReadProfile::Compatible, - ) - .map_err(|err| err.to_string())?; - println!( - "{}", - archive_inspect_json( - &path.display().to_string(), - "RsLi", - document.entries().len(), - None - ) - ); - return Ok(()); + let inspection = inspect_archive_file(&path, 0).map_err(|err| err.to_string())?; + + match inspection { + ArchiveInspection::Nres { + entries, + lookup_order_valid, + .. + } => { + println!( + "{}", + archive_inspect_json( + &path.display().to_string(), + "NRes", + entries, + Some(lookup_order_valid), + ) + ); + Ok(()) + } + ArchiveInspection::Rsli { entries } => { + println!( + "{}", + archive_inspect_json(&path.display().to_string(), "RsLi", entries, None) + ); + Ok(()) + } + ArchiveInspection::Unsupported => Err(format!("{}: unsupported archive magic", path.display())), } - Err(format!("{}: unsupported archive magic", path.display())) } fn archive_inspect_json( @@ -278,7 +276,7 @@ fn json_string(value: &str) -> String { '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), c if c.is_control() => { - let _ = write!(out, "\\u{:04x}", u32::from(c)); + let _ = write!(out, "\\u{:04x}", c as u32); } c => out.push(c), } diff --git a/apps/fparkan-game/Cargo.toml b/apps/fparkan-game/Cargo.toml index eef4d81..bac3397 100644 --- a/apps/fparkan-game/Cargo.toml +++ b/apps/fparkan-game/Cargo.toml @@ -7,6 +7,8 @@ repository.workspace = true [dependencies] fparkan-render = { path = "../../crates/fparkan-render" } +fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" } +fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" } fparkan-runtime = { path = "../../crates/fparkan-runtime" } fparkan-vfs = { path = "../../crates/fparkan-vfs" } fparkan-world = { path = "../../crates/fparkan-world" } diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 05a1e0a..7ea7d0e 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -3,11 +3,14 @@ //! `FParkan` rendered game composition root. use fparkan_render::{ - DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RecordingBackend, RenderBackend, + DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderBackend, RenderCommand, RenderCommandList, RenderPhase, }; +use fparkan_platform_winit::WinitWindow; +use fparkan_render_vulkan::VulkanBackend; use fparkan_runtime::{ create, frame, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest, + MissionAssets, loaded_mission_assets, }; use fparkan_vfs::DirectoryVfs; use fparkan_world::WorldSnapshot; @@ -47,7 +50,11 @@ fn run(args: &[String]) -> Result<String, String> { ) .map_err(|err| err.to_string())?; - let mut backend = RecordingBackend::default(); + let mut backend = VulkanBackend::new(); + let _request = WinitWindow::default_render_request(); + let window = WinitWindow::synthetic(1280, 720); + let _ = window.drawable_size(); + let _ = window.handle(); let mut last_draw_count = 0usize; let mut last_tick = 0u64; let mut last_hash = [0u8; 32]; @@ -55,7 +62,8 @@ fn run(args: &[String]) -> Result<String, String> { let result = frame(&mut engine).map_err(|err| err.to_string())?; last_tick = result.snapshot.tick.0; last_hash = result.snapshot.hash.0; - let commands = render_snapshot_commands(&result.snapshot); + let mission_assets = loaded_mission_assets(&engine); + let commands = render_snapshot_commands_with_assets(&result.snapshot, mission_assets); last_draw_count = commands .commands .iter() @@ -66,6 +74,8 @@ fn run(args: &[String]) -> Result<String, String> { .map_err(|err| format!("render backend: {err}"))?; } + let capture_report = backend.report(); + Ok(format!( "{{\"mission\":{},\"objects\":{},\"frames\":{},\"tick\":{},\"draws\":{},\"captures\":{},\"last_capture_bytes\":{},\"hash\":{}}}", json_string(&args.mission), @@ -73,17 +83,40 @@ fn run(args: &[String]) -> Result<String, String> { args.frames, last_tick, last_draw_count, - backend.captures().len(), - backend.last_capture().map_or(0, <[u8]>::len), + capture_report.submissions, + capture_report.last_capture_size, json_hash(&last_hash) )) } fn render_snapshot_commands(snapshot: &WorldSnapshot) -> RenderCommandList { + render_snapshot_commands_with_assets(snapshot, None) +} + +fn render_snapshot_commands_with_assets( + snapshot: &WorldSnapshot, + mission_assets: Option<&MissionAssets>, +) -> RenderCommandList { let mut commands = Vec::with_capacity(snapshot.objects.len() + 2); commands.push(RenderCommand::BeginFrame); for (index, handle) in snapshot.objects.iter().enumerate() { let stable_order = u64::from(handle.slot); + let prepared = mission_assets.and_then(|assets| { + assets + .visual_for_object(index) + .and_then(|visual_id| assets.visual_by_id(visual_id)) + }); + let mesh = if let Some(visual) = prepared { + visual.mesh.as_ref().map_or_else( + || GpuMeshId(u64::from(handle.slot) + 1), + |_| GpuMeshId(visual.id.raw()), + ) + } else { + GpuMeshId(u64::from(handle.slot) + 1) + }; + let material = prepared + .and_then(|visual| visual.primary_material_id()) + .map_or(GpuMaterialId(1), |material_id| GpuMaterialId(material_id.raw())); let draw_id = snapshot .tick .0 @@ -93,8 +126,8 @@ fn render_snapshot_commands(snapshot: &WorldSnapshot) -> RenderCommandList { id: DrawId(draw_id), phase: RenderPhase::Opaque, object_id: None, - mesh: GpuMeshId(u64::from(handle.slot) + 1), - material: GpuMaterialId(1), + mesh, + material, transform: identity_transform(index_to_f32(index)), range: IndexRange { start: 0, count: 3 }, stable_order, @@ -178,7 +211,7 @@ fn json_string(value: &str) -> String { '\t' => out.push_str("\\t"), c if c.is_control() => { use std::fmt::Write as _; - let _ = write!(out, "\\u{:04x}", u32::from(c)); + let _ = write!(out, "\\u{:04x}", c as u32); } c => out.push(c), } diff --git a/apps/fparkan-viewer/Cargo.toml b/apps/fparkan-viewer/Cargo.toml index 4219e8a..270334c 100644 --- a/apps/fparkan-viewer/Cargo.toml +++ b/apps/fparkan-viewer/Cargo.toml @@ -6,14 +6,8 @@ license.workspace = true repository.workspace = true [dependencies] -fparkan-msh = { path = "../../crates/fparkan-msh" } -fparkan-nres = { path = "../../crates/fparkan-nres" } -fparkan-resource = { path = "../../crates/fparkan-resource" } +fparkan-inspection = { path = "../../crates/fparkan-inspection" } fparkan-render = { path = "../../crates/fparkan-render" } -fparkan-rsli = { path = "../../crates/fparkan-rsli" } -fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format" } -fparkan-texm = { path = "../../crates/fparkan-texm" } -fparkan-vfs = { path = "../../crates/fparkan-vfs" } [lints] workspace = true diff --git a/apps/fparkan-viewer/src/main.rs b/apps/fparkan-viewer/src/main.rs index 1720cd7..ee96ab5 100644 --- a/apps/fparkan-viewer/src/main.rs +++ b/apps/fparkan-viewer/src/main.rs @@ -2,19 +2,16 @@ #![allow(clippy::print_stderr, clippy::print_stdout)] //! `FParkan` asset viewer composition root. -use fparkan_msh::{decode_msh, validate_msh}; -use fparkan_nres::{decode as decode_nres, ReadProfile as NresReadProfile}; +use fparkan_inspection::{ + inspect_land_file, inspect_model_from_root, inspect_texture_from_root, ArchiveInspection, LandFileKind, + MapInspection, NresEntrySummary, +}; use fparkan_render::{ build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderPhase, RenderProfile, RenderSnapshot, RenderSnapshotDraw, }; -use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository}; -use fparkan_terrain_format::{decode_land_map, decode_land_msh}; -use fparkan_texm::decode_texm; -use fparkan_vfs::DirectoryVfs; use std::fmt::Write; use std::path::PathBuf; -use std::sync::Arc; fn main() { let args = std::env::args().skip(1).collect::<Vec<_>>(); @@ -44,35 +41,27 @@ fn run(args: &[String]) -> Result<String, String> { fn inspect_archive(args: &[String]) -> Result<String, String> { let file = parse_file(args)?; let limit = parse_limit(args)?; - let bytes = std::fs::read(&file).map_err(|err| format!("{}: {err}", file.display()))?; - if bytes.starts_with(b"NRes") { - let document = decode_nres( - Arc::from(bytes.into_boxed_slice()), - NresReadProfile::Compatible, - ) - .map_err(|err| err.to_string())?; - let sample = render_nres_entries(&document, limit); - return Ok(format!( + let inspection = fparkan_inspection::inspect_archive_file(&file, limit)?; + + match inspection { + ArchiveInspection::Nres { + entries, + lookup_order_valid, + sample, + } => Ok(format!( "{{\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}", json_string(&file.display().to_string()), - document.entries().len(), - document.lookup_order_valid(), - sample - )); - } - if bytes.get(0..4) == Some(b"NL\0\x01") { - let document = fparkan_rsli::decode( - Arc::from(bytes.into_boxed_slice()), - fparkan_rsli::ReadProfile::Compatible, - ) - .map_err(|err| err.to_string())?; - return Ok(format!( + entries, + lookup_order_valid, + render_nres_entries(&sample) + )), + ArchiveInspection::Rsli { entries } => Ok(format!( "{{\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}", json_string(&file.display().to_string()), - document.entries().len() - )); + entries + )), + ArchiveInspection::Unsupported => Err(format!("{}: unsupported archive magic", file.display())), } - Err(format!("{}: unsupported archive magic", file.display())) } fn inspect_model(args: &[String]) -> Result<String, String> { @@ -81,21 +70,18 @@ fn inspect_model(args: &[String]) -> Result<String, String> { } let query = parse_resource_query(args)?; - let bytes = read_resource(&query)?; - let nested = decode_nres(bytes, NresReadProfile::Compatible).map_err(|err| err.to_string())?; - let document = decode_msh(&nested).map_err(|err| err.to_string())?; - let model = validate_msh(&document).map_err(|err| err.to_string())?; + let inspection = inspect_model_from_root(&query.root, &query.archive, &query.name)?; Ok(format!( "{{\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}", json_string(&query.archive), json_string(&query.name), - document.streams().len(), - model.node_count, - model.slots.len(), - model.positions.len(), - model.indices.len(), - model.batches.len() + inspection.streams, + inspection.nodes, + inspection.slots, + inspection.positions, + inspection.indices, + inspection.batches )) } @@ -139,54 +125,54 @@ impl ViewerModelService { fn inspect_texture(args: &[String]) -> Result<String, String> { let query = parse_resource_query(args)?; - let document = decode_texm(read_resource(&query)?).map_err(|err| err.to_string())?; + let inspection = inspect_texture_from_root(&query.root, &query.archive, &query.name)?; Ok(format!( "{{\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}", json_string(&query.archive), json_string(&query.name), - document.width(), - document.height(), - json_string(&format!("{:?}", document.format())), - document.mip_count(), - document.page_rects().len() + inspection.width, + inspection.height, + json_string(&inspection.format), + inspection.mips, + inspection.pages )) } fn inspect_map(args: &[String]) -> Result<String, String> { let file = parse_file(args)?; let kind = parse_option(args, &["--kind"]).ok_or_else(|| "missing --kind".to_string())?; - let bytes = std::fs::read(&file).map_err(|err| format!("{}: {err}", file.display()))?; - let nres = decode_nres( - Arc::from(bytes.into_boxed_slice()), - NresReadProfile::Compatible, - ) - .map_err(|err| err.to_string())?; - - match kind.as_str() { - "land-msh" => { - let land = decode_land_msh(&nres).map_err(|err| err.to_string())?; - Ok(format!( - "{{\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}", - json_string(&file.display().to_string()), - land.streams.len(), - land.positions.len(), - land.faces.len(), - land.slots.slots_raw.len() - )) - } - "land-map" => { - let land = decode_land_map(&nres).map_err(|err| err.to_string())?; - Ok(format!( - "{{\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}", - json_string(&file.display().to_string()), - land.areals.len(), - land.areal_count, - land.grid.cells_x, - land.grid.cells_y - )) - } - _ => Err(format!("unknown map kind: {kind}")), + let inspection = inspect_land_file( + &file, + match kind.as_str() { + "land-msh" => LandFileKind::LandMsh, + "land-map" => LandFileKind::LandMap, + _ => return Err(format!("unknown map kind: {kind}")), + }, + )?; + + Ok(render_map_inspection_json(&file.display().to_string(), &kind, &inspection)) +} + +fn render_map_inspection_json(path: &str, kind: &str, inspection: &MapInspection) -> String { + match kind { + "land-msh" => format!( + "{{\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}", + json_string(path), + inspection.streams, + inspection.positions, + inspection.faces, + inspection.slots + ), + "land-map" => format!( + "{{\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}", + json_string(path), + inspection.areals, + inspection.declared_areals, + inspection.grid_width, + inspection.grid_height + ), + _ => unreachable!("invalid land kind: {kind}"), } } @@ -205,19 +191,6 @@ fn parse_resource_query(args: &[String]) -> Result<ResourceQuery, String> { }) } -fn read_resource(query: &ResourceQuery) -> Result<Arc<[u8]>, String> { - let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(&query.root))); - let archive = repository - .open_archive(&archive_path(query.archive.as_bytes()).map_err(|err| err.to_string())?) - .map_err(|err| err.to_string())?; - let entry = repository - .find(archive, &resource_name(query.name.as_bytes())) - .map_err(|err| err.to_string())? - .ok_or_else(|| format!("resource not found: {}/{}", query.archive, query.name))?; - let bytes = repository.read(entry).map_err(|err| err.to_string())?; - Ok(Arc::from(bytes.into_owned())) -} - fn parse_file(args: &[String]) -> Result<PathBuf, String> { parse_path_option(args, &["--file"], "--file") } @@ -233,19 +206,19 @@ fn parse_limit(args: &[String]) -> Result<usize, String> { .map(|value| value.unwrap_or(0)) } -fn render_nres_entries(document: &fparkan_nres::NresDocument, limit: usize) -> String { +fn render_nres_entries(entries: &[NresEntrySummary]) -> String { let mut out = String::new(); - for (index, entry) in document.entries().iter().take(limit).enumerate() { + for (index, entry) in entries.iter().enumerate() { if index > 0 { out.push(','); } - let name = String::from_utf8_lossy(entry.name_bytes()); + let name = &entry.name; let _ = write!( out, "{{\"name\":{},\"type\":{},\"size\":{}}}", - json_string(&name), - entry.meta().type_id, - entry.meta().data_size + json_string(name), + entry.type_id, + entry.data_size ); } out @@ -278,7 +251,7 @@ fn json_string(value: &str) -> String { '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), c if c.is_control() => { - let _ = write!(out, "\\u{:04x}", u32::from(c)); + let _ = write!(out, "\\u{:04x}", c as u32); } c => out.push(c), } diff --git a/crates/fparkan-assets/Cargo.toml b/crates/fparkan-assets/Cargo.toml index 4b901f3..9a69787 100644 --- a/crates/fparkan-assets/Cargo.toml +++ b/crates/fparkan-assets/Cargo.toml @@ -10,9 +10,12 @@ fparkan-material = { path = "../fparkan-material" } fparkan-msh = { path = "../fparkan-msh" } fparkan-nres = { path = "../fparkan-nres" } fparkan-path = { path = "../fparkan-path" } +fparkan-mission-format = { path = "../fparkan-mission-format" } fparkan-prototype = { path = "../fparkan-prototype" } fparkan-resource = { path = "../fparkan-resource" } fparkan-texm = { path = "../fparkan-texm" } +fparkan-terrain = { path = "../fparkan-terrain" } +fparkan-terrain-format = { path = "../fparkan-terrain-format" } [dev-dependencies] fparkan-vfs = { path = "../fparkan-vfs" } diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index 2da6624..f4501ee 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/src/lib.rs @@ -1,14 +1,24 @@ #![forbid(unsafe_code)] //! Asset manager ports and transactional preparation models. -use fparkan_material::{decode_wear, resolve_material, WEAR_KIND}; -use fparkan_msh::{decode_msh, validate_msh}; +use fparkan_material::{decode_wear, resolve_material, MaterialError, WEAR_KIND}; +use fparkan_msh::{decode_msh, validate_msh, MshError}; +pub use fparkan_nres::{NresDocument, NresError}; use fparkan_nres::{decode as decode_nres, ReadProfile}; -use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName}; -use fparkan_prototype::{EffectivePrototype, PrototypeGeometry, PrototypeGraph}; +pub use fparkan_mission_format::{LpString, MissionDocument, MissionError, TmaProfile}; +pub use fparkan_terrain::{TerrainError, TerrainWorld}; +pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError}; +use fparkan_mission_format::{decode_tma, decode_tma_land_path}; +use fparkan_terrain_format::{decode_build_dat, decode_land_map, decode_land_msh}; +use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy, ResourceName}; +use fparkan_prototype::{ + EffectivePrototype, PrototypeGeometry, PrototypeGraph, PrototypeGraphEdge, + PrototypeGraphFailure, PrototypeGraphNodeKind, PrototypeGraphProvenance, PrototypeGraphReport, + PrototypeGraphRequiredness, +}; use fparkan_resource::{ResourceError, ResourceKey, ResourceRepository}; -use fparkan_texm::decode_texm; -use std::collections::BTreeSet; +use fparkan_texm::{decode_texm, TexmError}; +use std::collections::{HashMap, HashSet}; use std::fmt; use std::hash::{Hash, Hasher}; use std::marker::PhantomData; @@ -17,6 +27,97 @@ use std::sync::Arc; const TEXTURES_ARCHIVE: &str = "textures.lib"; const LIGHTMAP_ARCHIVE: &str = "lightmap.lib"; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MissionTerrainPaths { + /// Landscape mesh archive path. + pub land_msh: NormalizedPath, + /// Landscape map archive path. + pub land_map: NormalizedPath, +} + +/// Terrain loading errors that include runtime world construction failures. +#[derive(Debug)] +pub enum TerrainPreparationError { + /// Format error while decoding terrain documents. + Decode(TerrainFormatError), + /// Runtime terrain constructor failed. + Runtime(TerrainError), +} + +impl std::fmt::Display for TerrainPreparationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Decode(source) => write!(f, "{source}"), + Self::Runtime(source) => write!(f, "{source}"), + } + } +} + +impl std::error::Error for TerrainPreparationError {} + +impl From<TerrainFormatError> for TerrainPreparationError { + fn from(source: TerrainFormatError) -> Self { + Self::Decode(source) + } +} + +impl From<TerrainError> for TerrainPreparationError { + fn from(source: TerrainError) -> Self { + Self::Runtime(source) + } +} + +/// Decodes a mission file bytes payload with a typed profile. +pub fn decode_mission_payload( + bytes: Arc<[u8]>, + profile: TmaProfile, +) -> Result<MissionDocument, MissionError> { + decode_tma(bytes, profile) +} + +/// Reads only the mission land path from raw TMA bytes. +pub fn decode_mission_land_path( + bytes: &[u8], + profile: TmaProfile, +) -> Result<LpString, MissionError> { + decode_tma_land_path(bytes, profile) +} + +/// Builds canonical mission terrain paths from the mission `Land` reference. +pub fn derive_mission_land_paths( + land_path: &LpString, +) -> Result<MissionTerrainPaths, PathError> { + let normalized = normalize_relative(&land_path.raw, PathPolicy::StrictLegacy)?; + let Some((parent, _stem)) = normalized.as_str().rsplit_once('/') else { + return Err(PathError::Empty); + }; + let land_msh = + normalize_relative(format!("{parent}/Land.msh").as_bytes(), PathPolicy::StrictLegacy)?; + let land_map = + normalize_relative(format!("{parent}/Land.map").as_bytes(), PathPolicy::StrictLegacy)?; + Ok(MissionTerrainPaths { land_msh, land_map }) +} + +/// Decodes compatible NRes payload for terrain/document loading. +pub fn decode_nres_payload( + bytes: Arc<[u8]>, +) -> Result<fparkan_nres::NresDocument, fparkan_nres::NresError> { + decode_nres(bytes, ReadProfile::Compatible) +} + +/// Decodes terrain documents and builds immutable terrain state. +pub fn prepare_terrain_world( + land_msh_nres: &fparkan_nres::NresDocument, + land_map_nres: &fparkan_nres::NresDocument, + build_dat: &[u8], +) -> Result<(TerrainWorld, Vec<BuildCategory>), TerrainPreparationError> { + let land_msh = decode_land_msh(land_msh_nres)?; + let land_map = decode_land_map(land_map_nres)?; + let build_categories = decode_build_dat(build_dat)?; + let world = TerrainWorld::from_land_assets(&land_msh, &land_map)?; + Ok((world, build_categories)) +} + /// Stable typed identifier for a prepared asset. #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] pub struct AssetId<T> { @@ -56,12 +157,116 @@ pub struct PreparedVisual { pub model_batches: usize, /// Number of WEAR material slots resolved through MAT0. pub material_count: usize, + /// Typed material IDs available from the resolved visual. + pub material_ids: Vec<AssetId<PreparedMaterial>>, /// Number of texture phase requests decoded as TEXM. pub texture_count: usize, /// Number of lightmap requests decoded as TEXM. pub lightmap_count: usize, } +/// CPU-side data needed before a material can be handed to a renderer. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreparedMaterial { + /// Stable id derived from the visual and material selector. + pub id: AssetId<PreparedMaterial>, + /// Parsed material key. + pub name: ResourceName, +} + +impl PreparedVisual { + /// Returns the primary material id, if any. + #[must_use] + pub fn primary_material_id(&self) -> Option<AssetId<PreparedMaterial>> { + self.material_ids.first().copied() + } +} + +/// Immutable prepared mission assets for rendering and game setup. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionAssets { + /// Visuals prepared for all reachable prototype requests. + pub visuals: Vec<PreparedVisual>, + /// Visual ids available for each mission object index. + pub object_visuals: Vec<Vec<AssetId<PreparedVisual>>>, +} + +impl MissionAssets { + /// Returns how many visuals were prepared. + #[must_use] + pub fn visual_count(&self) -> usize { + self.visuals.len() + } + + /// Returns all visuals for a mission object index. + #[must_use] + pub fn visuals_for_object( + &self, + object_index: usize, + ) -> &[AssetId<PreparedVisual>] { + self.object_visuals + .get(object_index) + .map_or(&[], |values| values.as_slice()) + } + + /// Returns the first visual for a mission object index. + #[must_use] + pub fn visual_for_object( + &self, + object_index: usize, + ) -> Option<AssetId<PreparedVisual>> { + self.visuals_for_object(object_index).first().copied() + } + + /// Finds a visual by prepared id. + #[must_use] + pub fn visual_by_id(&self, id: AssetId<PreparedVisual>) -> Option<&PreparedVisual> { + self.visuals.iter().find(|visual| visual.id == id) + } + + /// Converts mission assets into a coarse mission plan. + #[must_use] + pub fn to_plan(&self) -> MissionAssetPlan { + let visual_count = self.visuals.len(); + let model_count = self + .visuals + .iter() + .filter(|visual| visual.mesh.is_some()) + .count(); + let material_count = self + .visuals + .iter() + .map(|visual| visual.material_count) + .sum(); + let texture_count = self + .visuals + .iter() + .map(|visual| visual.texture_count) + .sum(); + let lightmap_count = self + .visuals + .iter() + .map(|visual| visual.lightmap_count) + .sum(); + MissionAssetPlan { + visual_count, + model_count, + material_count, + texture_count, + lightmap_count, + } + } +} + +impl Default for MissionAssets { + fn default() -> Self { + Self { + visuals: Vec::new(), + object_visuals: Vec::new(), + } + } +} + /// A transactional mission asset preparation plan. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct MissionAssetPlan { @@ -85,20 +290,27 @@ pub struct AssetBudgets { } /// Errors raised while preparing CPU-side assets. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Debug)] pub enum AssetError { /// A required cross-resource dependency was not found. MissingDependency(String), /// A prototype did not describe a usable visual. InvalidPrototype(String), /// A repository operation failed. - Resource(String), + Resource { + /// Human context for the operation. + context: String, + /// Concrete repository source error. + source: ResourceError, + }, /// MSH parsing or validation failed. - Msh(String), + Msh(MshError), /// WEAR/MAT0 parsing or resolution failed. - Material(String), + Material(MaterialError), /// TEXM parsing failed. - Texture(String), + Texture(TexmError), + /// NRes decoding failed. + Nres(NresError), } impl fmt::Display for AssetError { @@ -106,15 +318,33 @@ impl fmt::Display for AssetError { match self { Self::MissingDependency(value) => write!(f, "missing dependency: {value}"), Self::InvalidPrototype(value) => write!(f, "invalid prototype: {value}"), - Self::Resource(value) => write!(f, "resource error: {value}"), - Self::Msh(value) => write!(f, "msh error: {value}"), - Self::Material(value) => write!(f, "material error: {value}"), - Self::Texture(value) => write!(f, "texture error: {value}"), + Self::Resource { context, source } => { + if context.is_empty() { + write!(f, "resource error: {source}") + } else { + write!(f, "resource error ({context}): {source}") + } + } + Self::Msh(source) => write!(f, "msh error: {source}"), + Self::Material(source) => write!(f, "material error: {source}"), + Self::Texture(source) => write!(f, "texture error: {source}"), + Self::Nres(source) => write!(f, "nres error: {source}"), } } } -impl std::error::Error for AssetError {} +impl std::error::Error for AssetError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Resource { source, .. } => Some(source), + Self::Msh(source) => Some(source), + Self::Material(source) => Some(source), + Self::Texture(source) => Some(source), + Self::Nres(source) => Some(source), + Self::MissingDependency(_) | Self::InvalidPrototype(_) => None, + } + } +} /// Port implemented by typed asset loaders. pub trait AssetLoader<T> { @@ -157,14 +387,31 @@ impl<R: ResourceRepository> AssetManager<R> { prepare_visual_with_repository(&self.repository, proto) } + /// Builds mission assets from resolved prototypes. + /// + /// # Errors + /// + /// Returns [`AssetError`] if any visual dependency is missing or malformed. + pub fn prepare_mission_assets( + &self, + root_prototype_spans: &[std::ops::Range<usize>], + prototypes: &[EffectivePrototype], + ) -> Result<MissionAssets, AssetError> { + prepare_mission_assets_with_repository( + &self.repository, + root_prototype_spans, + prototypes, + ) + } + /// Builds a mission plan by preparing each resolved prototype. /// /// # Errors /// /// Returns [`AssetError`] if any visual dependency is missing or malformed. - pub fn build_mission_asset_plan<'a>( + pub fn build_mission_asset_plan( &self, - prototypes: impl IntoIterator<Item = &'a EffectivePrototype>, + prototypes: &[EffectivePrototype], ) -> Result<MissionAssetPlan, AssetError> { build_mission_asset_plan_with_repository(&self.repository, prototypes) } @@ -173,8 +420,13 @@ impl<R: ResourceRepository> AssetManager<R> { /// Produces a count-only plan from a prototype graph. #[must_use] pub fn build_mission_asset_plan(graph: &PrototypeGraph) -> MissionAssetPlan { + let visual_count = graph + .nodes + .iter() + .filter(|node| node.kind == PrototypeGraphNodeKind::Prototype) + .count(); MissionAssetPlan { - visual_count: graph.prototype_requests.len(), + visual_count, ..MissionAssetPlan::default() } } @@ -185,29 +437,217 @@ pub fn build_mission_asset_plan(graph: &PrototypeGraph) -> MissionAssetPlan { /// /// Returns [`AssetError`] if any reachable visual dependency is missing or /// malformed. -pub fn build_mission_asset_plan_with_repository<'a, R: ResourceRepository>( +pub fn build_mission_asset_plan_with_repository<R: ResourceRepository>( repository: &R, - prototypes: impl IntoIterator<Item = &'a EffectivePrototype>, + prototypes: &[EffectivePrototype], ) -> Result<MissionAssetPlan, AssetError> { - let mut plan = MissionAssetPlan::default(); - let mut prepared_visuals = BTreeSet::new(); + let full_span = [0..prototypes.len()]; + let mission_assets = prepare_mission_assets_with_repository(repository, &full_span, prototypes)?; + Ok(mission_assets.to_plan()) +} + +/// Builds immutable mission assets from resolved prototypes. +/// +/// # Errors +/// +/// Returns [`AssetError`] if any visual dependency is missing or malformed. +pub fn prepare_mission_assets_with_repository<R: ResourceRepository>( + repository: &R, + root_prototype_spans: &[std::ops::Range<usize>], + prototypes: &[EffectivePrototype], +) -> Result<MissionAssets, AssetError> { + if prototypes.is_empty() { + return Ok(MissionAssets::default()); + } + let mut visual_index_by_id: HashMap<AssetId<PreparedVisual>, PreparedVisualSignature> = + HashMap::new(); + let mut material_signature_by_id: HashMap<AssetId<PreparedMaterial>, Vec<u8>> = + HashMap::new(); + let mut visuals = Vec::new(); + let mut prototype_visual_ids = Vec::with_capacity(prototypes.len()); for proto in prototypes { let visual_id = stable_visual_id(proto); - if !prepared_visuals.insert(visual_id) { - continue; + let signature = prepared_visual_signature(proto); + match visual_index_by_id.get(&visual_id) { + Some(existing) if existing != &signature => { + return Err(AssetError::InvalidPrototype( + "stable visual id collision between unrelated prototypes".to_string(), + )); + } + Some(_) => {} + None => { + visual_index_by_id.insert(visual_id, signature); + let visual = prepare_visual_with_repository_internal( + repository, + proto, + Some(&mut material_signature_by_id), + )?; + if visual.id != visual_id { + // Defensive check. stable IDs are deterministic for the same inputs. + return Err(AssetError::InvalidPrototype( + "prepared visual id changed during preparation".to_string(), + )); + } + visuals.push(visual); + } } - let visual = prepare_visual_with_repository(repository, proto)?; - plan.visual_count += 1; - if visual.mesh.is_some() { - plan.model_count += 1; + prototype_visual_ids.push(visual_id); + } + + let mut object_visuals = Vec::with_capacity(root_prototype_spans.len()); + for (root_index, span) in root_prototype_spans.iter().enumerate() { + if span.start > span.end || span.end > prototype_visual_ids.len() { + return Err(AssetError::InvalidPrototype(format!( + "invalid prototype span for mission object {root_index}: {span:?}" + ))); + } + let mut ids = Vec::new(); + let mut dedup = HashSet::new(); + for index in span.clone() { + let visual_id = prototype_visual_ids[index]; + if dedup.insert(visual_id) { + ids.push(visual_id); + } } - plan.material_count += visual.material_count; - plan.texture_count += visual.texture_count; - plan.lightmap_count += visual.lightmap_count; + object_visuals.push(ids); } - Ok(plan) + Ok(MissionAssets { + visuals, + object_visuals, + }) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum PreparedVisualSignature { + Mesh { + archive: String, + name: Vec<u8>, + type_id: Option<u32>, + dependency_count: usize, + }, + NonGeometric { + dependency_count: usize, + }, +} + +fn prepared_visual_signature(proto: &EffectivePrototype) -> PreparedVisualSignature { + match &proto.geometry { + PrototypeGeometry::Mesh(key) => PreparedVisualSignature::Mesh { + archive: key.archive.as_str().to_string(), + name: key.name.0.clone(), + type_id: key.type_id, + dependency_count: proto.dependencies.len(), + }, + PrototypeGeometry::NonGeometric => PreparedVisualSignature::NonGeometric { + dependency_count: proto.dependencies.len(), + }, + } +} + +/// Extends a prototype dependency report with visual dependency failures. +/// +/// This function validates WEAR/material/TEXM/LIGHTMAP resolution for each resolved +/// prototype without constructing full immutable assets. +pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>( + repository: &R, + report: &mut PrototypeGraphReport, + graph: &PrototypeGraph, + prototypes: &[EffectivePrototype], +) { + let texture_archive = parse_path(TEXTURES_ARCHIVE).ok(); + let lightmap_archive = parse_path(LIGHTMAP_ARCHIVE).ok(); + + for (prototype_index, prototype) in prototypes.iter().enumerate() { + let PrototypeGeometry::Mesh(mesh) = &prototype.geometry else { + continue; + }; + report.mesh_dependency_count += prototype.dependencies.len(); + report.wear_request_count += 1; + + match resolve_wear_table(repository, mesh) { + Ok(table) => { + report.wear_resolved_count += 1; + report.material_slot_count += table.entries.len(); + for (material_index, _entry) in table.entries.iter().enumerate() { + let Ok(material_index) = u16::try_from(material_index) else { + push_visual_failure( + report, + graph, + prototype_index, + mesh.name.0.clone(), + PrototypeGraphEdge::WearToMaterial, + PrototypeGraphRequiredness::Required, + "material index does not fit archive format", + ); + continue; + }; + match resolve_material(repository, &table, material_index) { + Ok(material) => { + report.material_resolved_count += 1; + for texture in material.document.texture_requests() { + report.texture_request_count += 1; + match resolve_texm_from_candidates( + repository, + &texture, + [texture_archive.as_ref(), lightmap_archive.as_ref()], + ) { + Ok(()) => report.texture_resolved_count += 1, + Err(message) => push_visual_failure( + report, + graph, + prototype_index, + texture.0, + PrototypeGraphEdge::MaterialToTexture, + PrototypeGraphRequiredness::Required, + &message, + ), + } + } + } + Err(message) => push_visual_failure( + report, + graph, + prototype_index, + mesh.name.0.clone(), + PrototypeGraphEdge::WearToMaterial, + PrototypeGraphRequiredness::Required, + &message.to_string(), + ), + } + } + for lightmap in &table.lightmaps { + report.lightmap_request_count += 1; + match resolve_texm_from_candidates( + repository, + &lightmap.lightmap, + [lightmap_archive.as_ref(), texture_archive.as_ref()], + ) { + Ok(()) => report.lightmap_resolved_count += 1, + Err(message) => push_visual_failure( + report, + graph, + prototype_index, + lightmap.lightmap.0.clone(), + PrototypeGraphEdge::WearToLightmap, + PrototypeGraphRequiredness::Required, + &message, + ), + } + } + } + Err(message) => push_visual_failure( + report, + graph, + prototype_index, + mesh.name.0.clone(), + PrototypeGraphEdge::MeshToWear, + PrototypeGraphRequiredness::Required, + &message.to_string(), + ), + } + } } /// Validates a prototype visual without resolving cross-resource dependencies. @@ -231,6 +671,7 @@ pub fn prepare_visual(proto: &EffectivePrototype) -> Result<PreparedVisual, Asse model_slots: 0, model_batches: 0, material_count: 0, + material_ids: Vec::new(), texture_count: 0, lightmap_count: 0, }) @@ -246,6 +687,14 @@ pub fn prepare_visual_with_repository<R: ResourceRepository>( repository: &R, proto: &EffectivePrototype, ) -> Result<PreparedVisual, AssetError> { + prepare_visual_with_repository_internal(repository, proto, None) +} + +fn prepare_visual_with_repository_internal<R: ResourceRepository>( + repository: &R, + proto: &EffectivePrototype, + material_signature_by_id: Option<&mut HashMap<AssetId<PreparedMaterial>, Vec<u8>>>, +) -> Result<PreparedVisual, AssetError> { let PrototypeGeometry::Mesh(mesh_key) = &proto.geometry else { return prepare_visual(proto); }; @@ -254,9 +703,9 @@ pub fn prepare_visual_with_repository<R: ResourceRepository>( read_key(repository, mesh_key, Some("mesh"))?, ReadProfile::Compatible, ) - .map_err(|err| AssetError::Msh(err.to_string()))?; - let msh_document = decode_msh(&nres).map_err(|err| AssetError::Msh(err.to_string()))?; - let model = validate_msh(&msh_document).map_err(|err| AssetError::Msh(err.to_string()))?; + .map_err(AssetError::Nres)?; + let msh_document = decode_msh(&nres).map_err(AssetError::Msh)?; + let model = validate_msh(&msh_document).map_err(AssetError::Msh)?; let wear_name = sibling_name(mesh_key, "wea")?; let wear_key = ResourceKey { @@ -264,22 +713,44 @@ pub fn prepare_visual_with_repository<R: ResourceRepository>( name: wear_name, type_id: Some(WEAR_KIND), }; - let wear = decode_wear(&read_key(repository, &wear_key, Some("wear"))?) - .map_err(|err| AssetError::Material(err.to_string()))?; + let wear = decode_wear(&read_key(repository, &wear_key, Some("wear"))?).map_err(AssetError::Material)?; let mut material_count = 0; + let mut material_ids = Vec::with_capacity(wear.entries.len()); let mut texture_count = 0; let mut lightmap_count = 0; for material_index in 0..wear.entries.len() { let material_index = u16::try_from(material_index).map_err(|_| { - AssetError::Material("material index does not fit archive format".to_string()) + AssetError::InvalidPrototype("material index does not fit archive format".to_string()) })?; let material = resolve_material(repository, &wear, material_index) - .map_err(|err| AssetError::Material(err.to_string()))?; + .map_err(AssetError::Material)?; material_count += 1; + material_ids.push(AssetId::new(stable_material_id( + proto, + material_index, + &material.name, + ))); + let material_id = *material_ids + .last() + .expect("material id was appended immediately before collision check"); + if let Some(registry) = material_signature_by_id { + match registry.get(&material_id) { + Some(existing_name) => { + if existing_name != &material.name.0 { + return Err(AssetError::InvalidPrototype( + "stable material id collision between unrelated materials".to_string(), + )); + } + } + None => { + registry.insert(material_id, material.name.0.clone()); + } + } + } for texture in material.document.texture_requests() { - resolve_texture(repository, &texture)?; + resolve_texture(repository, &texture)?; texture_count += 1; } } @@ -296,6 +767,7 @@ pub fn prepare_visual_with_repository<R: ResourceRepository>( model_slots: model.slots.len(), model_batches: model.batches.len(), material_count, + material_ids, texture_count, lightmap_count, }) @@ -306,21 +778,267 @@ fn read_key<R: ResourceRepository>( key: &ResourceKey, label: Option<&str>, ) -> Result<Arc<[u8]>, AssetError> { + let label = label.unwrap_or("asset"); let handle = repository .open_archive(&key.archive) - .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}"))) + .map_err(|err| map_resource_error(label, key, err))? .and_then(|archive| { repository .find(archive, &key.name) - .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}"))) + .map_err(|err| map_resource_error(label, key, err)) })? - .ok_or_else(|| AssetError::MissingDependency(format!("{label:?} {key:?}")))?; + .ok_or_else(|| AssetError::MissingDependency(format!("{label}: {key:?}")))?; let bytes = repository .read(handle) - .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))?; + .map_err(|err| map_resource_error(label, key, err))?; Ok(Arc::from(bytes.into_owned())) } +fn map_resource_error( + label: &str, + key: &ResourceKey, + source: ResourceError, +) -> AssetError { + AssetError::Resource { + context: format!( + "{label}: archive={} entry={}", + key.archive.as_str(), + String::from_utf8_lossy(&key.name.0), + ), + source, + } +} + +fn resolve_wear_table<R: ResourceRepository>( + repository: &R, + mesh: &ResourceKey, +) -> Result<fparkan_material::WearTable, AssetError> { + let archive = repository + .open_archive(&mesh.archive) + .map_err(|err| map_resource_error("wear", mesh, err))?; + let wear_name = sibling_name(mesh, "wea")?; + let handle = repository + .find(archive, &wear_name) + .map_err(|err| { + map_resource_error( + "wear", + &ResourceKey { + archive: mesh.archive.clone(), + name: wear_name.clone(), + type_id: Some(WEAR_KIND), + }, + err, + ) + })? + .ok_or_else(|| { + AssetError::MissingDependency(format!( + "missing WEAR entry {}", + String::from_utf8_lossy(&wear_name.0) + )) + })?; + let info = repository + .entry_info(handle) + .map_err(|err| { + map_resource_error( + "wear", + &ResourceKey { + archive: mesh.archive.clone(), + name: wear_name.clone(), + type_id: Some(WEAR_KIND), + }, + err, + ) + })?; + if info.key.type_id != Some(WEAR_KIND) { + return Err(AssetError::InvalidPrototype(format!( + "entry {} is not WEAR", + String::from_utf8_lossy(&wear_name.0) + ))); + } + let bytes = repository + .read(handle) + .map_err(|err| { + map_resource_error( + "wear", + &ResourceKey { + archive: mesh.archive.clone(), + name: wear_name.clone(), + type_id: Some(WEAR_KIND), + }, + err, + ) + })? + .into_owned(); + decode_wear(&bytes).map_err(AssetError::Material) +} + +fn resolve_texm_from_candidates<'a, R: ResourceRepository>( + repository: &R, + texture: &ResourceName, + candidates: impl IntoIterator<Item = Option<&'a NormalizedPath>>, +) -> Result<(), AssetError> { + let mut missing_archive = false; + for path in candidates.into_iter().flatten() { + let key = ResourceKey { + archive: path.to_owned(), + name: texture.clone(), + type_id: None, + }; + let archive = match repository.open_archive(path) { + Ok(archive) => archive, + Err(ResourceError::MissingArchive) => { + missing_archive = true; + continue; + } + Err(err) => return Err(map_resource_error("texm", &key, err)), + }; + let Some(handle) = repository + .find(archive, texture) + .map_err(|err| map_resource_error("texm", &key, err))? + else { + continue; + }; + let bytes = repository + .read(handle) + .map_err(|err| map_resource_error("texm", &key, err))? + .into_owned(); + decode_texm(bytes).map_err(AssetError::Texture)?; + return Ok(()); + } + if missing_archive { + Err(AssetError::MissingDependency(format!( + "texm archive missing for {}", + String::from_utf8_lossy(&texture.0) + ))) + } else { + Err(AssetError::MissingDependency(format!( + "missing texm {}", + String::from_utf8_lossy(&texture.0) + ))) + } +} + +fn push_visual_failure( + report: &mut PrototypeGraphReport, + graph: &PrototypeGraph, + prototype_index: usize, + resource_raw: Vec<u8>, + edge: PrototypeGraphEdge, + requiredness: PrototypeGraphRequiredness, + message: &str, +) { + let root_index = root_index_for_prototype(graph, prototype_index); + let parent_edge = parent_edge_for_failure(graph, prototype_index, &edge); + let dependency = mesh_dependency_resource(graph, prototype_index); + report.failures.push(PrototypeGraphFailure { + root_index, + resource_raw, + edge, + message: message.to_string(), + requiredness, + provenance: Some(PrototypeGraphProvenance { + root_index, + parent_edge, + archive: dependency.map(|resource| resource.archive.as_str().to_string()), + resource: Some(resource_raw), + span: None, + }), + }) +} + +fn root_index_for_prototype(graph: &PrototypeGraph, prototype_index: usize) -> usize { + for (root_index, span) in graph.root_prototype_request_spans.iter().enumerate() { + if span.start <= prototype_index && prototype_index < span.end { + return root_index; + } + } + 0 +} + +fn parent_edge_for_failure( + graph: &PrototypeGraph, + prototype_index: usize, + edge: &PrototypeGraphEdge, +) -> Option<fparkan_prototype::PrototypeGraphEdgeId> { + let prototype_node_id = prototype_node_id(graph, prototype_index)?; + match edge { + PrototypeGraphEdge::MeshToWear + | PrototypeGraphEdge::WearToMaterial + | PrototypeGraphEdge::MaterialToTexture + | PrototypeGraphEdge::WearToLightmap => { + mesh_edge_id(graph, prototype_node_id).or_else(|| root_edge_id(graph, prototype_node_id)) + } + _ => root_edge_id(graph, prototype_node_id), + } +} + +fn prototype_node_id(graph: &PrototypeGraph, prototype_index: usize) -> Option<fparkan_prototype::PrototypeGraphNodeId> { + graph + .nodes + .iter() + .filter(|node| node.kind == PrototypeGraphNodeKind::Prototype) + .nth(prototype_index) + .map(|node| node.id) +} + +fn root_edge_id( + graph: &PrototypeGraph, + prototype_node: fparkan_prototype::PrototypeGraphNodeId, +) -> Option<fparkan_prototype::PrototypeGraphEdgeId> { + graph + .edges + .iter() + .find(|edge| { + edge.to == prototype_node + && matches!( + edge.kind, + fparkan_prototype::PrototypeGraphEdgeKind::MissionToRoot + | fparkan_prototype::PrototypeGraphEdgeKind::UnitDatToComponent + ) + }) + .map(|edge| edge.id) +} + +fn mesh_edge_id( + graph: &PrototypeGraph, + prototype_node: fparkan_prototype::PrototypeGraphNodeId, +) -> Option<fparkan_prototype::PrototypeGraphEdgeId> { + graph + .edges + .iter() + .find(|edge| { + edge.from == prototype_node + && matches!( + edge.kind, + fparkan_prototype::PrototypeGraphEdgeKind::PrototypeToMesh + ) + }) + .map(|edge| edge.id) +} + +fn mesh_dependency_resource( + graph: &PrototypeGraph, + prototype_index: usize, +) -> Option<&fparkan_resource::ResourceKey> { + let prototype_node = prototype_node_id(graph, prototype_index)?; + let mesh_node = graph + .edges + .iter() + .find(|edge| { + edge.from == prototype_node + && matches!( + edge.kind, + fparkan_prototype::PrototypeGraphEdgeKind::PrototypeToMesh + ) + })? + .to; + graph + .nodes + .iter() + .find(|node| node.id == mesh_node) + .and_then(|node| node.resource.as_ref()) +} + fn resolve_texture<R: ResourceRepository>( repository: &R, name: &ResourceName, @@ -351,7 +1069,7 @@ fn resolve_texm<R: ResourceRepository>( }; decode_texm(bytes) .map(|_| ()) - .map_err(|err| AssetError::Texture(err.to_string())) + .map_err(AssetError::Texture) } fn read_optional_key<R: ResourceRepository>( @@ -362,17 +1080,26 @@ fn read_optional_key<R: ResourceRepository>( let archive = match repository.open_archive(&key.archive) { Ok(archive) => archive, Err(ResourceError::MissingArchive | ResourceError::MissingEntry) => return Ok(None), - Err(err) => return Err(AssetError::Resource(format!("{label:?} {key:?}: {err}"))), + Err(err) => { + let label = label.unwrap_or("asset"); + return Err(map_resource_error(label, key, err)) + } }; let Some(handle) = repository .find(archive, &key.name) - .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))? + .map_err(|err| { + let label = label.unwrap_or("asset"); + map_resource_error(label, key, err) + })? else { return Ok(None); }; let bytes = repository .read(handle) - .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))?; + .map_err(|err| { + let label = label.unwrap_or("asset"); + map_resource_error(label, key, err) + })?; Ok(Some(Arc::from(bytes.into_owned()))) } @@ -407,6 +1134,18 @@ fn stable_visual_id(proto: &EffectivePrototype) -> u64 { hasher.finish() } +fn stable_material_id( + proto: &EffectivePrototype, + material_index: u16, + material_name: &ResourceName, +) -> u64 { + let mut hasher = StableHasher::default(); + stable_visual_id(proto).hash(&mut hasher); + material_index.hash(&mut hasher); + material_name.0.hash(&mut hasher); + hasher.finish() +} + fn parse_path(value: &str) -> Result<NormalizedPath, AssetError> { normalize_relative(value.as_bytes(), PathPolicy::HostCompatible) .map_err(|err| AssetError::InvalidPrototype(format!("{err}"))) diff --git a/crates/fparkan-corpus/Cargo.toml b/crates/fparkan-corpus/Cargo.toml index 552870d..e9285a8 100644 --- a/crates/fparkan-corpus/Cargo.toml +++ b/crates/fparkan-corpus/Cargo.toml @@ -7,8 +7,16 @@ repository.workspace = true [dependencies] fparkan-binary = { path = "../fparkan-binary" } +fparkan-fx = { path = "../fparkan-fx" } +fparkan-material = { path = "../fparkan-material" } +fparkan-msh = { path = "../fparkan-msh" } +fparkan-mission-format = { path = "../fparkan-mission-format" } fparkan-nres = { path = "../fparkan-nres" } +fparkan-prototype = { path = "../fparkan-prototype" } fparkan-path = { path = "../fparkan-path" } +fparkan-rsli = { path = "../fparkan-rsli" } +fparkan-texm = { path = "../fparkan-texm" } +fparkan-terrain-format = { path = "../fparkan-terrain-format" } [lints] workspace = true diff --git a/crates/fparkan-corpus/src/lib.rs b/crates/fparkan-corpus/src/lib.rs index 460bbbf..f923841 100644 --- a/crates/fparkan-corpus/src/lib.rs +++ b/crates/fparkan-corpus/src/lib.rs @@ -2,7 +2,16 @@ //! Licensed corpus discovery and aggregate reports. use fparkan_binary::{sha256, sha256_hex, Sha256Digest}; +use fparkan_fx::{decode_fxid, FXID_KIND}; +use fparkan_material::{decode_mat0, decode_wear, MAT0_KIND, WEAR_KIND}; +use fparkan_msh::{decode_msh, validate_msh}; +use fparkan_mission_format::{decode_tma, TmaProfile}; +use fparkan_nres::NresDocument; use fparkan_path::{ascii_lookup_key, normalize_relative, PathPolicy}; +use fparkan_prototype::{decode_unit_dat, decode_unit_dat_binding}; +use fparkan_rsli::{decode as decode_rsli, ReadProfile}; +use fparkan_texm::decode_texm; +use fparkan_terrain_format::{decode_land_map, decode_land_msh}; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::fs; @@ -10,6 +19,8 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Arc; +const TEXM_KIND: u32 = 0x6d78_6554; + /// Corpus kind. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CorpusKind { @@ -336,7 +347,6 @@ fn inspect_report_file( } }; if bytes.starts_with(b"NRes") { - variant = "nres".to_string(); bump(metrics, "nres_files", 1); if let Err(message) = inspect_nres_metrics(bytes, metrics) { return CorpusFileRecord { @@ -346,9 +356,52 @@ fn inspect_report_file( message: Some(message), }; } + if variant == "land_msh" && let Err(message) = inspect_land_metrics(&bytes, false) { + return CorpusFileRecord { + path: entry.path.clone(), + status: CorpusFileStatus::Error, + variant, + message: Some(message), + }; + } + if variant == "land_map" && let Err(message) = inspect_land_metrics(&bytes, true) { + return CorpusFileRecord { + path: entry.path.clone(), + status: CorpusFileStatus::Error, + variant, + message: Some(message), + }; + } } else if bytes.starts_with(b"NL") { variant = "rsli".to_string(); bump(metrics, "rsli_files", 1); + if let Err(message) = inspect_rsli_metrics(&bytes) { + return CorpusFileRecord { + path: entry.path.clone(), + status: CorpusFileStatus::Error, + variant, + message: Some(message), + }; + } + } else if lower.ends_with("data.tma") { + if let Err(message) = inspect_tma_metrics(&bytes) { + return CorpusFileRecord { + path: entry.path.clone(), + status: CorpusFileStatus::Error, + variant: "tma".to_string(), + message: Some(message), + }; + } + } else if has_extension(lower, "dat") && (lower.starts_with("units/") || lower.contains("/units/")) { + variant = "unit_dat".to_string(); + if let Err(message) = inspect_unit_dat_metrics(&bytes) { + return CorpusFileRecord { + path: entry.path.clone(), + status: CorpusFileStatus::Error, + variant, + message: Some(message), + }; + } } CorpusFileRecord { path: entry.path.clone(), @@ -380,25 +433,30 @@ fn inspect_path_metrics(lower: &str, metrics: &mut BTreeMap<String, u64>) -> Str } fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) -> Result<(), String> { - let entries = inspect_nres_entries(bytes)?; - bump(metrics, "nres_entries", entries.len() as u64); - for entry in entries { + let document = inspect_nres_document(&bytes)?; + bump(metrics, "nres_entries", document.entries().len() as u64); + for entry in document.entries() { let name = String::from_utf8_lossy(entry.name_bytes()).to_ascii_lowercase(); if has_extension(&name, "msh") { bump(metrics, "msh_entries", 1); + validate_nres_msh_payload(&document, entry)?; } match entry.meta().type_id { - 0x3054_414D => { + MAT0_KIND => { bump(metrics, "mat0_entries", 1); + validate_nres_mat0_payload(&document, entry)?; } - 0x6D78_6554 => { + TEXM_KIND => { bump(metrics, "texm_entries", 1); + validate_nres_texm_payload(&document, entry)?; } - 0x4449_5846 => { + FXID_KIND => { bump(metrics, "fxid_entries", 1); + validate_nres_fxid_payload(&document, entry)?; } - 0x5241_4557 => { + WEAR_KIND => { bump(metrics, "wear_entries", 1); + validate_nres_wear_payload(&document, entry)?; } _ => {} } @@ -406,6 +464,94 @@ fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) -> Ok(()) } +fn validate_nres_msh_payload(document: &NresDocument, entry: &fparkan_nres::NresEntry) -> Result<(), String> { + let payload = document.payload(entry.id()).map_err(|err| err.to_string())?; + let nested = fparkan_nres::decode( + Arc::from(payload.to_vec().into_boxed_slice()), + fparkan_nres::ReadProfile::Compatible, + ) + .map_err(|err| err.to_string())?; + let model = decode_msh(&nested).map_err(|err| err.to_string())?; + validate_msh(&model).map_err(|err| err.to_string())?; + Ok(()) +} + +fn validate_nres_mat0_payload( + document: &NresDocument, + entry: &fparkan_nres::NresEntry, +) -> Result<(), String> { + let payload = document.payload(entry.id()).map_err(|err| err.to_string())?; + decode_mat0(payload, entry.meta().attr2).map_err(|err| err.to_string())?; + Ok(()) +} + +fn validate_nres_wear_payload( + document: &NresDocument, + entry: &fparkan_nres::NresEntry, +) -> Result<(), String> { + let payload = document.payload(entry.id()).map_err(|err| err.to_string())?; + decode_wear(payload).map_err(|err| err.to_string())?; + Ok(()) +} + +fn validate_nres_texm_payload( + document: &NresDocument, + entry: &fparkan_nres::NresEntry, +) -> Result<(), String> { + let payload = document.payload(entry.id()).map_err(|err| err.to_string())?; + decode_texm(Arc::from(payload.to_vec().into_boxed_slice())).map_err(|err| err.to_string())?; + Ok(()) +} + +fn validate_nres_fxid_payload( + document: &NresDocument, + entry: &fparkan_nres::NresEntry, +) -> Result<(), String> { + let payload = document.payload(entry.id()).map_err(|err| err.to_string())?; + decode_fxid(Arc::from(payload.to_vec().into_boxed_slice())).map_err(|err| err.to_string())?; + Ok(()) +} + +fn inspect_rsli_metrics(bytes: &[u8]) -> Result<(), String> { + let _ = decode_rsli( + Arc::from(bytes.to_vec().into_boxed_slice()), + ReadProfile::Compatible, + ) + .map_err(|err| err.to_string())?; + Ok(()) +} + +fn inspect_tma_metrics(bytes: &[u8]) -> Result<(), String> { + let _ = decode_tma(Arc::from(bytes.to_vec().into_boxed_slice()), TmaProfile::Strict) + .map_err(|err| err.to_string())?; + Ok(()) +} + +fn inspect_unit_dat_metrics(bytes: &[u8]) -> Result<(), String> { + if decode_unit_dat(bytes).is_err() && decode_unit_dat_binding(bytes).is_err() { + return Err("failed to parse unit.dat payload as unit or binding format".to_string()); + } + Ok(()) +} + +fn inspect_land_metrics(bytes: &[u8], is_map: bool) -> Result<(), String> { + let document = inspect_nres_document(bytes)?; + if is_map { + decode_land_map(&document).map_err(|err| err.to_string())?; + } else { + decode_land_msh(&document).map_err(|err| err.to_string())?; + } + Ok(()) +} + +fn inspect_nres_document(bytes: &[u8]) -> Result<NresDocument, String> { + fparkan_nres::decode( + Arc::from(bytes.to_vec().into_boxed_slice()), + fparkan_nres::ReadProfile::Compatible, + ) + .map_err(|err| err.to_string()) +} + fn bump(metrics: &mut BTreeMap<String, u64>, key: &str, delta: u64) { if let Some(value) = metrics.get_mut(key) { *value = value.saturating_add(delta); @@ -418,15 +564,6 @@ fn has_extension(path: &str, expected: &str) -> bool { .is_some_and(|extension| extension.eq_ignore_ascii_case(expected)) } -fn inspect_nres_entries(bytes: Vec<u8>) -> Result<Vec<fparkan_nres::NresEntry>, String> { - let document = fparkan_nres::decode( - Arc::from(bytes.into_boxed_slice()), - fparkan_nres::ReadProfile::Compatible, - ) - .map_err(|err| err.to_string())?; - Ok(document.entries().to_vec()) -} - /// Computes stable manifest fingerprint. #[must_use] pub fn fingerprint(manifest: &CorpusManifest) -> Sha256Digest { @@ -699,6 +836,116 @@ mod tests { } #[test] + fn report_land_map_paths_use_production_land_parser() { + let root = temp_dir("report-land-map"); + fs::write(root.join("WORLD/MAP/land.map"), build_nres(&[])).expect("land map"); + let manifest = CorpusManifest { + kind: CorpusKind::Unknown, + files: vec![ManifestEntry { + path: "WORLD/MAP/land.map".to_string(), + size: 16, + hash: sha256(b"land.map"), + }], + casefold_collisions: Vec::new(), + }; + + let report = report(&root, &manifest).expect("report"); + + assert_eq!(report.failures, 1); + assert_eq!(report.records[0].status, CorpusFileStatus::Error); + assert_eq!(report.records[0].variant, "land_map"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn report_land_msh_paths_use_production_land_parser() { + let root = temp_dir("report-land-msh"); + fs::write(root.join("WORLD/MAP/land.msh"), build_nres(&[])).expect("land msh"); + let manifest = CorpusManifest { + kind: CorpusKind::Unknown, + files: vec![ManifestEntry { + path: "WORLD/MAP/land.msh".to_string(), + size: 16, + hash: sha256(b"land.msh"), + }], + casefold_collisions: Vec::new(), + }; + + let report = report(&root, &manifest).expect("report"); + + assert_eq!(report.failures, 1); + assert_eq!(report.records[0].status, CorpusFileStatus::Error); + assert_eq!(report.records[0].variant, "land_msh"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn report_tma_paths_use_production_tma_parser() { + let root = temp_dir("report-tma"); + fs::write(root.join("MISSIONS/test/data.tma"), b"malformed tma").expect("tma"); + let manifest = CorpusManifest { + kind: CorpusKind::Unknown, + files: vec![ManifestEntry { + path: "MISSIONS/test/data.tma".to_string(), + size: 12, + hash: sha256(b"malformed tma"), + }], + casefold_collisions: Vec::new(), + }; + + let report = report(&root, &manifest).expect("report"); + + assert_eq!(report.failures, 1); + assert_eq!(report.records[0].status, CorpusFileStatus::Error); + assert_eq!(report.records[0].variant, "tma"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn report_unit_dat_paths_use_production_unit_parser() { + let root = temp_dir("report-unit"); + fs::write(root.join("units/unit.dat"), vec![0u8; 120]).expect("unit"); + let manifest = CorpusManifest { + kind: CorpusKind::Unknown, + files: vec![ManifestEntry { + path: "units/unit.dat".to_string(), + size: 120, + hash: sha256(&[0u8; 120]), + }], + casefold_collisions: Vec::new(), + }; + + let report = report(&root, &manifest).expect("report"); + + assert_eq!(report.failures, 0); + assert_eq!(report.records[0].status, CorpusFileStatus::Ok); + assert_eq!(report.records[0].variant, "unit_dat"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn report_rsli_paths_use_production_rsli_parser() { + let root = temp_dir("report-rsli"); + fs::write(root.join("patch.nl"), b"NL malformed").expect("rsli"); + let manifest = CorpusManifest { + kind: CorpusKind::Unknown, + files: vec![ManifestEntry { + path: "patch.nl".to_string(), + size: 12, + hash: sha256(b"NL malformed"), + }], + casefold_collisions: Vec::new(), + }; + + let report = report(&root, &manifest).expect("report"); + + assert_eq!(report.failures, 1); + assert_eq!(report.records[0].status, CorpusFileStatus::Error); + assert_eq!(report.records[0].variant, "rsli"); + let _ = fs::remove_dir_all(root); + } + + #[test] fn deterministic_traversal_is_creation_order_independent() { let first = temp_dir("order-first"); let second = temp_dir("order-second"); diff --git a/crates/fparkan-diagnostics/Cargo.toml b/crates/fparkan-diagnostics/Cargo.toml index 8e7b1bd..59b8273 100644 --- a/crates/fparkan-diagnostics/Cargo.toml +++ b/crates/fparkan-diagnostics/Cargo.toml @@ -6,6 +6,8 @@ license.workspace = true repository.workspace = true [dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" [lints] workspace = true diff --git a/crates/fparkan-diagnostics/src/lib.rs b/crates/fparkan-diagnostics/src/lib.rs index 8b3e160..2131336 100644 --- a/crates/fparkan-diagnostics/src/lib.rs +++ b/crates/fparkan-diagnostics/src/lib.rs @@ -1,8 +1,11 @@ #![forbid(unsafe_code)] //! Structured diagnostics shared by `FParkan` crates. +use serde::Serialize; + /// Diagnostic severity. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] pub enum Severity { /// Informational note. Info, @@ -15,7 +18,8 @@ pub enum Severity { } /// Evidence level for a contract or interpretation. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] pub enum EvidenceStatus { /// Described by project documentation. Documented, @@ -30,7 +34,8 @@ pub enum EvidenceStatus { } /// Operation phase where a diagnostic was produced. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] pub enum Phase { /// Discovery. Discover, @@ -55,7 +60,7 @@ pub enum Phase { } /// Byte span in an input source. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] pub struct SourceSpan { /// Start offset. pub offset: u64, @@ -64,11 +69,11 @@ pub struct SourceSpan { } /// Stable diagnostic code. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)] pub struct DiagnosticCode(pub &'static str); /// Context attached to a diagnostic. -#[derive(Clone, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] pub struct DiagnosticContext { /// Phase. pub phase: Option<Phase>, @@ -83,7 +88,7 @@ pub struct DiagnosticContext { } /// Structured diagnostic with cause chain. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct Diagnostic { /// Stable code. pub code: DiagnosticCode, @@ -145,104 +150,13 @@ pub fn render_human(diagnostic: &Diagnostic) -> String { out } -/// Renders deterministic JSON without requiring a serialization dependency. +/// Renders deterministic JSON using the typed diagnostic schema. #[must_use] pub fn render_json(diagnostic: &Diagnostic) -> String { - fn esc(value: &str) -> String { - let mut out = String::with_capacity(value.len() + 2); - for ch in value.chars() { - match ch { - '\\' => out.push_str("\\\\"), - '"' => out.push_str("\\\""), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - _ => out.push(ch), - } - } - out - } - - let mut out = String::new(); - out.push('{'); - out.push_str("\"code\":\""); - out.push_str(&esc(diagnostic.code.0)); - out.push_str("\",\"severity\":\""); - out.push_str(match diagnostic.severity { - Severity::Info => "info", - Severity::Warning => "warning", - Severity::Error => "error", - Severity::Fatal => "fatal", - }); - out.push_str("\",\"message\":\""); - out.push_str(&esc(&diagnostic.message)); - out.push_str("\",\"context\":{"); - if let Some(phase) = diagnostic.context.phase { - out.push_str("\"phase\":\""); - out.push_str(match phase { - Phase::Discover => "discover", - Phase::Read => "read", - Phase::Parse => "parse", - Phase::Validate => "validate", - Phase::Resolve => "resolve", - Phase::Prepare => "prepare", - Phase::Construct => "construct", - Phase::Register => "register", - Phase::Simulate => "simulate", - Phase::Render => "render", - }); - out.push('"'); - } - if let Some(path) = &diagnostic.context.path { - if diagnostic.context.phase.is_some() { - out.push(','); - } - out.push_str("\"path\":\""); - out.push_str(&esc(path)); - out.push('"'); - } - if let Some(entry) = &diagnostic.context.archive_entry { - if diagnostic.context.phase.is_some() || diagnostic.context.path.is_some() { - out.push(','); - } - out.push_str("\"archive_entry\":\""); - out.push_str(&esc(entry)); - out.push('"'); + match serde_json::to_string(diagnostic) { + Ok(json) => json, + Err(err) => format!("{{\"error\":\"diagnostic serialization failed: {err}\"}}"), } - if let Some(key) = &diagnostic.context.object_key { - if diagnostic.context.phase.is_some() - || diagnostic.context.path.is_some() - || diagnostic.context.archive_entry.is_some() - { - out.push(','); - } - out.push_str("\"object_key\":\""); - out.push_str(&esc(key)); - out.push('"'); - } - if let Some(span) = diagnostic.context.span { - if diagnostic.context.phase.is_some() - || diagnostic.context.path.is_some() - || diagnostic.context.archive_entry.is_some() - || diagnostic.context.object_key.is_some() - { - out.push(','); - } - out.push_str("\"span\":{\"offset\":"); - out.push_str(&span.offset.to_string()); - out.push_str(",\"length\":"); - out.push_str(&span.length.to_string()); - out.push('}'); - } - out.push_str("},\"causes\":["); - for (idx, cause) in diagnostic.causes.iter().enumerate() { - if idx > 0 { - out.push(','); - } - out.push_str(&render_json(cause)); - } - out.push_str("]}"); - out } #[cfg(test)] @@ -298,4 +212,14 @@ mod tests { assert!(json.contains("\"code\":\"CAUSE\"")); assert!(json.contains("\"span\":{\"offset\":16,\"length\":8}")); } + + #[test] + fn json_escapes_all_control_characters() { + let value = diagnostic(DiagnosticCode("S1-H01"), "quote\"\u{0000}tab\tline\r\n"); + let json = render_json(&value); + assert!(json.contains("\\u0000")); + assert!(json.contains("\\u0009")); + assert!(!json.contains('\t')); + assert!(!json.contains('\r')); + } } diff --git a/crates/fparkan-inspection/Cargo.toml b/crates/fparkan-inspection/Cargo.toml new file mode 100644 index 0000000..4f35ecd --- /dev/null +++ b/crates/fparkan-inspection/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "fparkan-inspection" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-msh = { path = "../fparkan-msh" } +fparkan-nres = { path = "../fparkan-nres" } +fparkan-rsli = { path = "../fparkan-rsli" } +fparkan-resource = { path = "../fparkan-resource" } +fparkan-terrain-format = { path = "../fparkan-terrain-format" } +fparkan-texm = { path = "../fparkan-texm" } +fparkan-vfs = { path = "../fparkan-vfs" } + +[lints] +workspace = true diff --git a/crates/fparkan-inspection/src/lib.rs b/crates/fparkan-inspection/src/lib.rs new file mode 100644 index 0000000..0b35ad6 --- /dev/null +++ b/crates/fparkan-inspection/src/lib.rs @@ -0,0 +1,286 @@ +#![forbid(unsafe_code)] +//! Shared inspection helpers for format-backed tooling. + +use fparkan_msh::{decode_msh, validate_msh}; +use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile}; +use fparkan_resource::{archive_path, resource_name, CachedResourceRepository}; +use fparkan_rsli::decode as decode_rsli; +use fparkan_terrain_format::{decode_land_map, decode_land_msh}; +use fparkan_texm::decode_texm; +use fparkan_vfs::{DirectoryVfs, Vfs}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// Archive inspection variants. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ArchiveInspection { + /// NRes inspection summary. + Nres { + /// Archive entry count. + entries: usize, + /// Lookup order validity. + lookup_order_valid: bool, + /// Entry samples (subject to request limit). + sample: Vec<NresEntrySummary>, + }, + /// RsLi inspection summary. + Rsli { + /// Archive entry count. + entries: usize, + }, + /// Unknown/unsupported archive magic. + Unsupported, +} + +/// NRes entry summary. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NresEntrySummary { + /// ASCII/legacy resource name. + pub name: String, + /// Entry type identifier. + pub type_id: u32, + /// Declared entry payload size. + pub data_size: u32, +} + +/// Model inspection payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ModelInspection { + /// Terrain stream/document stream count. + pub streams: usize, + /// Node count. + pub nodes: usize, + /// Slot count. + pub slots: usize, + /// Position count. + pub positions: usize, + /// Index count. + pub indices: usize, + /// Batch count. + pub batches: usize, +} + +/// Texture inspection payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TextureInspection { + /// Width. + pub width: u32, + /// Height. + pub height: u32, + /// Texture format debug text. + pub format: String, + /// Mip level count. + pub mips: usize, + /// Total page rectangles. + pub pages: usize, +} + +/// Land map/msh inspection payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MapInspection { + /// Mapped mesh stream count. + pub streams: usize, + /// Slot count. + pub slots: usize, + /// Position count. + pub positions: usize, + /// Face count. + pub faces: usize, + /// Terrain areals. + pub areals: usize, + /// Declared areal count from map metadata. + pub declared_areals: u32, + /// Map grid width. + pub grid_width: u32, + /// Map grid height. + pub grid_height: u32, +} + +/// Supported land file kinds. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LandFileKind { + /// `land.msh` payload. + LandMsh, + /// `land.map` payload. + LandMap, +} + +/// Inspects a format archive. +pub fn inspect_archive_file(path: &Path, sample_limit: usize) -> Result<ArchiveInspection, String> { + let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?; + inspect_archive_bytes(&bytes, sample_limit, Some(path)) +} + +/// Inspects archive bytes and returns a typed summary. +fn inspect_archive_bytes( + bytes: &[u8], + sample_limit: usize, + source: Option<&Path>, +) -> Result<ArchiveInspection, String> { + if bytes.starts_with(b"NRes") { + let document = decode_nres( + Arc::from(bytes.to_vec().into_boxed_slice()), + ReadProfile::Compatible, + ) + .map_err(|err| err.to_string())?; + let mut sample = Vec::new(); + for entry in document.entries().iter().take(sample_limit) { + sample.push(NresEntrySummary { + name: String::from_utf8_lossy(entry.name_bytes()).to_string(), + type_id: entry.meta().type_id, + data_size: entry.meta().data_size, + }); + } + Ok(ArchiveInspection::Nres { + entries: document.entries().len(), + lookup_order_valid: document.lookup_order_valid(), + sample, + }) + } else if bytes.get(0..4) == Some(b"NL\0\x01") { + let document = decode_rsli(Arc::from(bytes.to_vec().into_boxed_slice()), fparkan_rsli::ReadProfile::Compatible) + .map_err(|err| err.to_string())?; + Ok(ArchiveInspection::Rsli { + entries: document.entries().len(), + }) + } else { + match source { + Some(path) => Err(format!("{}: unsupported archive magic", path.display())), + None => Err("unsupported archive magic".to_string()), + } + } +} + +/// Inspects a model through repository-backed resource lookup. +pub fn inspect_model_from_root( + root: &Path, + archive: &str, + resource: &str, +) -> Result<ModelInspection, String> { + let bytes = read_resource_bytes(root, archive, resource)?; + let document = decode_nres(bytes, ReadProfile::Compatible).map_err(|err| err.to_string())?; + let msh = decode_msh(&document).map_err(|err| err.to_string())?; + let validated = validate_msh(&msh).map_err(|err| err.to_string())?; + Ok(ModelInspection { + streams: msh.streams().len(), + nodes: validated.node_count, + slots: validated.slots.len(), + positions: validated.positions.len(), + indices: validated.indices.len(), + batches: validated.batches.len(), + }) +} + +/// Inspects a texture through repository-backed resource lookup. +pub fn inspect_texture_from_root( + root: &Path, + archive: &str, + resource: &str, +) -> Result<TextureInspection, String> { + let bytes = read_resource_bytes(root, archive, resource)?; + let document = decode_texm(bytes).map_err(|err| err.to_string())?; + Ok(TextureInspection { + width: document.width(), + height: document.height(), + format: format!("{:?}", document.format()), + mips: document.mip_count(), + pages: document.page_rects().len(), + }) +} + +/// Inspects a terrain land file by path. +pub fn inspect_land_file(path: &Path, kind: LandFileKind) -> Result<MapInspection, String> { + let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?; + let document = decode_nres( + Arc::from(bytes.into_boxed_slice()), + ReadProfile::Compatible, + ) + .map_err(|err| err.to_string())?; + match kind { + LandFileKind::LandMsh => inspect_land_msh(&document), + LandFileKind::LandMap => inspect_land_map(&document), + } +} + +fn inspect_land_msh(document: &NresDocument) -> Result<MapInspection, String> { + let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?; + Ok(MapInspection { + streams: land_msh.streams.len(), + slots: land_msh.slots.slots_raw.len(), + positions: land_msh.positions.len(), + faces: land_msh.faces.len(), + areals: 0, + declared_areals: 0, + grid_width: 0, + grid_height: 0, + }) +} + +fn inspect_land_map(document: &NresDocument) -> Result<MapInspection, String> { + let land_map = decode_land_map(document).map_err(|err| err.to_string())?; + Ok(MapInspection { + streams: 0, + slots: 0, + positions: 0, + faces: 0, + areals: land_map.areals.len(), + declared_areals: land_map.areal_count, + grid_width: land_map.grid.cells_x, + grid_height: land_map.grid.cells_y, + }) +} + +fn read_resource_bytes(root: &Path, archive: &str, name: &str) -> Result<Arc<[u8]>, String> { + let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root))); + let archive_path = archive_path(archive.as_bytes()).map_err(|err| err.to_string())?; + let resource_name = resource_name(name.as_bytes()); + let archive_handle = repository + .open_archive(&archive_path) + .map_err(|err| format!("{err}"))?; + let Some(handle) = repository + .find(archive_handle, &resource_name) + .map_err(|err| format!("{err}"))? + else { + return Err(format!( + "resource not found: {archive}/{}", + String::from_utf8_lossy(name.as_bytes()) + )); + }; + let bytes = repository.read(handle).map_err(|err| format!("{err}"))?; + Ok(Arc::from(bytes.into_owned())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + + #[test] + fn inspect_rsli_counts_entries() { + let dir = temp_dir("inspect"); + let path = dir.join("test.rsli"); + let mut file = fs::File::create(&path).expect("file"); + file.write_all(b"NL\0\x01").expect("magic"); + drop(file); + + let inspection = inspect_archive_file(&path, 0).expect("inspect"); + assert!(matches!(inspection, ArchiveInspection::Rsli { entries: 0 })); + } + + #[test] + fn nres_entry_summary_fields_are_readable() { + let dir = temp_dir("inspect-nres"); + let archive = dir.join("test.nres"); + let payload = Vec::from("NRes\x00\x00\x00\x00"); + fs::write(&archive, &payload).expect("nres"); + + let _ = inspect_archive_file(&archive, 2); + } + + fn temp_dir(name: &str) -> PathBuf { + let base = PathBuf::from("/tmp").join("fparkan-inspection-tests").join(name); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).expect("tmp dir"); + base + } +} diff --git a/crates/fparkan-path/src/lib.rs b/crates/fparkan-path/src/lib.rs index 330b03a..14cd0f1 100644 --- a/crates/fparkan-path/src/lib.rs +++ b/crates/fparkan-path/src/lib.rs @@ -24,13 +24,28 @@ impl OriginalPathBytes { /// Normalized relative path. #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] -pub struct NormalizedPath(String); +pub struct NormalizedPath { + raw: Vec<u8>, + display: String, +} impl NormalizedPath { /// Returns string view. #[must_use] pub fn as_str(&self) -> &str { - &self.0 + &self.display + } + + /// Returns normalized byte view. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + &self.raw + } + + /// Returns an OS path owned path buffer. + #[must_use] + pub fn as_path(&self) -> PathBuf { + as_os_path_from_bytes(&self.raw) } } @@ -91,8 +106,6 @@ pub enum PathError { ParentTraversal, /// Host path escape. EscapesRoot, - /// Invalid UTF-8 after normalization. - InvalidUtf8, } impl fmt::Display for PathError { @@ -103,7 +116,6 @@ impl fmt::Display for PathError { Self::Absolute => write!(f, "path must be relative and cannot be absolute"), Self::ParentTraversal => write!(f, "path attempts to traverse outside its root"), Self::EscapesRoot => write!(f, "normalized path escapes the configured root"), - Self::InvalidUtf8 => write!(f, "path is not valid UTF-8 after normalization"), } } } @@ -115,8 +127,7 @@ impl std::error::Error for PathError {} /// # Errors /// /// Returns [`PathError`] when the input is empty, absolute, contains an -/// embedded NUL, attempts parent traversal, or is not valid UTF-8 after -/// legacy separator normalization. +/// embedded NUL, attempts parent traversal, or has an invalid drive prefix. pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPath, PathError> { if raw.is_empty() { return Err(PathError::Empty); @@ -124,22 +135,21 @@ pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPa if raw.contains(&0) { return Err(PathError::EmbeddedNul); } - let text = std::str::from_utf8(raw).map_err(|_| PathError::InvalidUtf8)?; - if text.starts_with('/') || text.starts_with('\\') || has_drive_prefix(text) { + if raw.starts_with(b"/") || raw.starts_with(b"\\") || has_drive_prefix(raw) { return Err(PathError::Absolute); } let mut parts = Vec::new(); - for part in text.split(['/', '\\']) { - if part.is_empty() || part == "." { + for part in raw.split(|byte| *byte == b'/' || *byte == b'\\') { + if part.is_empty() || part == b"." { if policy == PathPolicy::StrictLegacy { return Err(PathError::ParentTraversal); } continue; } - if part == ".." { + if part == b".." { return Err(PathError::ParentTraversal); } - if policy == PathPolicy::StrictLegacy && part.contains(':') { + if policy == PathPolicy::StrictLegacy && part.contains(&b':') { return Err(PathError::Absolute); } parts.push(part); @@ -147,7 +157,17 @@ pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPa if parts.is_empty() { return Err(PathError::Empty); } - Ok(NormalizedPath(parts.join("/"))) + let mut normalized = Vec::new(); + for (index, part) in parts.iter().enumerate() { + if index > 0 { + normalized.push(b'/'); + } + normalized.extend_from_slice(part); + } + Ok(NormalizedPath { + raw: normalized, + display: String::from_utf8_lossy(&normalized).into_owned(), + }) } /// Normalizes a relative path while preserving its original bytes. @@ -166,8 +186,7 @@ pub fn normalize_relative_with_original( }) } -fn has_drive_prefix(text: &str) -> bool { - let bytes = text.as_bytes(); +fn has_drive_prefix(bytes: &[u8]) -> bool { bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() } @@ -184,7 +203,11 @@ pub fn ascii_lookup_key(raw: &[u8]) -> LookupKey { /// Returns [`PathError::ParentTraversal`] when a normalized segment attempts /// to address a parent directory. pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> { - if rel.0.split('/').any(|part| part == "..") { + if rel + .as_bytes() + .split(|byte| *byte == b'/') + .any(|part| part == b"..") + { Err(PathError::ParentTraversal) } else { Ok(()) @@ -198,7 +221,20 @@ pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> { /// Returns [`PathError`] if the normalized path fails the escape check. pub fn join_under(root: &Path, rel: &NormalizedPath) -> Result<PathBuf, PathError> { reject_escape(rel)?; - Ok(root.join(rel.as_str())) + Ok(root.join(rel.as_path())) +} + +#[cfg(unix)] +fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + PathBuf::from(OsString::from_vec(raw.to_vec())) +} + +#[cfg(not(unix))] +fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf { + PathBuf::from(String::from_utf8_lossy(raw).into_owned()) } #[cfg(test)] @@ -293,6 +329,14 @@ mod tests { } #[test] + fn accepts_non_utf8_legacy_bytes() { + let path = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible) + .expect("raw legacy bytes"); + + assert_eq!(path.as_str(), "DATA/\u{FFFD}.bin"); + } + + #[test] fn original_separators_and_raw_bytes_are_preserved() { let raw = b"DATA\\Maps/Intro\\Land.msh"; let path = normalize_relative_with_original(raw, PathPolicy::StrictLegacy).expect("path"); diff --git a/crates/fparkan-platform/src/lib.rs b/crates/fparkan-platform/src/lib.rs index cfa021b..bc908f4 100644 --- a/crates/fparkan-platform/src/lib.rs +++ b/crates/fparkan-platform/src/lib.rs @@ -1,11 +1,11 @@ #![forbid(unsafe_code)] -//! Platform ports for clocks, input, events, windows, and graphics requests. +//! Platform ports for clocks, event sources and window descriptors. -/// Monotonic instant. +/// Monotonic instant measured in milliseconds since process start. #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub struct MonotonicInstant(pub u64); -/// Monotonic clock. +/// Platform clock. pub trait MonotonicClock { /// Current instant. fn now(&self) -> MonotonicInstant; @@ -14,26 +14,74 @@ pub trait MonotonicClock { /// Platform event. #[derive(Clone, Debug, Eq, PartialEq)] pub enum PlatformEvent { - /// Quit requested. - Quit, + /// Window/application requested to quit. + QuitRequested, + /// Window focus changed. + FocusChanged { focused: bool }, + /// Window resize or move to a new drawable size. + Resize { width: u32, height: u32 }, + /// Device pixel ratio changed. + DpiChanged { scale: f64 }, + /// Window minimized/hidden. + Minimized { minimized: bool }, + /// Window occlusion state changed. + Occluded { occluded: bool }, + /// Window is being suspended. + Suspended, + /// Window resumed from suspend. + Resumed, + /// Keyboard/scancode input. + KeyboardInput { + /// Platform scancode. + scancode: u32, + /// Pressed state. + pressed: bool, + }, + /// Mouse button input. + MouseInput { + /// Mouse button code. + button: u16, + /// Pressed state. + pressed: bool, + /// X position in window coordinates. + x: f64, + /// Y position in window coordinates. + y: f64, + }, + /// Mouse cursor movement. + CursorMoved { + /// Cursor x. + x: f64, + /// Cursor y. + y: f64, + }, } -/// Platform error. +/// Platform error with optional source detail. #[derive(Debug)] pub enum PlatformError { - /// Backend failed. - Backend, + /// Backend/backend-specific failure. + Backend { + /// Operation or subsystem. + context: &'static str, + /// Human-readable details. + message: String, + }, } impl std::fmt::Display for PlatformError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{self:?}") + match self { + Self::Backend { context, message } => { + write!(f, "{context}: {message}") + } + } } } impl std::error::Error for PlatformError {} -/// Event source. +/// Event source contract for polling platform events. pub trait EventSource { /// Polls events. /// @@ -43,7 +91,7 @@ pub trait EventSource { fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError>; } -/// Physical size. +/// Physical window size. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct PhysicalSize { /// Width. @@ -52,42 +100,83 @@ pub struct PhysicalSize { pub height: u32, } -/// Window port. +/// Window identity as a stable opaque handle token. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub struct WindowHandle { + /// Opaque integer token. + pub id: u64, +} + +/// Window presentation and lifecycle port. +/// +/// Presentation is not owned by the window abstraction. Render adapters +/// own swapchain and present lifecycle. pub trait WindowPort { - /// Drawable size. + /// Current drawable size. fn drawable_size(&self) -> PhysicalSize; - /// Presents. - /// - /// # Errors - /// - /// Returns [`PlatformError`] when the backend cannot present the current - /// frame. - fn present(&mut self) -> Result<(), PlatformError>; + /// DPI scale for this window. + fn dpi_scale(&self) -> f64; + /// Whether the window is focused. + fn has_focus(&self) -> bool; + /// Whether the window is minimized. + fn is_minimized(&self) -> bool; + /// Whether the window is occluded. + fn is_occluded(&self) -> bool; + /// Opaque window identity. + fn handle(&self) -> WindowHandle; } -/// Graphics profile. +/// Render backend request contract. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum GraphicsProfile { - /// Desktop core. - DesktopCore, - /// Embedded profile. - Embedded, +pub struct RenderRequest { + /// Preferred color-space profile. + pub color_space: ColorSpace, + /// Preferred presentation mode. + pub presentation: PresentationMode, + /// Requested depth/stencil format. + pub depth: DepthStencilSupport, } -/// Version. +/// Color-space profile. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct Version { - /// Major. - pub major: u8, - /// Minor. - pub minor: u8, +pub enum ColorSpace { + /// sRGB nonlinear. + Srgb, + /// Linear color-space. + Linear, } -/// Graphics context request. +/// Presentation mode. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct GraphicsContextRequest { - /// Profile. - pub profile: GraphicsProfile, - /// Version. - pub version: Version, +pub enum PresentationMode { + /// VSync. + Fifo, + /// No VSync. + Immediate, + /// Triple-buffer mailbox fallback. + Mailbox, +} + +/// Depth/stencil support profile requested by the composition root. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct DepthStencilSupport { + /// Depth bits. + pub depth_bits: u8, + /// Stencil bits. + pub stencil_bits: u8, +} + +impl RenderRequest { + /// Returns a conservative default request. + #[must_use] + pub const fn conservative() -> Self { + Self { + color_space: ColorSpace::Srgb, + presentation: PresentationMode::Fifo, + depth: DepthStencilSupport { + depth_bits: 24, + stencil_bits: 8, + }, + } + } } diff --git a/crates/fparkan-prototype/Cargo.toml b/crates/fparkan-prototype/Cargo.toml index 4825faf..4d9b958 100644 --- a/crates/fparkan-prototype/Cargo.toml +++ b/crates/fparkan-prototype/Cargo.toml @@ -8,13 +8,12 @@ repository.workspace = true [dependencies] encoding_rs = "0.8" fparkan-binary = { path = "../fparkan-binary" } -fparkan-material = { path = "../fparkan-material" } -fparkan-msh = { path = "../fparkan-msh" } -fparkan-nres = { path = "../fparkan-nres" } fparkan-path = { path = "../fparkan-path" } fparkan-resource = { path = "../fparkan-resource" } -fparkan-texm = { path = "../fparkan-texm" } fparkan-vfs = { path = "../fparkan-vfs" } [lints] workspace = true + +[dev-dependencies] +fparkan-nres = { path = "../fparkan-nres" } diff --git a/crates/fparkan-prototype/src/lib.rs b/crates/fparkan-prototype/src/lib.rs index 32e736b..c05fd27 100644 --- a/crates/fparkan-prototype/src/lib.rs +++ b/crates/fparkan-prototype/src/lib.rs @@ -3,14 +3,10 @@ use encoding_rs::WINDOWS_1251; use fparkan_binary::{checked_count_bytes, Cursor, DecodeError}; -use fparkan_material::{decode_wear, resolve_material, WEAR_KIND}; -use fparkan_msh::{decode_msh, validate_msh, MshError}; -use fparkan_nres::ReadProfile; use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName}; use fparkan_resource::{ archive_path, resource_name, ResourceError, ResourceKey, ResourceRepository, }; -use fparkan_texm::decode_texm; use fparkan_vfs::{Vfs, VfsError}; use std::sync::Arc; @@ -111,6 +107,141 @@ pub struct PrototypeGraph { pub roots: Vec<PrototypeKey>, /// Effective prototype requests after unit DAT expansion. pub prototype_requests: Vec<PrototypeKey>, + /// Mission object-local spans of effective prototype requests. + pub root_prototype_request_spans: Vec<std::ops::Range<usize>>, + /// Materialized prototype dependency graph nodes. + pub nodes: Vec<PrototypeGraphNode>, + /// Materialized prototype dependency graph edges. + pub edges: Vec<PrototypeGraphEdgeInstance>, +} + +/// Stable node identifier. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct PrototypeGraphNodeId(pub u32); + +/// Stable edge identifier. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct PrototypeGraphEdgeId(pub u32); + +/// Edge requiredness/fallback policy for a graph dependency. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PrototypeGraphRequiredness { + /// Missing edge should fail mission load. + Required, + /// Missing edge is tolerated and handled by fallback policy. + Optional, + /// Edge was produced by an explicit fallback transition. + Fallback, +} + +/// Source provenance for graph construction and failures. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PrototypeGraphProvenance { + /// Root mission object index that initiated traversal. + pub root_index: usize, + /// Immediate parent edge that discovered this edge. + pub parent_edge: Option<PrototypeGraphEdgeId>, + /// Source archive when available. + pub archive: Option<String>, + /// Source resource key when available. + pub resource: Option<Vec<u8>>, + /// Byte span in the source archive entry when known. + pub span: Option<(u64, u64)>, +} + +/// Prototype graph node kind. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PrototypeGraphNodeKind { + /// Mission root key. + MissionRoot, + /// Unit DAT root key. + UnitDatRoot, + /// Resolved prototype request. + Prototype, + /// Mesh dependency. + MeshResource, + /// Non-geometric prototype. + NonGeometric, +} + +/// Prototype graph node record. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PrototypeGraphNode { + /// Stable identifier. + pub id: PrototypeGraphNodeId, + /// Node kind. + pub kind: PrototypeGraphNodeKind, + /// Optional logical key represented by node. + pub key: Option<PrototypeKey>, + /// Optional resource represented by node. + pub resource: Option<ResourceKey>, +} + +impl PrototypeGraphNode { + /// Creates a mesh resource node. + #[must_use] + pub const fn mesh(resource: ResourceKey, id: PrototypeGraphNodeId) -> Self { + Self { + id, + kind: PrototypeGraphNodeKind::MeshResource, + key: None, + resource: Some(resource), + } + } + + /// Creates a prototype node. + #[must_use] + pub const fn prototype(key: PrototypeKey, id: PrototypeGraphNodeId) -> Self { + Self { + id, + kind: PrototypeGraphNodeKind::Prototype, + key: Some(key), + resource: None, + } + } + + /// Creates a root node. + #[must_use] + pub const fn root(key: PrototypeKey, is_unit_dat: bool, id: PrototypeGraphNodeId) -> Self { + Self { + id, + kind: if is_unit_dat { + PrototypeGraphNodeKind::UnitDatRoot + } else { + PrototypeGraphNodeKind::MissionRoot + }, + key: Some(key), + resource: None, + } + } +} + +/// Prototype graph edge kind. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PrototypeGraphEdgeKind { + /// Mission root to resolved prototype. + MissionToRoot, + /// Unit component to prototype. + UnitDatToComponent, + /// Prototype to mesh dependency. + PrototypeToMesh, +} + +/// Prototype graph edge record. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PrototypeGraphEdgeInstance { + /// Stable identifier. + pub id: PrototypeGraphEdgeId, + /// Source node. + pub from: PrototypeGraphNodeId, + /// Destination node. + pub to: PrototypeGraphNodeId, + /// Edge kind. + pub kind: PrototypeGraphEdgeKind, + /// Requiredness semantics for this dependency. + pub requiredness: PrototypeGraphRequiredness, + /// Provenance for reproducible diagnostics and tracing. + pub provenance: Option<PrototypeGraphProvenance>, } /// Mission prototype dependency graph report. @@ -152,8 +283,28 @@ impl PrototypeGraphReport { /// Returns true when all reachable mission roots resolved. #[must_use] pub fn is_success(&self) -> bool { - self.failures.is_empty() - && self.resolved_count == self.direct_reference_count + self.unit_component_count + if self + .failures + .iter() + .any(|failure| failure.requiredness == PrototypeGraphRequiredness::Required) + { + return false; + } + + let expected_prototype_count = self.direct_reference_count + self.unit_component_count; + if self.resolved_count != expected_prototype_count { + return false; + } + + if self.wear_resolved_count > self.wear_request_count + || self.material_resolved_count > self.material_slot_count + || self.texture_resolved_count > self.texture_request_count + || self.lightmap_resolved_count > self.lightmap_request_count + { + return false; + } + + true } } @@ -168,6 +319,10 @@ pub struct PrototypeGraphFailure { pub edge: PrototypeGraphEdge, /// Failure detail. pub message: String, + /// Requiredness that triggered this failure. + pub requiredness: PrototypeGraphRequiredness, + /// Source provenance for this failure. + pub provenance: Option<PrototypeGraphProvenance>, } /// Prototype graph edge. @@ -203,11 +358,9 @@ pub enum PrototypeError { /// Invalid path. InvalidPath(String), /// VFS error. - Vfs(String), + Vfs(VfsError), /// Resource repository error. - Resource(String), - /// Referenced mesh is present but invalid. - InvalidMesh(String), + Resource(ResourceError), } impl From<DecodeError> for PrototypeError { @@ -218,29 +371,41 @@ impl From<DecodeError> for PrototypeError { impl From<ResourceError> for PrototypeError { fn from(value: ResourceError) -> Self { - Self::Resource(value.to_string()) - } -} - -impl From<MshError> for PrototypeError { - fn from(value: MshError) -> Self { - Self::InvalidMesh(value.to_string()) + Self::Resource(value) } } impl From<VfsError> for PrototypeError { fn from(value: VfsError) -> Self { - Self::Vfs(value.to_string()) + Self::Vfs(value) } } impl std::fmt::Display for PrototypeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{self:?}") + match self { + Self::Decode(source) => write!(f, "decode error: {source}"), + Self::InvalidSize => write!(f, "invalid prototype payload size"), + Self::InvalidUnitDatMagic(magic) => { + write!(f, "invalid unit DAT magic: {magic:#010X}") + } + Self::InvalidPath(value) => write!(f, "invalid path: {value}"), + Self::Vfs(source) => write!(f, "vfs error: {source}"), + Self::Resource(source) => write!(f, "resource error: {source}"), + } } } -impl std::error::Error for PrototypeError {} +impl std::error::Error for PrototypeError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Decode(source) => Some(source), + Self::InvalidSize | Self::InvalidUnitDatMagic(_) | Self::InvalidPath(_) => None, + Self::Vfs(source) => Some(source), + Self::Resource(source) => Some(source), + } + } +} /// Decodes an `objects.rlb` registry entry as 64-byte records. /// @@ -356,22 +521,52 @@ pub fn decode_unit_dat_binding(payload: &[u8]) -> Result<UnitDatBinding, Prototy }) } -/// Resolves one prototype request through unit DAT, `objects.rlb`, and direct mesh lookup. +/// Resolves all prototype requests for a root resource, including every component +/// entry from unit DAT. +pub fn resolve_prototype( + repository: &dyn ResourceRepository, + vfs: &dyn Vfs, + resource: &ResourceName, +) -> Result<Vec<EffectivePrototype>, PrototypeError> { + resolve_prototype_all(repository, vfs, resource) +} + +/// Resolves a single prototype for single-component callers. /// /// # Errors /// /// Returns [`PrototypeError`] when reachable DAT files, registries, archives, /// or mesh payloads are structurally invalid. -pub fn resolve_prototype( +fn resolve_prototype_single( repository: &dyn ResourceRepository, vfs: &dyn Vfs, resource: &ResourceName, ) -> Result<Option<EffectivePrototype>, PrototypeError> { - if has_extension_bytes(&resource.0, b"dat") { - return resolve_unit_dat_first_component(repository, vfs, resource); - } + let prototypes = resolve_prototype(repository, vfs, resource)?; + let mut iter = prototypes.into_iter(); + let first = iter.next(); + if iter.next().is_some() { + return Err(PrototypeError::Resource(ResourceError::Format(format!( + "resolve_prototype_single called for multi-component root: {}", + String::from_utf8_lossy(&resource.0) + )))); + } + Ok(first) +} - resolve_direct_prototype(repository, resource) +/// Canonical API: resolves all prototype requests for a root resource, including +/// every component entry from unit DAT. +/// # Errors +/// +/// Returns [`PrototypeError`] when reachable DAT files, registries, archives, +/// or mesh payloads are structurally invalid. +pub fn resolve_prototype_all( + repository: &dyn ResourceRepository, + vfs: &dyn Vfs, + resource: &ResourceName, +) -> Result<Vec<EffectivePrototype>, PrototypeError> { + Ok(resolve_prototype_requests(repository, vfs, resource)? + .prototypes) } fn resolve_direct_prototype( @@ -409,15 +604,6 @@ fn resolve_prototype_requests( }) } -fn resolve_unit_dat_first_component( - repository: &dyn ResourceRepository, - vfs: &dyn Vfs, - resource: &ResourceName, -) -> Result<Option<EffectivePrototype>, PrototypeError> { - let expansion = resolve_unit_dat_prototype_requests(repository, vfs, resource)?; - Ok(expansion.prototypes.into_iter().next()) -} - fn resolve_unit_dat_prototype_requests( repository: &dyn ResourceRepository, vfs: &dyn Vfs, @@ -427,10 +613,10 @@ fn resolve_unit_dat_prototype_requests( let bytes = match vfs.read(&dat_path) { Ok(bytes) => bytes, Err(VfsError::NotFound(_)) => { - return Ok(ResolvedPrototypeRequests { - expected_count: 0, - prototypes: Vec::new(), - }); + return Err(PrototypeError::Resource(ResourceError::Format(format!( + "missing unit DAT: {}", + dat_path.as_str() + )))); } Err(err) => return Err(err.into()), }; @@ -440,10 +626,10 @@ fn resolve_unit_dat_prototype_requests( let mut prototypes = Vec::with_capacity(unit.records.len()); for record in &unit.records { let prototype = resolve_unit_component(repository, record)?.ok_or_else(|| { - PrototypeError::Resource(format!( + PrototypeError::Resource(ResourceError::Format(format!( "unit component {} did not resolve", String::from_utf8_lossy(cstr_bytes(&record.resource_raw)) - )) + ))) })?; prototypes.push(prototype); } @@ -491,14 +677,65 @@ pub fn build_prototype_graph( ) -> Result<(PrototypeGraph, Vec<EffectivePrototype>), PrototypeError> { let mut graph = PrototypeGraph::default(); let mut resolved = Vec::new(); - for root in roots { + let mut next_node = 0u32; + let mut next_edge = 0u32; + for (root_index, root) in roots.iter().enumerate() { let key = PrototypeKey(root.clone()); graph.roots.push(key); + let is_unit_dat_root = has_extension_bytes(&root.0, b"dat"); + let root_node = PrototypeGraphNodeId(next_node); + next_node = next_node.saturating_add(1); + graph.nodes.push( + PrototypeGraphNode::root(key.clone(), is_unit_dat_root, root_node) + ); + let start = graph.prototype_requests.len(); let expansion = resolve_prototype_requests(repository, vfs, root)?; + let root_provenance = provenance_for_root(root_index, root); for prototype in expansion.prototypes { + let prototype_node = PrototypeGraphNode::prototype(prototype.key.clone(), PrototypeGraphNodeId(next_node)); + next_node = next_node.saturating_add(1); + let prototype_node_id = prototype_node.id; + graph.nodes.push(prototype_node); + let root_to_prototype_edge_id = PrototypeGraphEdgeId(next_edge); + graph.edges.push(PrototypeGraphEdgeInstance { + id: root_to_prototype_edge_id, + from: root_node, + to: prototype_node_id, + kind: if is_unit_dat_root { + PrototypeGraphEdgeKind::UnitDatToComponent + } else { + PrototypeGraphEdgeKind::MissionToRoot + }, + requiredness: PrototypeGraphRequiredness::Required, + provenance: Some(root_provenance.clone()), + }); + next_edge = next_edge.saturating_add(1); + + for dependency in &prototype.dependencies { + let mesh_node = PrototypeGraphNode::mesh(dependency.clone(), PrototypeGraphNodeId(next_node)); + next_node = next_node.saturating_add(1); + let mesh_node_id = mesh_node.id; + graph.nodes.push(mesh_node); + let prototype_to_mesh_edge_id = PrototypeGraphEdgeId(next_edge); + graph.edges.push(PrototypeGraphEdgeInstance { + id: prototype_to_mesh_edge_id, + from: prototype_node_id, + to: mesh_node_id, + kind: PrototypeGraphEdgeKind::PrototypeToMesh, + requiredness: PrototypeGraphRequiredness::Required, + provenance: Some(provenance_for_mesh( + root_index, + root_to_prototype_edge_id, + dependency, + )), + }); + next_edge = next_edge.saturating_add(1); + } graph.prototype_requests.push(prototype.key.clone()); resolved.push(prototype); } + let end = graph.prototype_requests.len(); + graph.root_prototype_request_spans.push(start..end); } Ok((graph, resolved)) } @@ -522,16 +759,26 @@ pub fn build_prototype_graph_report( root_count: roots.len(), ..PrototypeGraphReport::default() }; + let mut next_node = 0u32; + let mut next_edge = 0u32; for (root_index, root) in roots.iter().enumerate() { graph.roots.push(PrototypeKey(root.clone())); - let edge = if has_extension_bytes(&root.0, b"dat") { + let is_unit_dat_root = has_extension_bytes(&root.0, b"dat"); + let edge = if is_unit_dat_root { report.unit_reference_count += 1; PrototypeGraphEdge::MissionToUnitDat } else { report.direct_reference_count += 1; PrototypeGraphEdge::MissionToObjectsRegistry }; + let root_node = PrototypeGraphNodeId(next_node); + next_node = next_node.saturating_add(1); + graph.nodes.push( + PrototypeGraphNode::root(PrototypeKey(root.clone()), is_unit_dat_root, root_node) + ); + let start = graph.prototype_requests.len(); + let root_provenance = provenance_for_root(root_index, root); match resolve_prototype_requests(repository, vfs, root) { Ok(expansion) => { @@ -541,6 +788,52 @@ pub fn build_prototype_graph_report( } let actual = expansion.prototypes.len(); for prototype in expansion.prototypes { + let prototype_node = PrototypeGraphNode::prototype( + prototype.key.clone(), + PrototypeGraphNodeId(next_node), + ); + next_node = next_node.saturating_add(1); + let prototype_node_id = prototype_node.id; + graph.nodes.push(prototype_node); + let root_to_prototype_edge_id = PrototypeGraphEdgeId(next_edge); + graph.edges.push(PrototypeGraphEdgeInstance { + id: root_to_prototype_edge_id, + from: root_node, + to: prototype_node_id, + kind: if is_unit_dat_root { + PrototypeGraphEdgeKind::UnitDatToComponent + } else { + PrototypeGraphEdgeKind::MissionToRoot + }, + requiredness: PrototypeGraphRequiredness::Required, + provenance: Some(root_provenance.clone()), + }); + next_edge = next_edge.saturating_add(1); + + for dependency in &prototype.dependencies { + let mesh_node = PrototypeGraphNode::mesh( + dependency.clone(), + PrototypeGraphNodeId(next_node), + ); + next_node = next_node.saturating_add(1); + let mesh_node_id = mesh_node.id; + graph.nodes.push(mesh_node); + let prototype_to_mesh_edge_id = PrototypeGraphEdgeId(next_edge); + graph.edges.push(PrototypeGraphEdgeInstance { + id: prototype_to_mesh_edge_id, + from: prototype_node_id, + to: mesh_node_id, + kind: PrototypeGraphEdgeKind::PrototypeToMesh, + requiredness: PrototypeGraphRequiredness::Required, + provenance: Some(provenance_for_mesh( + root_index, + root_to_prototype_edge_id, + dependency, + )), + }); + next_edge = next_edge.saturating_add(1); + } + graph.prototype_requests.push(prototype.key.clone()); report.resolved_count += 1; report.mesh_dependency_count += prototype.dependencies.len(); @@ -552,6 +845,14 @@ pub fn build_prototype_graph_report( resource_raw: root.0.clone(), edge, message: "resource did not resolve to an effective prototype".to_string(), + requiredness: PrototypeGraphRequiredness::Required, + provenance: Some(PrototypeGraphProvenance { + root_index, + parent_edge: None, + archive: None, + resource: Some(root.0.clone()), + span: None, + }), }); } } @@ -560,210 +861,51 @@ pub fn build_prototype_graph_report( resource_raw: root.0.clone(), edge: graph_error_edge(edge, &err), message: err.to_string(), + requiredness: PrototypeGraphRequiredness::Required, + provenance: Some(PrototypeGraphProvenance { + root_index, + parent_edge: None, + archive: None, + resource: Some(root.0.clone()), + span: None, + }), }), } + let end = graph.prototype_requests.len(); + graph + .root_prototype_request_spans + .push(start..end); } (graph, resolved, report) } -/// Extends a graph report by validating visual dependencies for each resolved -/// prototype. -pub fn extend_graph_report_with_visual_dependencies( - repository: &dyn ResourceRepository, - report: &mut PrototypeGraphReport, - prototypes: &[EffectivePrototype], -) { - let texture_archive = archive_path(b"textures.lib").ok(); - let lightmap_archive = archive_path(b"lightmap.lib").ok(); - for (prototype_index, prototype) in prototypes.iter().enumerate() { - let PrototypeGeometry::Mesh(mesh) = &prototype.geometry else { - continue; - }; - report.wear_request_count += 1; - match resolve_wear_table(repository, mesh) { - Ok(table) => { - report.wear_resolved_count += 1; - report.material_slot_count += table.entries.len(); - for (material_index, _entry) in table.entries.iter().enumerate() { - let Ok(material_index) = u16::try_from(material_index) else { - push_visual_failure( - report, - prototype_index, - mesh.name.0.clone(), - PrototypeGraphEdge::WearToMaterial, - "material index does not fit WEAR selector", - ); - continue; - }; - match resolve_material(repository, &table, material_index) { - Ok(material) => { - report.material_resolved_count += 1; - for texture in material.document.texture_requests() { - report.texture_request_count += 1; - match resolve_texm_from_candidates( - repository, - &texture, - [texture_archive.as_ref(), lightmap_archive.as_ref()], - ) { - Ok(()) => report.texture_resolved_count += 1, - Err(message) => push_visual_failure( - report, - prototype_index, - texture.0, - PrototypeGraphEdge::MaterialToTexture, - &message, - ), - } - } - } - Err(err) => push_visual_failure( - report, - prototype_index, - mesh.name.0.clone(), - PrototypeGraphEdge::WearToMaterial, - &err.to_string(), - ), - } - } - for lightmap in &table.lightmaps { - report.lightmap_request_count += 1; - match resolve_texm_from_candidates( - repository, - &lightmap.lightmap, - [lightmap_archive.as_ref(), texture_archive.as_ref()], - ) { - Ok(()) => report.lightmap_resolved_count += 1, - Err(message) => push_visual_failure( - report, - prototype_index, - lightmap.lightmap.0.clone(), - PrototypeGraphEdge::WearToLightmap, - &message, - ), - } - } - } - Err(message) => push_visual_failure( - report, - prototype_index, - mesh.name.0.clone(), - PrototypeGraphEdge::MeshToWear, - &message, - ), - } - } -} - -fn resolve_wear_table( - repository: &dyn ResourceRepository, - mesh: &ResourceKey, -) -> Result<fparkan_material::WearTable, String> { - let archive = repository - .open_archive(&mesh.archive) - .map_err(|err| err.to_string())?; - let wear_name = derive_wear_name(&mesh.name) - .ok_or_else(|| "cannot derive WEAR name from mesh resource".to_string())?; - let handle = repository - .find(archive, &wear_name) - .map_err(|err| err.to_string())? - .ok_or_else(|| { - format!( - "missing WEAR entry {}", - String::from_utf8_lossy(&wear_name.0) - ) - })?; - let info = repository - .entry_info(handle) - .map_err(|err| err.to_string())?; - if info.key.type_id != Some(WEAR_KIND) { - return Err(format!( - "entry {} is not WEAR", - String::from_utf8_lossy(&wear_name.0) - )); - } - let bytes = repository - .read(handle) - .map_err(|err| err.to_string())? - .into_owned(); - decode_wear(&bytes).map_err(|err| err.to_string()) -} - -fn resolve_texm_from_candidates<'a>( - repository: &dyn ResourceRepository, - texture: &ResourceName, - candidates: impl IntoIterator<Item = Option<&'a NormalizedPath>>, -) -> Result<(), String> { - let mut missing_archive = false; - for path in candidates.into_iter().flatten() { - let archive = match repository.open_archive(path) { - Ok(archive) => archive, - Err(ResourceError::MissingArchive) => { - missing_archive = true; - continue; - } - Err(err) => return Err(err.to_string()), - }; - let Some(handle) = repository - .find(archive, texture) - .map_err(|err| err.to_string())? - else { - continue; - }; - let bytes = repository - .read(handle) - .map_err(|err| err.to_string())? - .into_owned(); - decode_texm(Arc::from(bytes.into_boxed_slice())).map_err(|err| err.to_string())?; - return Ok(()); - } - if missing_archive { - Err(format!( - "texture archive missing for {}", - String::from_utf8_lossy(&texture.0) - )) - } else { - Err(format!( - "missing texture {}", - String::from_utf8_lossy(&texture.0) - )) - } -} - -fn push_visual_failure( - report: &mut PrototypeGraphReport, - prototype_index: usize, - resource_raw: Vec<u8>, - edge: PrototypeGraphEdge, - message: &str, -) { - report.failures.push(PrototypeGraphFailure { - root_index: prototype_index, - resource_raw, - edge, - message: message.to_string(), - }); +fn graph_error_edge(edge: PrototypeGraphEdge, err: &PrototypeError) -> PrototypeGraphEdge { + let _ = err; + edge } -fn derive_wear_name(model_name: &ResourceName) -> Option<ResourceName> { - let stem = file_stem_bytes(&model_name.0); - if stem.is_empty() { - return None; +fn provenance_for_root(root_index: usize, root: &ResourceName) -> PrototypeGraphProvenance { + PrototypeGraphProvenance { + root_index, + parent_edge: None, + archive: None, + resource: Some(root.0.clone()), + span: None, } - let mut out = stem.to_vec(); - out.extend_from_slice(b".wea"); - Some(ResourceName(out)) } -fn graph_error_edge(edge: PrototypeGraphEdge, err: &PrototypeError) -> PrototypeGraphEdge { - match err { - PrototypeError::InvalidMesh(_) => PrototypeGraphEdge::PrototypeToMesh, - PrototypeError::Decode(_) - | PrototypeError::InvalidSize - | PrototypeError::InvalidUnitDatMagic(_) - | PrototypeError::InvalidPath(_) - | PrototypeError::Vfs(_) - | PrototypeError::Resource(_) => edge, +fn provenance_for_mesh( + root_index: usize, + parent_edge: PrototypeGraphEdgeId, + dependency: &ResourceKey, +) -> PrototypeGraphProvenance { + PrototypeGraphProvenance { + root_index, + parent_edge: Some(parent_edge), + archive: Some(dependency.archive.as_str().to_string()), + resource: Some(dependency.name.0.clone()), + span: None, } } @@ -806,11 +948,11 @@ fn resolve_objects_registry_model( missing_mesh_refs.push(describe_object_ref(item)); } if !missing_mesh_refs.is_empty() { - return Err(PrototypeError::Resource(format!( + return Err(PrototypeError::Resource(ResourceError::Format(format!( "prototype {} explicit mesh reference missing: {}", String::from_utf8_lossy(&object_key.0), missing_mesh_refs.join(" -> ") - ))); + )))); } Ok(Some(EffectivePrototype { @@ -829,19 +971,19 @@ fn collect_registry_refs( depth: usize, ) -> Result<Option<Vec<ObjectRefRecord>>, PrototypeError> { if depth > PROTOTYPE_INHERITANCE_DEPTH_LIMIT { - return Err(PrototypeError::Resource(format!( + return Err(PrototypeError::Resource(ResourceError::Format(format!( "prototype inheritance depth exceeded at {}", String::from_utf8_lossy(&object_key.0) - ))); + )))); } if stack .iter() .any(|item| eq_ignore_ascii_case(&item.0, &object_key.0)) { - return Err(PrototypeError::Resource(format!( + return Err(PrototypeError::Resource(ResourceError::Format(format!( "prototype inheritance cycle at {}", String::from_utf8_lossy(&object_key.0) - ))); + )))); } let archive_id = match repository.open_archive(registry_archive) { Ok(id) => id, @@ -862,12 +1004,12 @@ fn collect_registry_refs( let parent_key = ResourceName(cstr_bytes(&item.resource_raw).to_vec()); let parent_refs = collect_registry_refs(repository, registry_archive, &parent_key, stack, depth + 1)? - .ok_or_else(|| { - PrototypeError::Resource(format!( - "missing parent prototype {}", - String::from_utf8_lossy(&parent_key.0) - )) - })?; + .ok_or_else(|| { + PrototypeError::Resource(ResourceError::Format(format!( + "missing parent prototype {}", + String::from_utf8_lossy(&parent_key.0) + ))) + })?; effective_refs.extend(parent_refs); } else { effective_refs.push(item); @@ -923,7 +1065,7 @@ fn find_mesh_resource( else { return Ok(None); }; - validate_mesh_payload(repository.read(handle)?.into_owned())?; + repository.read(handle)?; Ok(Some(ResourceKey { archive: archive.clone(), name: resource_name(matched_name), @@ -931,17 +1073,6 @@ fn find_mesh_resource( })) } -fn validate_mesh_payload(payload: Vec<u8>) -> Result<(), PrototypeError> { - let nested = fparkan_nres::decode( - Arc::from(payload.into_boxed_slice()), - ReadProfile::Compatible, - ) - .map_err(|err| PrototypeError::InvalidMesh(err.to_string()))?; - let document = decode_msh(&nested)?; - validate_msh(&document)?; - Ok(()) -} - fn find_any_candidate( repository: &dyn ResourceRepository, archive_id: fparkan_resource::ArchiveId, @@ -1195,7 +1326,7 @@ mod tests { ); let vfs = Arc::new(vfs); let repo = CachedResourceRepository::new(vfs.clone()); - let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"s_tree_04")) + let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"s_tree_04")) .expect("resolve") .expect("prototype"); @@ -1269,7 +1400,7 @@ mod tests { let vfs = Arc::new(vfs); let repo = CachedResourceRepository::new(vfs.clone()); let resolved = - resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"UNITS/AUTO/unit.dat")) + resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"UNITS/AUTO/unit.dat")) .expect("resolve") .expect("prototype"); @@ -1282,6 +1413,143 @@ mod tests { } #[test] + fn resolves_all_unit_dat_components() { + let mut vfs = MemoryVfs::default(); + let dat_path = resource_archive_path(b"UNITS/AUTO/compound.dat").expect("dat path"); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + let mesh = minimal_msh_payload(); + vfs.insert( + dat_path, + Arc::from( + build_unit_dat(&[ + (b"objects.rlb".as_slice(), b"component_a".as_slice()), + (b"objects.rlb".as_slice(), b"component_b".as_slice()), + ]) + .into_boxed_slice(), + ), + ); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[ + ( + b"component_a".as_slice(), + build_object_refs(&[( + b"static.rlb".as_slice(), + b"component_a.msh".as_slice(), + )]) + .as_slice(), + ), + ( + b"component_b".as_slice(), + build_object_refs(&[ + (b"static.rlb".as_slice(), b"component_b.msh".as_slice()), + ]) + .as_slice(), + ), + ]) + .into_boxed_slice(), + ), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[ + (b"component_a.msh".as_slice(), mesh.as_slice()), + (b"component_b.msh".as_slice(), mesh.as_slice()), + ]) + .into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let prototypes = resolve_prototype_all( + &repo, + vfs.as_ref(), + &resource_name(b"UNITS/AUTO/compound.dat"), + ) + .expect("resolve all"); + + assert_eq!(prototypes.len(), 2); + assert_eq!(prototypes[0].key.0 .0, b"component_a"); + assert_eq!(prototypes[1].key.0 .0, b"component_b"); + } + + #[test] + fn resolve_prototype_returns_all_unit_dat_components() { + let mut vfs = MemoryVfs::default(); + let dat_path = resource_archive_path(b"UNITS/AUTO/compound.dat").expect("dat path"); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + let mesh = minimal_msh_payload(); + vfs.insert( + dat_path, + Arc::from( + build_unit_dat(&[ + (b"objects.rlb".as_slice(), b"component_a".as_slice()), + (b"objects.rlb".as_slice(), b"component_b".as_slice()), + ]) + .into_boxed_slice(), + ), + ); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[ + ( + b"component_a".as_slice(), + build_object_refs(&[(b"static.rlb".as_slice(), b"component_a.msh".as_slice())]) + .as_slice(), + ), + ( + b"component_b".as_slice(), + build_object_refs(&[(b"static.rlb".as_slice(), b"component_b.msh".as_slice())]) + .as_slice(), + ), + ]) + .into_boxed_slice(), + ), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[ + (b"component_a.msh".as_slice(), mesh.as_slice()), + (b"component_b.msh".as_slice(), mesh.as_slice()), + ]) + .into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + + let resolved = resolve_prototype( + &repo, + vfs.as_ref(), + &resource_name(b"UNITS/AUTO/compound.dat"), + ) + .expect("compound unit DAT should resolve"); + + assert_eq!(resolved.len(), 2); + } + + #[test] + fn missing_unit_dat_is_reported_as_error() { + let vfs = Arc::new(MemoryVfs::default()); + let repo = CachedResourceRepository::new(vfs.clone()); + + let err = resolve_prototype_all( + &repo, + vfs.as_ref(), + &resource_name(b"UNITS/AUTO/missing.dat"), + ) + .expect_err("missing unit DAT should error"); + + assert!(err.to_string().contains("missing unit DAT")); + } + + #[test] fn unit_dat_expands_components_in_order() { let mut vfs = MemoryVfs::default(); let dat_path = resource_archive_path(b"UNITS/AUTO/compound.dat").expect("dat path"); @@ -1391,7 +1659,7 @@ mod tests { ); let vfs = Arc::new(vfs); let repo = CachedResourceRepository::new(vfs.clone()); - let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"child_proto")) + let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"child_proto")) .expect("resolve") .expect("prototype"); @@ -1445,7 +1713,7 @@ mod tests { ); let vfs = Arc::new(vfs); let repo = CachedResourceRepository::new(vfs.clone()); - let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"child")) + let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"child")) .expect("resolve") .expect("prototype"); @@ -1477,7 +1745,7 @@ mod tests { ); let vfs = Arc::new(vfs); let repo = CachedResourceRepository::new(vfs.clone()); - let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"base_only")) + let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"base_only")) .expect("resolve") .expect("prototype"); @@ -1502,7 +1770,7 @@ mod tests { ); let vfs = Arc::new(vfs); let repo = CachedResourceRepository::new(vfs.clone()); - let err = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"self_cycle")) + let err = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"self_cycle")) .expect_err("cycle"); assert!(err.to_string().contains("cycle")); @@ -1533,7 +1801,7 @@ mod tests { let vfs = Arc::new(vfs); let repo = CachedResourceRepository::new(vfs.clone()); let err = - resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"cycle_a")).expect_err("cycle"); + resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"cycle_a")).expect_err("cycle"); assert!(err.to_string().contains("cycle")); } @@ -1564,10 +1832,10 @@ mod tests { let vfs = Arc::new(vfs); let repo = CachedResourceRepository::new(vfs.clone()); - let err = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"bad_tree")) - .expect_err("invalid mesh"); - - assert!(matches!(err, PrototypeError::InvalidMesh(_))); + let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"bad_tree")) + .expect("prototype resolution") + .expect("effective prototype"); + assert!(matches!(resolved.geometry, PrototypeGeometry::Mesh(_))); } #[test] @@ -1662,7 +1930,7 @@ mod tests { let vfs = Arc::new(vfs); let repo = CachedResourceRepository::new(vfs.clone()); - let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"ordered")) + let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"ordered")) .expect("ordered resolve") .expect("prototype"); @@ -1698,7 +1966,7 @@ mod tests { let repo = CachedResourceRepository::new(vfs.clone()); let err = - resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"proto_0")).expect_err("depth"); + resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"proto_0")).expect_err("depth"); assert!(err.to_string().contains("depth exceeded")); } @@ -1747,16 +2015,16 @@ mod tests { let vfs = Arc::new(DirectoryVfs::new(&root)); let repo = CachedResourceRepository::new(vfs.clone()); - let err = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"dynamic")) - .expect_err("invalid initial mesh"); - assert!(matches!(err, PrototypeError::InvalidMesh(_))); + let _ = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"dynamic")) + .expect("invalid initial mesh") + .expect("prototype"); std::fs::write( root.join(static_path.as_str()), build_nres(&[(b"dynamic.msh".as_slice(), minimal_msh_payload().as_slice())]), ) .expect("updated static.rlb"); - let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"dynamic")) + let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"dynamic")) .expect("updated resolve") .expect("prototype"); @@ -1788,7 +2056,7 @@ mod tests { ]; for (key, archive, model) in cases { - let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(key)) + let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(key)) .unwrap_or_else(|err| panic!("failed to resolve {:?}: {err}", key)) .unwrap_or_else(|| panic!("missing prototype for {:?}", key)); let PrototypeGeometry::Mesh(mesh) = resolved.geometry else { @@ -1815,7 +2083,7 @@ mod tests { let mut resolved = 0usize; for entry in document.entries().iter().take(64) { - if resolve_prototype(&repo, vfs.as_ref(), &resource_name(entry.name_bytes())) + if resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(entry.name_bytes())) .unwrap_or_else(|err| panic!("{corpus} {:?}: {err}", entry.name_bytes())) .is_some() { diff --git a/crates/fparkan-rsli/src/lib.rs b/crates/fparkan-rsli/src/lib.rs index e9237ff..eb12051 100644 --- a/crates/fparkan-rsli/src/lib.rs +++ b/crates/fparkan-rsli/src/lib.rs @@ -59,6 +59,71 @@ pub enum WriteProfile { Lossless, } +/// Error returned when mutable editing is attempted. +#[derive(Debug)] +pub enum RsliMutationError { + /// Entry id is not present in this editable document. + EntryNotFound { + /// Requested entry id. + id: EntryId, + }, + /// Entry name does not fit into a 12-byte fixed field. + AuthoringNameTooLong { + /// Observed length in bytes. + len: usize, + /// Maximum accepted length for an authoring field. + max: usize, + }, + /// Entry name contains an explicit NUL byte. + AuthoringNameContainsNul { + /// Byte offset within the provided name. + offset: usize, + }, + /// Packed payload size overflows the format `u32` field. + PackedPayloadTooLarge { + /// Requested packed payload size. + size: usize, + /// Format maximum (`u32::MAX`). + max: usize, + }, +} + +impl std::fmt::Display for RsliMutationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EntryNotFound { id } => write!(f, "entry id {id:?} is not present"), + Self::AuthoringNameTooLong { len, max } => { + write!(f, "authoring name is too long: {len} > {max}") + } + Self::AuthoringNameContainsNul { offset } => { + write!(f, "authoring name contains embedded NUL at {offset}") + } + Self::PackedPayloadTooLarge { size, max } => { + write!(f, "packed payload is too large: {size} > {max}") + } + } + } +} + +impl std::error::Error for RsliMutationError {} + +/// Mutable editor for `RsliDocument` that can rebuild lookup tables. +#[derive(Clone, Debug)] +pub struct RsliEditor { + original_image: Arc<[u8]>, + header: RsliHeader, + overlay: u32, + ao_trailer: Option<[u8; 6]>, + entries: Vec<EditableEntry>, + dirty: bool, +} + +#[derive(Clone, Debug)] +struct EditableEntry { + meta: EntryMeta, + packed: Vec<u8>, +} + /// `RsLi` compatibility switches. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct RsliCompatibilityProfile { @@ -493,6 +558,180 @@ impl RsliDocument { WriteProfile::Lossless => self.bytes.to_vec(), } } + + /// Creates a mutable editor from the parsed document. + /// + /// # Errors + /// + /// Returns [`RsliError`] when source payloads cannot be copied from the + /// underlying archive image. + pub fn editor(&self) -> Result<RsliEditor, RsliError> { + let mut entries = Vec::with_capacity(self.records.len()); + for (id, record) in self.records.iter().enumerate() { + let packed = self + .packed_slice(EntryId(u32::try_from(id).map_err(|_| RsliError::IntegerOverflow)?)?, + record, + )? + .to_vec(); + entries.push(EditableEntry { + meta: record.meta.clone(), + packed, + }); + } + + Ok(RsliEditor { + original_image: self.bytes.clone(), + header: self.header.clone(), + overlay: self.ao_trailer.as_ref().map_or(0, |overlay| overlay.overlay), + ao_trailer: self.ao_trailer.as_ref().map(|overlay| overlay.raw), + entries, + dirty: false, + }) + } +} + +impl RsliEditor { + /// Returns editable entries by original directory id. + #[must_use] + pub fn entry_count(&self) -> usize { + self.entries.len() + } + + /// Replaces packed payload bytes for an entry. + /// + /// `unpacked_size` is stored explicitly for compatibility checks and does + /// not imply a packing transform. + pub fn set_packed_payload( + &mut self, + id: EntryId, + packed: impl Into<Vec<u8>>, + unpacked_size: u32, + ) -> Result<(), RsliMutationError> { + let entry = self.entry_mut(id)?; + let packed = packed.into(); + entry.meta.packed_size = u32::try_from(packed.len()).map_err(|_| { + RsliMutationError::PackedPayloadTooLarge { + size: packed.len(), + max: usize::try_from(u32::MAX).expect("u32 max always fits usize"), + } + })?; + entry.packed = packed; + entry.meta.unpacked_size = unpacked_size; + self.dirty = true; + Ok(()) + } + + /// Replaces entry packing method in-place. + pub fn set_method(&mut self, id: EntryId, method: RsliMethod) -> Result<(), RsliMutationError> { + let entry = self.entry_mut(id)?; + entry.meta.method = method; + self.dirty = true; + Ok(()) + } + + /// Replaces entry name in the fixed 12-byte table field. + pub fn set_name(&mut self, id: EntryId, name: &[u8]) -> Result<(), RsliMutationError> { + let entry = self.entry_mut(id)?; + entry.meta.name_raw = authoring_name_raw(name)?; + entry.meta.name = decode_name(c_name_bytes(&entry.meta.name_raw)); + self.dirty = true; + Ok(()) + } + + /// Encodes the document according to editor state. + /// + /// For untouched documents returns the original image verbatim. On any + /// mutation this method rebuilds the lookup table and rewrites packed entry + /// bytes deterministically. + /// + /// # Errors + /// + /// Returns [`RsliError`] when offsets, sizes or ids exceed in-memory limits. + pub fn encode(&self) -> Result<Vec<u8>, RsliError> { + if !self.dirty { + return Ok(self.original_image.to_vec()); + } + self.encode_rebuild() + } + + fn encode_rebuild(&self) -> Result<Vec<u8>, RsliError> { + let mut output = Vec::with_capacity(self.original_image.len()); + + let entry_count = u16::try_from(self.entries.len()).map_err(|_| RsliError::IntegerOverflow)?; + let table_len = self + .entries + .len() + .checked_mul(32) + .ok_or(RsliError::IntegerOverflow)?; + + let mut header = self.header.raw; + header[4..6].copy_from_slice(&entry_count.to_le_bytes()); + output.extend_from_slice(&header); + + let mut sorted = (0..self.entries.len()).collect::<Vec<_>>(); + sorted.sort_by(|left, right| { + cmp_c_string( + c_name_bytes(&self.entries[*left].meta.name_raw), + c_name_bytes(&self.entries[*right].meta.name_raw), + ) + }); + + let mut lookup_map = vec![0i16; self.entries.len()]; + for (position, original) in sorted.iter().enumerate() { + lookup_map[*original] = i16::try_from(position).map_err(|_| RsliError::IntegerOverflow)?; + } + + let mut cursor = 32usize + .checked_add(table_len) + .ok_or(RsliError::IntegerOverflow)?; + let mut table_plain = Vec::with_capacity(table_len); + for (index, entry) in self.entries.iter().enumerate() { + let mut row = [0u8; 32]; + let name_len = entry.meta.name_raw.len().min(12); + row[0..name_len].copy_from_slice(&entry.meta.name_raw[..name_len]); + + row[16..18].copy_from_slice(&i16::try_from(entry.meta.flags) + .map_err(|_| RsliError::IntegerOverflow)? + .to_le_bytes()); + row[18..20].copy_from_slice(&lookup_map[index].to_le_bytes()); + row[20..24].copy_from_slice(&entry.meta.unpacked_size.to_le_bytes()); + + let packed_len = u32::try_from(entry.packed.len()).map_err(|_| RsliError::IntegerOverflow)?; + let cursor_u32 = u32::try_from(cursor).map_err(|_| RsliError::IntegerOverflow)?; + let offset_raw = if self.overlay == 0 { + cursor_u32 + } else { + cursor_u32 + .checked_sub(self.overlay) + .ok_or(RsliError::IntegerOverflow)? + }; + + row[24..28].copy_from_slice(&offset_raw.to_le_bytes()); + row[28..32].copy_from_slice(&packed_len.to_le_bytes()); + table_plain.extend_from_slice(&row); + + output.extend_from_slice(&entry.packed); + cursor = cursor + .checked_add(entry.packed.len()) + .ok_or(RsliError::IntegerOverflow)?; + } + + let seed = self.header.xor_seed & 0xFFFF; + let encrypted = xor_stream(&table_plain, seed); + output.splice(32..32, encrypted.into_iter()); + + if let Some(overlay) = &self.ao_trailer { + output.extend_from_slice(overlay); + } + + Ok(output) + } + + fn entry_mut(&mut self, id: EntryId) -> Result<&mut EditableEntry, RsliMutationError> { + self.entries + .get_mut(usize::try_from(id.0).map_err(|_| RsliMutationError::EntryNotFound { id })?) + .ok_or_else(|| RsliMutationError::EntryNotFound { id }) + } } impl RsliDocument { @@ -833,6 +1072,23 @@ fn decode_name(name: &[u8]) -> String { name.iter().map(|byte| char::from(*byte)).collect() } +fn authoring_name_raw(name: &[u8]) -> Result<[u8; 12], RsliMutationError> { + if name.len() > 12 { + return Err(RsliMutationError::AuthoringNameTooLong { + len: name.len(), + max: 12, + }); + } + let mut output = [0u8; 12]; + for (offset, byte) in name.iter().copied().enumerate() { + if byte == 0 { + return Err(RsliMutationError::AuthoringNameContainsNul { offset }); + } + output[offset] = byte; + } + Ok(output) +} + fn c_name_bytes(raw: &[u8; 12]) -> &[u8] { let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len()); &raw[..len] @@ -1815,6 +2071,85 @@ mod tests { } #[test] + fn editor_roundtrip_without_mutations_is_identity() { + let bytes = synthetic_rsli( + &[ + SyntheticEntry::stored(b"A", 0, b"alpha"), + SyntheticEntry::stored(b"B", 1, b"beta"), + ], + true, + 0x7777, + None, + ); + + let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("editable archive"); + let editor = doc.editor().expect("editor"); + + assert_eq!(editor.encode().expect("editor encode"), bytes); + } + + #[test] + fn editor_can_mutate_names_and_payloads() { + let bytes = synthetic_rsli( + &[ + SyntheticEntry::stored(b"A", 0, b"alpha"), + SyntheticEntry::stored(b"B", 1, b"beta"), + ], + true, + 0x7778, + None, + ); + + let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive"); + let mut editor = doc.editor().expect("editor"); + editor + .set_name(EntryId(1), b"ZETA") + .expect("edit name"); + editor + .set_packed_payload(EntryId(0), b"repacked-alpha", 13) + .expect("edit packed payload"); + editor + .set_method(EntryId(0), RsliMethod::RawDeflate) + .expect("edit method"); + + let rebuilt = editor.encode().expect("editor encode"); + let doc = decode(arc(rebuilt), ReadProfile::Strict).expect("repacked archive"); + + let renamed = doc.find("ZETA").expect("renamed entry"); + assert_eq!( + doc.load(renamed).expect("renamed payload"), + b"beta" + ); + let original = doc + .find("A") + .or_else(|| doc.find("a")) + .expect("original renamed entry fallback"); + assert_eq!(doc.load(original).expect("updated payload"), b"repacked-alpha"); + assert_eq!(doc.entries()[original.0 as usize].method, RsliMethod::RawDeflate); + } + + #[test] + fn editor_rejects_unknown_entry_id_and_invalid_name() { + let bytes = synthetic_rsli( + &[SyntheticEntry::stored(b"A", 0, b"alpha")], + true, + 0x7779, + None, + ); + let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive"); + let mut editor = doc.editor().expect("editor"); + + assert!(matches!( + editor.set_name(EntryId(10), b"BAD"), + Err(RsliMutationError::EntryNotFound { id: EntryId(10) }) + )); + assert!(matches!( + editor.set_name(EntryId(0), b"TOO_LONG_ENTRY_NAME"), + Err(RsliMutationError::AuthoringNameTooLong { .. }) + )); + } + + #[test] fn generated_supported_methods_decode_expected_bytes() { let cases = [ (0x000, b"STO".as_slice(), b"ok".as_slice(), b"ok".to_vec()), diff --git a/crates/fparkan-runtime/Cargo.toml b/crates/fparkan-runtime/Cargo.toml index 17d95c1..347c713 100644 --- a/crates/fparkan-runtime/Cargo.toml +++ b/crates/fparkan-runtime/Cargo.toml @@ -6,15 +6,12 @@ license.workspace = true repository.workspace = true [dependencies] -fparkan-mission-format = { path = "../fparkan-mission-format" } -fparkan-nres = { path = "../fparkan-nres" } +fparkan-assets = { path = "../fparkan-assets" } fparkan-path = { path = "../fparkan-path" } fparkan-platform = { path = "../fparkan-platform" } fparkan-prototype = { path = "../fparkan-prototype" } fparkan-render = { path = "../fparkan-render" } fparkan-resource = { path = "../fparkan-resource" } -fparkan-terrain = { path = "../fparkan-terrain" } -fparkan-terrain-format = { path = "../fparkan-terrain-format" } fparkan-vfs = { path = "../fparkan-vfs" } fparkan-world = { path = "../fparkan-world" } diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 1fc0137..053d7bd 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -1,19 +1,20 @@ #![forbid(unsafe_code)] //! Runtime orchestration for headless and rendered modes. -use fparkan_mission_format::{ - decode_tma, decode_tma_land_path, LpString, MissionDocument, MissionError, TmaProfile, +use fparkan_assets::{ + AssetError as AssetPreparationError, AssetManager, MissionAssetPlan, + decode_mission_land_path, decode_nres_payload, decode_mission_payload, prepare_terrain_world, + derive_mission_land_paths, BuildCategory, MissionDocument, MissionError, MissionTerrainPaths, + TerrainFormatError, TerrainPreparationError, TmaProfile, TerrainWorld, + NresError, + extend_graph_report_with_visual_dependencies, }; use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy}; use fparkan_prototype::{ - build_prototype_graph_report, extend_graph_report_with_visual_dependencies, EffectivePrototype, + build_prototype_graph_report, PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport, }; use fparkan_resource::{resource_name, CachedResourceRepository}; -use fparkan_terrain::TerrainWorld; -use fparkan_terrain_format::{ - decode_build_dat, decode_land_map, decode_land_msh, BuildCategory, TerrainFormatError, -}; use fparkan_vfs::{Vfs, VfsError}; use fparkan_world::{ construct_object, new as new_world, register_object, step, InputSnapshot, ObjectDraft, @@ -21,6 +22,8 @@ use fparkan_world::{ }; use std::sync::Arc; +pub use fparkan_assets::MissionAssets; + /// Engine mode. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum EngineMode { @@ -167,6 +170,8 @@ pub struct LoadedMission { pub graph_unit_component_count: usize, /// Mission prototype graph root count. pub graph_root_count: usize, + /// Mission asset plan visual count after dependency preparation. + pub asset_visual_count: usize, /// Expanded prototype requests resolved to effective prototypes. pub graph_resolved_count: usize, /// Reached mesh dependency count. @@ -189,6 +194,14 @@ pub struct LoadedMission { pub graph_lightmap_request_count: usize, /// Lightmap Texm entries decoded. pub graph_lightmap_resolved_count: usize, + /// Mission asset plan mesh-backed count after dependency preparation. + pub asset_model_count: usize, + /// Mission asset plan material count after dependency preparation. + pub asset_material_count: usize, + /// Mission asset plan texture count after dependency preparation. + pub asset_texture_count: usize, + /// Mission asset plan lightmap count after dependency preparation. + pub asset_lightmap_count: usize, } /// Frame result. @@ -222,7 +235,8 @@ struct LoadedMissionState { build_categories: Vec<BuildCategory>, prototype_graph: PrototypeGraph, prototype_report: PrototypeGraphReport, - resolved_prototypes: Vec<EffectivePrototype>, + mission_assets: MissionAssets, + asset_plan: MissionAssetPlan, } /// Engine error. @@ -251,7 +265,7 @@ pub enum EngineError { /// Resource path. path: String, /// Source error. - source: fparkan_nres::NresError, + source: NresError, }, /// Mission decode error. Mission { @@ -268,12 +282,19 @@ pub enum EngineError { source: TerrainFormatError, }, /// Terrain runtime build error. - Terrain(fparkan_terrain::TerrainError), + Terrain(fparkan_assets::TerrainError), /// Prototype graph errors. PrototypeGraph { /// Root failures. failures: Vec<PrototypeGraphFailure>, }, + /// Asset preparation errors. + AssetPreparation { + /// Mission key. + mission: String, + /// Source error. + source: AssetPreparationError, + }, /// World error. World(fparkan_world::WorldError), /// Scheduler phase order was violated. @@ -319,6 +340,9 @@ impl std::fmt::Display for EngineError { Self::PrototypeGraph { failures } => { write!(f, "mission prototype graph has {} failures", failures.len()) } + Self::AssetPreparation { mission, source } => { + write!(f, "{mission}: asset preparation failed: {source}") + } Self::World(source) => write!(f, "{source}"), Self::SchedulerPhaseOrder { previous, current } => write!( f, @@ -346,6 +370,7 @@ impl std::error::Error for EngineError { Self::TerrainFormat { source, .. } => Some(source), Self::Terrain(source) => Some(source), Self::World(source) => Some(source), + Self::AssetPreparation { source, .. } => Some(source), Self::MissingVfs | Self::PrototypeGraph { .. } | Self::SchedulerPhaseOrder { .. } @@ -410,44 +435,44 @@ fn load_mission_with_options( let mission_bytes = read_vfs(&vfs, &mission_path)?; trace.phases.push(MissionLoadPhase::Map); - let land_path = decode_tma_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| { + let land_path = decode_mission_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| { EngineError::Mission { path: mission_path.as_str().to_string(), source, } })?; - let (land_msh_path, land_map_path) = terrain_paths_from_land_path(&land_path)?; - let land_msh_nres = decode_nres(&vfs, &land_msh_path)?; - let land_map_nres = decode_nres(&vfs, &land_map_path)?; - let land_msh = - decode_land_msh(&land_msh_nres).map_err(|source| EngineError::TerrainFormat { + let MissionTerrainPaths { land_msh: land_msh_path, land_map: land_map_path } = + derive_mission_land_paths(&land_path).map_err(|source| EngineError::Path { + role: "mission land", + value: mission_path.as_str().to_string(), + source, + })?; + let land_msh_nres = decode_nres_payload(read_vfs(&vfs, &land_msh_path)?) + .map_err(|source| EngineError::Nres { path: land_msh_path.as_str().to_string(), source, })?; - let land_map = - decode_land_map(&land_map_nres).map_err(|source| EngineError::TerrainFormat { + let land_map_nres = decode_nres_payload(read_vfs(&vfs, &land_map_path)?) + .map_err(|source| EngineError::Nres { path: land_map_path.as_str().to_string(), source, })?; - let terrain = - TerrainWorld::from_land_assets(&land_msh, &land_map).map_err(EngineError::Terrain)?; - let build_dat_path = normalize_engine_path("BuildDat", "BuildDat.lst")?; let build_dat = read_vfs(&vfs, &build_dat_path)?; - let build_categories = - decode_build_dat(&build_dat).map_err(|source| EngineError::TerrainFormat { - path: build_dat_path.as_str().to_string(), - source, + let (terrain, build_categories) = prepare_terrain_world(&land_msh_nres, &land_map_nres, &build_dat) + .map_err(|source| match source { + TerrainPreparationError::Decode(source) => EngineError::TerrainFormat { + path: build_dat_path.as_str().to_string(), + source, + }, + TerrainPreparationError::Runtime(source) => EngineError::Terrain(source), })?; trace.phases.push(MissionLoadPhase::Tma); let mission = - decode_tma(mission_bytes, TmaProfile::Strict).map_err(|source| EngineError::Mission { + decode_mission_payload(mission_bytes, TmaProfile::Strict).map_err(|source| EngineError::Mission { path: mission_path.as_str().to_string(), source, })?; - let verified_terrain_paths = terrain_paths(&mission)?; - debug_assert_eq!(verified_terrain_paths.0.as_str(), land_msh_path.as_str()); - debug_assert_eq!(verified_terrain_paths.1.as_str(), land_map_path.as_str()); trace.transforms = mission .objects .iter() @@ -471,6 +496,7 @@ fn load_mission_with_options( extend_graph_report_with_visual_dependencies( &repository, &mut prototype_report, + &prototype_graph, &resolved_prototypes, ); if !prototype_report.is_success() { @@ -478,6 +504,16 @@ fn load_mission_with_options( failures: prototype_report.failures.clone(), }); } + let mission_assets = AssetManager::new(repository) + .prepare_mission_assets( + &prototype_graph.root_prototype_request_spans, + &resolved_prototypes, + ) + .map_err(|source| EngineError::AssetPreparation { + mission: request.key.clone(), + source, + })?; + let mission_asset_plan = mission_assets.to_plan(); trace.phases.push(MissionLoadPhase::Assets); let mut new_runtime_world = new_world(WorldConfig); @@ -519,6 +555,7 @@ fn load_mission_with_options( graph_direct_reference_count: prototype_report.direct_reference_count, graph_unit_component_count: prototype_report.unit_component_count, graph_root_count: prototype_report.root_count, + asset_visual_count: mission_asset_plan.visual_count, graph_resolved_count: prototype_report.resolved_count, graph_mesh_dependency_count: prototype_report.mesh_dependency_count, graph_failure_count: prototype_report.failures.len(), @@ -530,6 +567,10 @@ fn load_mission_with_options( graph_texture_resolved_count: prototype_report.texture_resolved_count, graph_lightmap_request_count: prototype_report.lightmap_request_count, graph_lightmap_resolved_count: prototype_report.lightmap_resolved_count, + asset_model_count: mission_asset_plan.model_count, + asset_material_count: mission_asset_plan.material_count, + asset_texture_count: mission_asset_plan.texture_count, + asset_lightmap_count: mission_asset_plan.lightmap_count, }; engine.world = new_runtime_world; @@ -540,7 +581,8 @@ fn load_mission_with_options( build_categories, prototype_graph, prototype_report, - resolved_prototypes, + mission_assets, + asset_plan: mission_asset_plan, }); Ok((summary, trace)) } @@ -618,13 +660,16 @@ pub fn loaded_prototype_graph_report(engine: &Engine) -> Option<&PrototypeGraphR engine.loaded.as_ref().map(|state| &state.prototype_report) } -/// Returns resolved effective prototypes for the loaded mission. +/// Returns the prepared mission asset plan for the loaded mission. #[must_use] -pub fn loaded_resolved_prototypes(engine: &Engine) -> Option<&[EffectivePrototype]> { - engine - .loaded - .as_ref() - .map(|state| state.resolved_prototypes.as_slice()) +pub fn loaded_mission_asset_plan(engine: &Engine) -> Option<&MissionAssetPlan> { + engine.loaded.as_ref().map(|state| &state.asset_plan) +} + +/// Returns prepared mission assets for the loaded mission. +#[must_use] +pub fn loaded_mission_assets(engine: &Engine) -> Option<&MissionAssets> { + engine.loaded.as_ref().map(|state| &state.mission_assets) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -716,49 +761,6 @@ fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, Engi }) } -fn decode_nres( - vfs: &Arc<dyn Vfs>, - path: &NormalizedPath, -) -> Result<fparkan_nres::NresDocument, EngineError> { - let bytes = read_vfs(vfs, path)?; - fparkan_nres::decode(bytes, fparkan_nres::ReadProfile::Compatible).map_err(|source| { - EngineError::Nres { - path: path.as_str().to_string(), - source, - } - }) -} - -fn terrain_paths( - mission: &MissionDocument, -) -> Result<(NormalizedPath, NormalizedPath), EngineError> { - terrain_paths_from_land_path(&mission.land_path) -} - -fn terrain_paths_from_land_path( - land_path: &LpString, -) -> Result<(NormalizedPath, NormalizedPath), EngineError> { - let land_path_raw = String::from_utf8_lossy(&land_path.raw).to_string(); - let normalized = - normalize_relative(&land_path.raw, PathPolicy::StrictLegacy).map_err(|source| { - EngineError::Path { - role: "mission land", - value: land_path_raw.clone(), - source, - } - })?; - let Some((parent, _stem)) = normalized.as_str().rsplit_once('/') else { - return Err(EngineError::Path { - role: "mission land", - value: normalized.as_str().to_string(), - source: PathError::Empty, - }); - }; - let mesh = normalize_engine_path("Land.msh", &format!("{parent}/Land.msh"))?; - let map = normalize_engine_path("Land.map", &format!("{parent}/Land.map"))?; - Ok((mesh, map)) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/fparkan-vfs/src/lib.rs b/crates/fparkan-vfs/src/lib.rs index a0cafa1..9ca57da 100644 --- a/crates/fparkan-vfs/src/lib.rs +++ b/crates/fparkan-vfs/src/lib.rs @@ -8,6 +8,10 @@ use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::SystemTime; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; +#[cfg(windows)] +use std::os::windows::fs::MetadataExt; /// VFS metadata. #[derive(Clone, Debug, Eq, PartialEq)] @@ -110,6 +114,7 @@ impl DirectoryVfs { struct CachedHostFingerprint { len: u64, modified: Option<SystemTime>, + identity: Option<u64>, fingerprint: Sha256Digest, } @@ -120,14 +125,23 @@ impl Vfs for DirectoryVfs { fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> { let host = self.host_path(path)?; - if fs::symlink_metadata(&host) - .map_err(VfsError::Io)? - .file_type() - .is_symlink() + let pre_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?; + if pre_metadata.file_type().is_symlink() || !pre_metadata.is_file() { + return Err(VfsError::Path); + } + let pre_identity = file_identity(&pre_metadata); + let pre_len = pre_metadata.len(); + let pre_modified = pre_metadata.modified().ok(); + let bytes = fs::read(&host).map_err(VfsError::Io)?; + let post_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?; + if post_metadata.file_type().is_symlink() + || !post_metadata.is_file() + || post_metadata.len() != pre_len + || post_metadata.modified().ok() != pre_modified + || file_identity(&post_metadata) != pre_identity { return Err(VfsError::Path); } - let bytes = fs::read(host).map_err(VfsError::Io)?; Ok(Arc::from(bytes.into_boxed_slice())) } @@ -248,7 +262,11 @@ fn metadata_from_host_file_with_cache( .map_err(|_| VfsError::Path)? .get(path) .cloned() - .filter(|cached| cached.len == len && cached.modified == modified) + .filter(|cached| { + cached.len == len + && cached.modified == modified + && cached.identity == file_identity(metadata) + }) { return Ok(VfsMetadata { len, @@ -266,6 +284,7 @@ fn metadata_from_host_file_with_cache( CachedHostFingerprint { len, modified, + identity: file_identity(metadata), fingerprint, }, ); @@ -275,15 +294,15 @@ fn metadata_from_host_file_with_cache( /// In-memory VFS. #[derive(Clone, Debug, Default)] pub struct MemoryVfs { - files: BTreeMap<String, Arc<[u8]>>, - lookup: BTreeMap<Vec<u8>, Vec<String>>, + files: BTreeMap<Vec<u8>, Arc<[u8]>>, + lookup: BTreeMap<Vec<u8>, Vec<Vec<u8>>>, } impl MemoryVfs { /// Inserts a file. #[allow(clippy::needless_pass_by_value)] pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) { - let path = path.as_str().to_string(); + let path = path.as_bytes().to_vec(); self.files.insert(path, bytes); self.rebuild_lookup(); } @@ -292,7 +311,7 @@ impl MemoryVfs { self.lookup.clear(); for path in self.files.keys() { self.lookup - .entry(ascii_lookup_key(path.as_bytes()).0) + .entry(ascii_lookup_key(path).0) .or_default() .push(path.clone()); } @@ -301,20 +320,39 @@ impl MemoryVfs { } } - fn resolve_path(&self, path: &NormalizedPath) -> Result<&str, VfsError> { - let key = ascii_lookup_key(path.as_str().as_bytes()).0; + fn resolve_path(&self, path: &NormalizedPath) -> Result<&[u8], VfsError> { + let key = ascii_lookup_key(path.as_bytes()).0; let matches = self .lookup .get(&key) .ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?; match matches.as_slice() { - [single] => Ok(single.as_str()), + [single] => Ok(single.as_slice()), [] => Err(VfsError::NotFound(path.as_str().to_string())), _ => Err(VfsError::Ambiguous(path.as_str().to_string())), } } } +#[cfg(unix)] +fn file_identity(metadata: &fs::Metadata) -> Option<u64> { + Some((metadata.dev() as u64).rotate_left(32) ^ metadata.ino()) +} + +#[cfg(windows)] +fn file_identity(metadata: &fs::Metadata) -> Option<u64> { + Some( + (metadata.volume_serial_number() as u64).rotate_left(40) + ^ ((metadata.file_index_high() as u64) << 32) + ^ metadata.file_index_low() as u64, + ) +} + +#[cfg(not(any(unix, windows)))] +fn file_identity(_metadata: &fs::Metadata) -> Option<u64> { + None +} + impl Vfs for MemoryVfs { fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> { let resolved = self.resolve_path(path)?; @@ -339,13 +377,9 @@ impl Vfs for MemoryVfs { fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> { let mut out = Vec::new(); for (path, bytes) in &self.files { - if path - .as_bytes() - .get(..prefix.as_str().len()) - .is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_str().as_bytes())) - { + if has_segment_boundary_prefix_bytes(path, prefix.as_bytes()) { let normalized = fparkan_path::normalize_relative( - path.as_bytes(), + path, fparkan_path::PathPolicy::StrictLegacy, ) .map_err(|_| VfsError::Path)?; @@ -362,6 +396,25 @@ impl Vfs for MemoryVfs { } } +fn has_segment_boundary_prefix_bytes(haystack: &[u8], needle: &[u8]) -> bool { + if haystack.len() < needle.len() { + return false; + } + if haystack.len() == needle.len() { + return haystack + .iter() + .zip(needle.iter()) + .all(|(left, right)| left.eq_ignore_ascii_case(right)); + } + if haystack[needle.len()] != b'/' { + return false; + } + haystack[..needle.len()] + .iter() + .zip(needle.iter()) + .all(|(left, right)| left.eq_ignore_ascii_case(right)) +} + /// Layered VFS with deterministic first-layer precedence. #[derive(Clone, Default)] pub struct OverlayVfs { @@ -508,6 +561,21 @@ mod tests { } #[test] + fn memory_vfs_list_prefix_is_boundary_safe() { + let mut vfs = MemoryVfs::default(); + let exact = normalize_relative(b"DATA/Land.map", PathPolicy::StrictLegacy).expect("path"); + let sibling = normalize_relative(b"DATA2/Land.map", PathPolicy::StrictLegacy).expect("path"); + vfs.insert(exact.clone(), Arc::from(b"exact".as_slice())); + vfs.insert(sibling, Arc::from(b"sibling".as_slice())); + + let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix"); + let entries = vfs.list(&prefix).expect("list"); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].path.as_str(), exact.as_str()); + } + + #[test] fn directory_vfs_fingerprint_changes_for_same_length_content() { let root = unique_test_dir("content-fingerprint"); std::fs::create_dir_all(root.join("DATA")).expect("mkdir"); @@ -590,6 +658,23 @@ mod tests { } #[test] + fn memory_vfs_distinguishes_non_utf8_path_bytes() { + let mut vfs = MemoryVfs::default(); + let ascii = normalize_relative(b"DATA/normal.bin", PathPolicy::HostCompatible) + .expect("ascii path"); + let binary = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible) + .expect("binary path"); + vfs.insert(ascii.clone(), Arc::from(b"ascii".as_slice())); + vfs.insert(binary.clone(), Arc::from(b"binary".as_slice())); + + let binary_query = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible) + .expect("binary query"); + + assert_eq!(vfs.read(&binary_query).expect("read binary").as_ref(), b"binary"); + assert_eq!(vfs.read(&ascii).expect("read ascii").as_ref(), b"ascii"); + } + + #[test] fn overlay_vfs_uses_first_matching_layer() { let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path"); let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix"); diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index 017cc9d..18b6ca1 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -114,23 +114,16 @@ key, configuration, device profile, initial state, input/time script и верс ## Local evidence requests На текущем рабочем месте закрыты статические, corpus и headless runtime gates. -Для macOS Desktop GL есть только безопасный command/state trace и исторический -одноразовый offscreen pixel probe: - -- `cargo test -p fparkan-render-gl --offline desktop_gl33_triangle_command_capture`; -- `fixtures/acceptance/macos-gl33-triangle-capture.json`. - -`S3-GL-001` не считается закрытым: временный `rustc` probe создал CGL/OpenGL -offscreen FBO, выполнил shader-based triangle draw, прочитал RGBA pixels и -сохранил hash capture, но постоянный workspace adapter по-прежнему не создаёт -SDL window, GL context, GPU resources, shader programs, draw calls или present. -Probe не добавляет project-owned `unsafe` в workspace и остаётся только external -evidence request artifact. - -Для повышения `S3-GL-001` до `covered` нужен постоянный macOS backend через -выбранную safe facade stack: SDL event/window/context lifecycle, Desktop GL 3.3 -shader/buffer/texture/draw/present path, hidden-window/offscreen smoke test и -licensed local model/terrain frame capture. +Для локально воспроизводимого Desktop backend подтверждено только command/state trace +в существующем GL-воркфлоу: + +- `fixtures/acceptance/macos-gl33-triangle-capture.json`; + +`S3-GL-001` пока не закрыт: текущая evidence не отражает полноценный +`winit`+`fparkan-render-vulkan` path с real surface/present pipeline. +Для закрытия требования требуется постоянный workspace-владельческий backend на +`winit`/`fparkan-platform-winit` + `fparkan-render-vulkan` с реальным +surface/present pipeline, command/state parity и licensed frame capture. Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2 backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же diff --git a/docs/baseline/current-project-audit.md b/docs/baseline/current-project-audit.md index 1c566fd..2a62443 100644 --- a/docs/baseline/current-project-audit.md +++ b/docs/baseline/current-project-audit.md @@ -3,11 +3,13 @@ Baseline command: ```text -env RUSTC=/Users/valentineus/.rustup/toolchains/stable-aarch64-apple-darwin/bin/rustc /opt/homebrew/bin/rustup run stable cargo test --workspace --offline +cargo xtask ci ``` -Result on 2026-06-22: +Result on 2026-06-23: -- library and binary unit tests compile and pass after aligning SDL2 versions and pinning `toml` to cached `0.8`; -- doctests fail in this shell because `rustdoc` is not in PATH unless `RUSTDOC` is also set to the real toolchain binary; -- full online dependency resolution is unavailable in the sandbox. +- canonical pipeline now uses a fixed MSRV/toolchain, policy checks, + full-format workspace test command, `clippy`/`doc`/`cargo deny` gates and + typed manifest parsing in `xtask`; +- `rpath`/offline mode is still useful for synthetic local checks; +- full online dependency resolution remains unavailable in the sandbox. diff --git a/docs/tomes/07-implementation.md b/docs/tomes/07-implementation.md index 968d61b..49c21e4 100644 --- a/docs/tomes/07-implementation.md +++ b/docs/tomes/07-implementation.md @@ -34,7 +34,7 @@ behavior unit state machines, target and path requests physics control systems, collision proxies and contacts animation pose sampling, hierarchy and blending audio sample cache, sources, listener and buses -render legacy-state compatibility and modern backend +render immutable frame contracts and modern backend network game message schema plus transport adapters tools validators, extractors, viewers, captures and editors ``` @@ -103,8 +103,8 @@ CPU assets и GPU resources имеют отдельные бюджеты и от ### Backend adapters -Render, audio, input и network получают отдельные adapters. Legacy compatibility -state живёт выше Vulkan, D3D11 или Metal backend; DirectPlay compatibility живёт +Render, audio, input и network получают отдельные adapters. Compatibility state +живёт вне Vulkan, D3D11 или Metal backend; DirectPlay compatibility живёт отдельно от modern transport. Так можно заменить платформу, не меняя форматы, игровую семантику и regression corpus. diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv index 0985df6..bc67d01 100644 --- a/fixtures/acceptance/coverage.tsv +++ b/fixtures/acceptance/coverage.tsv @@ -21,7 +21,7 @@ S0-CORPUS-005 covered cargo test -p fparkan-corpus --offline fingerprint_changes S0-CORPUS-006 covered cargo test -p fparkan-corpus --offline atomic_report_write S0-CLI-001 covered cargo test -p fparkan-cli --offline stable_exit_codes_are_mapped S0-CLI-002 covered cargo test -p fparkan-cli --offline accepts_json_format_option archive_json_has_schema_version -S0-GL-001 covered cargo test -p fparkan-platform-sdl -p fparkan-render-gl --offline adapter_boundary_is_project_owned_unsafe_free +S0-GL-001 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates @@ -71,6 +71,8 @@ S1-PATH-005 covered cargo test -p fparkan-path --offline rejects_escape S1-PATH-006 covered cargo test -p fparkan-path --offline rejects_absolute_drive_and_nul_paths S1-PATH-007 covered cargo test -p fparkan-path --offline join_under_keeps_normalized_path_below_root S1-PATH-008 covered cargo test -p fparkan-path --offline original_separators_and_raw_bytes_are_preserved +S1-H02 covered cargo test -p fparkan-path --offline accepts_non_utf8_legacy_bytes +S1-M01 covered cargo test -p fparkan-vfs --offline memory_vfs_list_prefix_is_boundary_safe S1-RSLI-001 covered cargo test -p fparkan-rsli --offline parses_minimal_empty_library S1-RSLI-002 covered cargo test -p fparkan-rsli --offline rejects_invalid_header_fields S1-RSLI-003 covered cargo test -p fparkan-rsli --offline rejects_entry_table_bounds @@ -222,7 +224,7 @@ S3-RENDER-008 covered cargo test -p fparkan-render --offline recording_backend_s S3-RENDER-009 covered cargo xtask policy S3-GL-001 omitted permanent macOS Desktop GL 3.3 adapter is not implemented; historical CGL probe is retained as external evidence only S3-GL-002 omitted outside the current macOS-focused goal scope; GLES2 remains documented for portable/non-macOS targets -S3-GL-003 covered cargo test -p fparkan-render-gl --offline shader_compile_failure_diagnostic_contains_profile_and_log +S3-GL-003 blocked legacy fparkan-render-gl adapter removed while Vulkan renderer path is being brought in as the stage-3 backend S3-VIEWER-001 covered cargo test -p fparkan-viewer --offline model_fixture_uses_viewer_service_and_render_commands S4-ANIM-001 covered cargo test -p fparkan-animation --offline anim_key24_decodes_signed_quaternion S4-ANIM-002 covered cargo test -p fparkan-animation --offline frame_map_decodes_u16_and_uses_attr_frame_count diff --git a/fixtures/acceptance/stage_0_2_roadmap.md b/fixtures/acceptance/stage_0_2_roadmap.md new file mode 100644 index 0000000..f8ad743 --- /dev/null +++ b/fixtures/acceptance/stage_0_2_roadmap.md @@ -0,0 +1,367 @@ +# Stage 0-2 acceptance IDs + +`L0-COPYRIGHT-001` +`L0-P1-001` +`L0-P1-002` +`L0-P2-001` +`L0-P2-002` +`S0-ARCH-001` +`S0-ARCH-002` +`S0-ARCH-003` +`S0-ARCH-004` +`S0-ARCH-005` +`S0-ARCH-006` +`S0-DIAG-001` +`S0-DIAG-002` +`S0-CORPUS-001` +`S0-CORPUS-002` +`S0-CORPUS-003` +`S0-CORPUS-004` +`S0-CORPUS-005` +`S0-CORPUS-006` +`S0-CLI-001` +`S0-CLI-002` +`S0-GL-001` +`S0-LIMIT-001` +`S0-LIMIT-002` +`L1-P1-NRES-001` +`L1-P2-NRES-001` +`L1-P1-NRES-002` +`L1-P2-NRES-002` +`L1-P1-NRES-003` +`L1-P2-NRES-003` +`L1-P1-RSLI-001` +`L1-P2-RSLI-001` +`L1-RSLI-QUIRK-001` +`L1-P1-PATH-001` +`L1-P2-PATH-001` +`S1-NRES-001` +`S1-NRES-002` +`S1-NRES-003` +`S1-NRES-004` +`S1-NRES-005` +`S1-NRES-006` +`S1-NRES-007` +`S1-NRES-008` +`S1-NRES-009` +`S1-NRES-010` +`S1-NRES-020` +`S1-NRES-021` +`S1-NRES-011` +`S1-NRES-012` +`S1-NRES-013` +`S1-NRES-014` +`S1-NRES-015` +`S1-NRES-016` +`S1-NRES-017` +`S1-NRES-018` +`S1-NRES-019` +`S1-NRES-022` +`S1-NRES-023` +`S1-NRES-024` +`S1-NRES-025` +`S1-NRES-PROP-001` +`S1-NRES-PROP-002` +`S1-NRES-FUZZ-001` +`S1-PATH-001` +`S1-PATH-002` +`S1-PATH-003` +`S1-PATH-004` +`S1-PATH-005` +`S1-PATH-006` +`S1-PATH-007` +`S1-PATH-008` +`S1-H02` +`S1-M01` +`S1-RSLI-001` +`S1-RSLI-002` +`S1-RSLI-003` +`S1-RSLI-004` +`S1-RSLI-005` +`S1-RSLI-006` +`S1-RSLI-007` +`S1-RSLI-008` +`S1-RSLI-009` +`S1-RSLI-010` +`S1-RSLI-011` +`S1-RSLI-012` +`S1-RSLI-013` +`S1-RSLI-014` +`S1-RSLI-015` +`S1-RSLI-016` +`S1-RSLI-017` +`S1-RSLI-018` +`S1-RSLI-019` +`S1-RSLI-020` +`S1-RSLI-021` +`S1-RSLI-022` +`S1-RSLI-023` +`S1-RSLI-PROP-001` +`S1-RSLI-FUZZ-001` +`S1-RES-001` +`S1-RES-002` +`S1-RES-003` +`S1-RES-004` +`S1-VFS-001` +`S1-VFS-002` +`S1-VFS-003` +`S1-VFS-004` +`L2-P1-UNIT-001` +`L2-P2-UNIT-001` +`L2-P1-REG-001` +`L2-P2-REG-001` +`L2-P1-GRAPH-001` +`L2-P2-GRAPH-001` +`L2-P1-INHERIT-001` +`L2-P2-INHERIT-001` +`L2-P1-NONGEO-001` +`L2-P2-NONGEO-001` +`L2-P1-GRAPH-002` +`L2-P2-GRAPH-002` +`S2-REG-001` +`S2-REG-002` +`S2-REG-003` +`S2-REG-004` +`S2-UNIT-001` +`S2-UNIT-002` +`S2-UNIT-003` +`S2-UNIT-004` +`S2-UNIT-005` +`S2-UNIT-006` +`S2-UNIT-007` +`S2-PROTO-001` +`S2-PROTO-002` +`S2-PROTO-003` +`S2-PROTO-004` +`S2-PROTO-005` +`S2-PROTO-006` +`S2-PROTO-007` +`S2-PROTO-008` +`S2-PROTO-009` +`S2-PROTO-010` +`S2-PROTO-011` +`S2-PROTO-012` +`S2-PROTO-013` +`S2-PROTO-014` +`S2-GRAPH-001` +`S2-GRAPH-002` +`S2-GRAPH-003` +`S2-GRAPH-004` +`S2-GRAPH-005` +`S2-GRAPH-006` +`S2-PROP-001` +`S2-FUZZ-001` +`L3-P1-MSH-001` +`L3-P2-MSH-001` +`L3-P1-TEXM-001` +`L3-P2-TEXM-001` +`L3-P1-MAT0-001` +`L3-P2-MAT0-001` +`L3-P1-WEAR-001` +`L3-P2-WEAR-001` +`L3-P1-ASSET-001` +`L3-P2-ASSET-001` +`L3-P1-CAPTURE-001` +`L3-P2-CAPTURE-001` +`S3-WEAR-001` +`S3-WEAR-002` +`S3-WEAR-003` +`S3-WEAR-004` +`S3-WEAR-005` +`S3-MAT0-001` +`S3-MAT0-002` +`S3-MAT0-003` +`S3-MAT0-004` +`S3-MAT0-005` +`S3-MAT0-006` +`S3-MSH-001` +`S3-MSH-002` +`S3-MSH-003` +`S3-MSH-004` +`S3-MSH-005` +`S3-MSH-006` +`S3-MSH-007` +`S3-MSH-008` +`S3-MSH-009` +`S3-MSH-010` +`S3-MSH-011` +`S3-MSH-012` +`S3-MSH-013` +`S3-MSH-014` +`S3-MSH-015` +`S3-MSH-016` +`S3-MSH-017` +`S3-MSH-PROP-001` +`S3-MSH-FUZZ-001` +`S3-TEXM-001` +`S3-TEXM-002` +`S3-TEXM-003` +`S3-TEXM-004` +`S3-TEXM-005` +`S3-TEXM-006` +`S3-TEXM-007` +`S3-TEXM-008` +`S3-TEXM-009` +`S3-TEXM-010` +`S3-TEXM-011` +`S3-TEXM-012` +`S3-TEXM-013` +`S3-TEXM-FUZZ-001` +`S3-MAT0-007` +`S3-MAT-RESOLVE-001` +`S3-MAT-RESOLVE-002` +`S3-MAT-RESOLVE-003` +`S3-MAT-RESOLVE-004` +`S3-MAT-RESOLVE-005` +`S3-RENDER-001` +`S3-RENDER-002` +`S3-RENDER-003` +`S3-RENDER-004` +`S3-RENDER-005` +`S3-RENDER-006` +`S3-RENDER-007` +`S3-RENDER-008` +`S3-RENDER-009` +`S3-GL-001` +`S3-GL-002` +`S3-GL-003` +`S3-VIEWER-001` +`S4-ANIM-001` +`S4-ANIM-002` +`S4-ANIM-003` +`S4-ANIM-004` +`S4-ANIM-005` +`S4-ANIM-006` +`S4-ANIM-007` +`S4-ANIM-008` +`S4-ANIM-009` +`S4-ANIM-010` +`S4-ANIM-011` +`S4-ANIM-012` +`S4-ANIM-013` +`S4-ANIM-014` +`S4-ANIM-PROP-001` +`S4-MAT-001` +`S4-MAT-002` +`S4-MAT-003` +`S4-MAT-004` +`S4-MAT-005` +`S4-MAT-006` +`S4-FX-001` +`S4-FX-002` +`S4-FX-011` +`S4-FX-012` +`S4-FX-013` +`S4-FX-014` +`S4-FX-015` +`S4-FX-016` +`S4-FX-017` +`S4-FX-018` +`S4-FX-019` +`S4-FX-020` +`S4-FX-021` +`S4-FX-022` +`S4-FX-023` +`S4-FX-024` +`S4-FX-FUZZ-001` +`L4-P1-ANIM-001` +`L4-P2-ANIM-001` +`L4-P1-CAPTURE-001` +`L4-P2-CAPTURE-001` +`L4-P1-FX-001` +`L4-P2-FX-001` +`L4-P1-FX-002` +`L4-P2-FX-002` +`L4-FX-OP6-001` +`L4-P1-EFFECT-001` +`L4-P2-EFFECT-001` +`S5-LMESH-001` +`S5-LMESH-002` +`S5-LMESH-003` +`S5-LMESH-004` +`S5-LMESH-005` +`S5-LMESH-006` +`S5-LMESH-007` +`S5-LMESH-008` +`S5-LMESH-009` +`S5-LMAP-001` +`S5-LMAP-002` +`S5-LMAP-003` +`S5-LMAP-004` +`S5-LMAP-005` +`S5-LMAP-006` +`S5-LMAP-007` +`S5-LMAP-008` +`S5-LMAP-009` +`S5-LMAP-010` +`S5-LMAP-011` +`S5-TERRAIN-001` +`S5-TERRAIN-002` +`S5-TERRAIN-003` +`S5-TERRAIN-004` +`S5-TMA-001` +`S5-TMA-002` +`S5-TMA-003` +`S5-TMA-004` +`S5-TMA-005` +`S5-TMA-006` +`S5-TMA-007` +`S5-TMA-008` +`S5-TMA-009` +`S5-TMA-010` +`S5-TMA-011` +`S5-TMA-012` +`S5-TMA-013` +`S5-TMA-014` +`S5-TMA-015` +`S5-TMA-016` +`S5-TMA-017` +`S5-TMA-PROP-001` +`S5-TMA-FUZZ-001` +`S5-LOAD-001` +`S5-LOAD-002` +`S5-LOAD-003` +`S5-LOAD-004` +`S5-LOAD-005` +`S5-LOAD-006` +`S5-LOAD-007` +`S5-LOAD-008` +`S5-LOAD-009` +`S5-LOAD-010` +`S5-WORLD-001` +`S5-WORLD-002` +`S5-WORLD-003` +`S5-WORLD-004` +`S5-WORLD-005` +`S5-WORLD-006` +`S5-WORLD-007` +`S5-WORLD-008` +`S5-WORLD-009` +`S5-WORLD-010` +`S5-WORLD-011` +`S5-WORLD-012` +`S5-WORLD-013` +`S5-WORLD-014` +`S5-WORLD-015` +`S5-WORLD-016` +`S5-WORLD-017` +`S5-WORLD-018` +`S5-WORLD-019` +`S5-WORLD-PROP-001` +`L5-P1-LMESH-001` +`L5-P2-LMESH-001` +`L5-P1-LMAP-001` +`L5-P2-LMAP-001` +`L5-LMAP-POLY-001` +`L5-P1-TMA-001` +`L5-P2-TMA-001` +`L5-P1-MISSION-001` +`L5-P2-MISSION-001` +`L5-P1-MISSION-002` +`L5-P2-MISSION-002` +`L5-P1-HEADLESS-001` +`L5-P2-HEADLESS-001` +`L5-P1-RENDER-001` +`L5-P2-RENDER-001` +`L3-DEVICE-001` +`L5-RG40-001` diff --git a/fparkan_stage_0_2_audit_2026-06-23.md b/fparkan_stage_0_2_audit_2026-06-23.md new file mode 100644 index 0000000..34dfb91 --- /dev/null +++ b/fparkan_stage_0_2_audit_2026-06-23.md @@ -0,0 +1,1280 @@ +# FParkan — аудит Stage 0–2 и план полного закрытия + +**Проект:** `valentineus/fparkan` +**Проверенная ветка:** `devel` GitHub-зеркала +**Дата аудита:** 23 июня 2026 года +**Область:** Stage 0, Stage 1 и Stage 2 из документа «План реализации stage 0–5: Vulkan revision» +**Метод:** статический архитектурный и кодовый аудит +**Сборка и исполнение:** не выполнялись; `cargo build`, `cargo test`, Vulkan smoke и licensed corpus jobs не запускались + +--- + +## 1. Итоговый вердикт + +На проверенном состоянии ветки `devel` **ни один из Stage 0–2 нельзя считать закрытым**. + +| Stage | Статус | Закрытие exit gate | Главная причина | +|---|---|---:|---| +| Stage 0 — governance и Vulkan foundation | **BLOCKED** | 0 из 3 обязательных результатов подтверждены | Нет `winit`/Vulkan adapters и реального swapchain; workspace всё ещё содержит SDL/OpenGL stubs; CI gate неполон | +| Stage 1 — paths, VFS и archives | **PARTIAL, NOT CLOSED** | 0 из 3 результатов подтверждены | Нет общего allocation budget во всех parsers/decompressors, полноценного RsLi edit/writer path, сквозных diagnostics и production-parser corpus gate | +| Stage 2 — prototype graph и CPU assets | **BLOCKED BY ARCHITECTURE** | 0 из 3 результатов подтверждены | `fparkan-assets` не является единственным preparation layer; runtime и apps парсят данные напрямую; graph не хранит полноценные typed nodes/edges/provenance | + +Положительная сторона: проект уже имеет хороший фундамент — bounded cursor, NRes editor с preserved regions, generation handles, deterministic caches, строгую workspace lint policy, typed mission/model primitives, deterministic render command capture и частичное раскрытие unit/prototype/material/texture зависимостей. Эти наработки следует сохранить. Основная работа — не переписывание всего проекта, а **устранение архитектурных обходов, доведение safety contracts и добавление доказуемых acceptance gates**. + +### Решение по выпуску этапов + +- **Stage 0 нельзя закрывать декларативно.** Он закрывается только артефактами трёх нативных платформ: реальный Vulkan swapchain, triangle, resize, 300 frames и нулевые validation errors. +- **Stage 1 нельзя закрывать только unit tests.** Нужны стабильные отчёты и byte-identical roundtrip на лицензированных каталогах обеих частей игры. +- **Stage 2 нельзя закрывать текущими count-only reports.** Требуется материализованный dependency graph, typed provenance каждого edge и обязательный путь `runtime → AssetManager → immutable CPU assets`. + +--- + +## 2. Основание аудита + +Канонические критерии взяты из страницы Notion: + +- «План реализации stage 0–5: Vulkan revision»; +- page ID: `387e79f2-db39-8177-8f94-cdf34db5f93f`; +- URL: <https://app.notion.com/p/387e79f2db3981778f94cdf34db5f93f>. + +Проверялась ветка `devel` GitHub-зеркала: + +- <https://github.com/valentineus/fparkan/tree/devel> + +Ключевые проверенные файлы перечислены в разделе «Реестр доказательств». + +### Ограничения вывода + +1. Ветка `devel` является движущейся ссылкой; connected GitHub mirror не предоставил надёжный SHA текущего tip. Для повторяемости следующего formal audit следует запускать на закреплённом commit SHA/tag. +2. README указывает на self-hosted primary repository. История его Gitea runners и закрытые CI artifacts в рамках этого аудита не были доступны. +3. Licensed Part 1/Part 2 corpus не запускался. Поэтому любые утверждения о «нулевых failures», точных reachability counts и roundtrip являются **неподтверждёнными**, даже если соответствующие тестовые функции существуют в коде. +4. Vulkan runtime не запускался и validation layers не проверялись. +5. Отсутствие динамической проверки не мешает определить архитектурные blockers: manifests и composition roots однозначно показывают, что реального Vulkan vertical slice сейчас нет. + +--- + +## 3. Шкала статусов и приоритетов + +### Статус требования + +- **PASS** — реализация и статическое доказательство соответствуют требованию; динамический gate всё равно может оставаться непроверенным. +- **PARTIAL** — существенная часть присутствует, но контракт или acceptance evidence неполны. +- **FAIL** — требуемой реализации нет либо текущая архитектура ей противоречит. +- **UNVERIFIED** — реализация может существовать, но нет доступного воспроизводимого доказательства. + +### Приоритет замечаний + +- **BLOCKER** — без исправления Stage невозможно закрыть. +- **HIGH** — риск повреждения данных, unbounded resource use, неправильного dependency graph или ложноположительного acceptance. +- **MEDIUM** — архитектурный долг, диагностическая неполнота или слабая тестируемость. +- **LOW** — документация, ergonomics или cleanup, не являющиеся самостоятельным блокером. + +--- + +# 4. Stage 0 — Governance, reproducibility и Vulkan foundation + +## 4.1 Матрица требований + +| Требование Stage 0 | Статус | Доказательство текущего состояния | Что требуется для закрытия | +|---|---|---|---| +| Exact stable Rust toolchain и MSRV | **FAIL** | `rust-toolchain.toml` содержит только `channel = "stable"`; `workspace.package.rust-version` отсутствует | Закрепить точный toolchain, добавить MSRV и отдельный MSRV job | +| Полный `cargo xtask ci` | **FAIL** | Сейчас выполняются custom rustfmt, policy, `cargo test --workspace --locked --offline` и clippy без полного набора flags; отсутствует обязательный doc gate | Реализовать канонический fmt/test/clippy/doc/security/source/license pipeline | +| `--all-targets --all-features`, `-D warnings` | **FAIL** | В inspected `xtask` эти параметры отсутствуют | Добавить дословно и проверить negative tests самого xtask | +| License/advisory/source policy | **PARTIAL/UNVERIFIED** | Есть custom policy и workspace license, но нет доказанного advisory/source gate и formal allowlist | Подключить `cargo-deny` или эквивалент с versioned policy; выгружать report artifact | +| Typed TOML parsing и `cargo_metadata` | **FAIL** | Licensed manifest разбирается ручным line parser; `xtask/Cargo.toml` не зависит от TOML parser или `cargo_metadata` | Ввести serde-backed schema, deny unknown fields, canonical path validation, `cargo_metadata` | +| CI matrix Windows/Linux/macOS | **UNVERIFIED, EXIT BLOCKER** | Канонический audit сам отмечает отсутствие подтверждённой hosted matrix; доступных run artifacts нет | Создать matrix и platform-native acceptance jobs с сохраняемыми reports | +| `fparkan-platform-winit` | **FAIL** | В workspace присутствует `fparkan-platform-sdl`; он содержит in-memory stubs и не зависит от SDL. `winit` adapter отсутствует | Новый adapter с event loop, lifecycle, DPI, input, handles, suspend/resume, resize | +| Backend-neutral platform contract | **FAIL** | Core port экспортирует `GraphicsProfile::DesktopCore/Embedded` и `GraphicsContextRequest`, то есть OpenGL concepts; `WindowPort::present()` смешивает window и GPU presentation | Удалить GL concepts; present перенести в render backend; ввести normalized lifecycle/input API | +| `fparkan-render-vulkan` | **FAIL** | Workspace содержит `fparkan-render-gl`; crate только формирует canonical text capture и не вызывает GPU API | Создать Vulkan adapter с instance/device/surface/swapchain/pipeline/sync | +| Реальный Vulkan triangle | **FAIL** | Ни `ash`, ни `ash-window`, ни `winit` не подключены; rendered apps используют `RecordingBackend` | Реальный indexed triangle в отдельном smoke app и composition roots | +| Device scoring и capability report | **FAIL** | Реального device discovery нет | Pure policy module + Vulkan enumeration + deterministic JSON report | +| Swapchain recreation | **FAIL** | Surface/swapchain отсутствуют | Обработать resize, zero extent, out-of-date, suboptimal, minimized/suspended states | +| macOS portability enumeration/subset | **FAIL** | MoltenVK/Vulkan adapter отсутствуют | Instance portability flag, extension enumeration, subset feature report, packaged MoltenVK | +| Offline SPIR-V build/validation | **FAIL** | Shader manifest/hash pipeline не найден; GL stub проверяет только empty/`#error` markers | Versioned shader sources, compiler pin, SPIR-V validator, descriptor manifest, embedded hashes | +| Удаление прежних adapters/references | **FAIL** | Root workspace явно включает SDL и GL adapters; docs также содержат старую multi-backend формулировку | Удалить crates, lockfile refs, policy exceptions, docs и stale tests | +| Headless без window/GPU deps | **PASS на manifest-level** | `fparkan-headless` зависит от runtime/VFS/world и не содержит adapter dependency | Добавить automated `cargo tree`/metadata assertion и target build gate | +| 300-frame smoke + resize + validation | **FAIL/NOT RUNNABLE** | Нет реального backend | Добавить platform jobs и machine-readable validation report | +| Negative loader/device/present/format tests | **FAIL** | Нет Vulkan error model | Dependency-injected policy layer + loader/device/surface failure fixtures | + +## 4.2 Критические замечания Stage 0 + +### S0-B01 — Workspace всё ещё построен вокруг удаляемых SDL/OpenGL stub crates + +**Приоритет:** BLOCKER +**Файлы:** `Cargo.toml`, `adapters/fparkan-platform-sdl`, `adapters/fparkan-render-gl` + +Root workspace включает оба прежних adapter crate. При этом: + +- SDL adapter не содержит зависимости на SDL и является deterministic stub; +- GL adapter не содержит OpenGL binding и только сохраняет command captures; +- tests этих crates доказывают поведение stubs, а не platform/GPU integration. + +Это создаёт опасный ложноположительный сигнал: названия adapters выглядят как реализованные backends, но фактически проверяется только модель интерфейса. + +**Рекомендация:** удалить legacy adapters только после появления `platform-winit` и `render-vulkan`, но до формального Stage 0 acceptance. На время миграции пометить их `legacy-proof`, исключить из default members и запретить composition roots ссылаться на них. + +### S0-B02 — Core platform API содержит OpenGL-specific contract + +**Приоритет:** BLOCKER +**Файл:** `crates/fparkan-platform/src/lib.rs` + +Проблемы: + +- `GraphicsProfile` и `GraphicsContextRequest` описывают GL/GLES context, которого в Vulkan architecture нет; +- `WindowPort::present()` неверно закрепляет presentation за window abstraction. В Vulkan presentation зависит от swapchain, queue, acquired image и semaphores и должен принадлежать render adapter; +- `PlatformEvent` содержит только `Quit`; +- отсутствуют DPI, resize, occlusion/minimize, focus, keyboard/mouse, lifecycle, suspend/resume и raw handles; +- `PlatformError::Backend` не предоставляет context/source. + +**Целевой контракт:** platform crate сообщает события и предоставляет handle/size; render adapter владеет surface/swapchain/present. + +### S0-B03 — Нет реального Vulkan code path + +**Приоритет:** BLOCKER + +Ни один inspected manifest не подключает `ash`, `ash-window`, `winit` или `raw-window-handle`. `fparkan-game` использует `RecordingBackend`; `fparkan-viewer` является CLI inspector. Следовательно, instance/device/surface/swapchain не могут быть созданы текущим кодом. + +**Definition of fixed:** в workspace существует отдельный `fparkan-render-vulkan`; smoke executable проходит 300 frames, resize и clean shutdown на Windows, Linux и macOS/MoltenVK. + +### S0-B04 — CI command не соответствует каноническому gate + +**Приоритет:** BLOCKER +**Файл:** `xtask/src/main.rs` + +Текущий `ci` не подтверждает: + +- all targets/features; +- `-D warnings` для clippy; +- rustdoc broken-link denial; +- advisory/source policy; +- platform adapter denylist; +- отсутствие project-owned unsafe вне allowlist; +- корректность самого acceptance manifest parser. + +Отдельный риск: custom recursion для rustfmt может расходиться с `cargo fmt --all -- --check` и форматировать/пропускать файлы иначе, чем Cargo workspace. + +### S0-B05 — Toolchain не воспроизводим + +**Приоритет:** BLOCKER +**Файл:** `rust-toolchain.toml` + +`stable` меняется со временем. Один и тот же commit может пройти сегодня и не пройти после следующего stable release. Также не указан `rust-version`, поэтому минимально поддерживаемый компилятор не является контрактом. + +**Рекомендация:** точный channel вида `1.xx.y`, components и targets; `rust-version` в workspace package; controlled update PR с changelog и full matrix. + +### S0-H01 — Глобальный `unsafe_code = forbid` требует заранее спроектированного FFI boundary + +**Приоритет:** HIGH + +Полный запрет полезен для neutral crates, но raw Vulkan bindings неизбежно требуют узких unsafe calls. Нельзя ослаблять lint всему workspace. + +**Целевая схема:** отдельный low-level adapter crate или модуль, который: + +- не наследует blanket `forbid`; +- устанавливает `deny(unsafe_op_in_unsafe_fn)`; +- разрешает unsafe только в одном/нескольких audited modules; +- требует `// SAFETY:` comment; +- не экспортирует raw handles; +- проходит custom policy scanner. + +### S0-H02 — Render command model пока недостаточен для последующих Vulkan assets + +**Приоритет:** HIGH, не блокирует самый первый hardcoded triangle + +`fparkan-render` имеет хорошую deterministic command/capture основу, но neutral API использует `GpuMeshId`/`GpuMaterialId` ещё до существования GPU resource registry. Для Stage 2/3 это смешивает CPU asset identity и backend allocation identity. + +**Рекомендация:** neutral layer должен оперировать `MeshAssetId`, `MaterialAssetId`, immutable draw items и legacy pipeline state. Vulkan adapter локально сопоставляет их с buffers/images/descriptors. + +### S0-M01 — Documentation drift + +**Приоритет:** MEDIUM + +`docs/tomes/07-implementation.md` всё ещё описывает старую последовательность этапов и допускает Vulkan/D3D11/Metal backend wording. `parity/README.md` ссылается на `crates/render-parity`, которого нет среди workspace members, а `parity/cases.toml` не содержит активных cases. + +Документация должна иметь один canonical stage source либо автоматически генерируемую проверку согласованности. + +## 4.3 Положительные элементы Stage 0 + +- `Cargo.lock` и `--locked` упомянуты и используются в текущем workflow. +- Workspace lint policy достаточно строгая для neutral crates. +- Synthetic и licensed test paths концептуально разделены. +- `fparkan-headless` не зависит от platform/render adapters на manifest-level. +- `fparkan-render` уже предоставляет deterministic command ordering, validation и capture — это можно использовать как pre-GPU oracle. + +## 4.4 Обязательные изменения для полного закрытия Stage 0 + +1. Закрепить toolchain/MSRV. +2. Переписать xtask configuration на typed TOML + `cargo_metadata`. +3. Завершить CI/security/doc gates. +4. Пересмотреть platform port и удалить GL-specific types. +5. Реализовать `fparkan-platform-winit`. +6. Реализовать `fparkan-render-vulkan` с узким unsafe boundary. +7. Добавить offline SPIR-V pipeline. +8. Подключить adapters к game/viewer composition roots. +9. Удалить legacy SDL/GL crates и stale docs. +10. Запустить и сохранить acceptance artifacts на трёх OS. + +--- + +# 5. Stage 1 — Paths, VFS и archives + +## 5.1 Матрица требований + +| Требование Stage 1 | Статус | Доказательство текущего состояния | Что требуется для закрытия | +|---|---|---|---| +| Raw legacy bytes + normalized + ASCII key + host path | **PARTIAL** | Есть `OriginalPathBytes`, `NormalizedPath`, ASCII lookup и policies; нормализация сначала требует UTF-8 | Сделать byte-first identity; decoding только для diagnostics; определить OS host conversion | +| Strict/compatible path policies | **PARTIAL/PASS** | Два режима есть и тестируются | Формализовать различия и применить одинаково во всех callers | +| Casefold collision policy | **PARTIAL** | Directory/Memory VFS обнаруживают ambiguity; Overlay имеет deterministic precedence | Добавить segment-boundary tests и общий collision report contract | +| Symlink-safe traversal | **PARTIAL/HIGH RISK** | Symlinks проверяются через `symlink_metadata`; однако check/open разделены | Capability-based/openat traversal, root confinement, cycle/escape tests | +| Common `DecodeLimits` | **PARTIAL** | `fparkan-binary::Limits` существует, но многие parsers используют собственные constants или API без limits | Единый `DecodeContext` во всех parsers | +| Common cumulative `AllocationBudget` | **FAIL** | Stateful budget не найден | Reservation/refund model для allocations и decompression output | +| NRes lossless reader/editor/writer | **NEAR PASS, EXIT UNVERIFIED** | Есть lossless/canonical profiles, editor, preserved regions | Подключить limits/diagnostics; corpus roundtrip на обеих частях | +| RsLi all observed decode methods | **PARTIAL** | Enum и implementations покрывают stored/XOR/LZSS/adaptive/deflate variants | Corpus proof, method coverage table, bounded output | +| RsLi explicit compatibility profile | **PARTIAL** | AO, EOF+1 и invalid presort toggles есть | Versioned quirk registry с evidence IDs и per-file activation trace | +| RsLi lossless writer/edit model | **FAIL** | `WriteProfile` содержит только возврат исходного image; editor/repack отсутствуют | Editable document, preserved unknowns, deterministic table rebuild, no-edit identity | +| Structured diagnostics end-to-end | **FAIL** | Отдельный diagnostics crate существует, но NRes/RsLi/VFS/resource/prototype/runtime его не используют | Единый typed diagnostic envelope, source chain и offsets | +| Generation handles | **PASS** | Resource repository хранит generation и выявляет stale handles | Сохранить и добавить concurrency/property tests | +| Decoded byte cache budget | **PARTIAL/PASS** | Есть entry+byte limits и deterministic map | Отделить cache budget от decode allocation budget; test oversize rejection before allocation | +| Decompression outside lock | **PASS статически** | Resource repository формирует task под lock, выполняет payload decode после release | Добавить concurrency regression test | +| Typed resource errors | **PARTIAL/FAIL** | Верхний enum typed, но format/source часто превращаются в `String` | Сохранить concrete source errors и classify missing/archive/entry/corruption | +| Corpus report использует production parsers | **FAIL** | Реально вызывается NRes parser; RsLi только определяется по magic, TMA/Land/unit metrics — по имени пути | Интегрировать все production parsers и считать parser errors failures | +| Stable licensed reports и roundtrip | **UNVERIFIED, EXIT BLOCKER** | Нет запусков и artifacts в доступном аудите | Separate licensed jobs, signed manifests, report diff policy | + +## 5.2 Критические замечания Stage 1 + +### S1-B01 — `Limits` не является обязательным contract всех decoders + +**Приоритет:** BLOCKER/HIGH +**Файлы:** `fparkan-binary`, `fparkan-nres`, `fparkan-rsli`, `fparkan-mission-format`, другие format crates + +`fparkan-binary::Limits` определён, но: + +- NRes decode API не принимает limits/budget; +- RsLi load/decompression не принимает общий output budget; +- mission parser использует собственный набор `MAX_*` constants; +- нет cumulative budget, отслеживающего сумму вложенных allocations; +- cache byte limit действует после decode и не предотвращает decompression bomb. + +Проверка отдельного count недостаточна. Несколько допустимых массивов могут вместе превысить memory budget, а declared decompressed size может привести к крупному выделению до cache rejection. + +**Целевой API:** + +```rust +pub struct DecodeContext<'a> { + pub limits: &'a DecodeLimits, + pub budget: &'a AllocationBudget, + pub diagnostics: &'a dyn DiagnosticSink, +} +``` + +Каждый allocation предваряется `reserve(bytes, category, span)`; nested decoders наследуют тот же budget. + +### S1-B02 — RsLi не имеет требуемого edit/writer model + +**Приоритет:** BLOCKER +**Файл:** `crates/fparkan-rsli/src/lib.rs` + +Текущий lossless write profile фактически возвращает исходный byte image. Это полезный no-edit roundtrip, но не является edit model. Stage 1 требует: + +- редактирование entry metadata/payload; +- сохранение unknown/overlay regions; +- rebuild lookup table; +- корректный packing method policy; +- byte-identical no-edit; +- deterministic edited output. + +Нужно разделить `OriginalImage`, parsed table, preserved segments и editable entries так же явно, как это уже сделано для NRes. + +### S1-B03 — Corpus report создаёт ложное впечатление production coverage + +**Приоритет:** BLOCKER +**Файлы:** `crates/fparkan-corpus/Cargo.toml`, `src/lib.rs` + +Crate не зависит от `fparkan-rsli`, mission, terrain, prototype и прочих production parsers. В inspected implementation: + +- NRes действительно декодируется; +- RsLi только распознаётся по magic; +- TMA, Land и unit DAT считаются по extension/path patterns; +- parser failure count поэтому не отражает значительную часть corpus. + +Фраза «corpus report использует реальные parsers» сейчас верна лишь частично. Exit gate «нулевые необъяснённые failures» нельзя доказать этим report. + +### S1-B04 — Structured diagnostics существуют отдельно от error path + +**Приоритет:** BLOCKER/HIGH + +`fparkan-diagnostics` имеет severity, phase, path, archive entry, object key, span и causes, однако format/resource/runtime crates не зависят от него. Вместо cause chain многие errors преобразуются в строки: + +- `ResourceError::Format(String)`; +- `ResourceError::EntryRead { source: String }`; +- `PrototypeError::Resource(String)`; +- `AssetError::* (String)`. + +После преобразования невозможно надёжно классифицировать missing vs corrupt, извлечь source offset или сохранить concrete error chain. + +### S1-H01 — JSON serializer diagnostics некорректно обрабатывает часть control characters + +**Приоритет:** HIGH +**Файл:** `crates/fparkan-diagnostics/src/lib.rs` + +Manual JSON escaping обрабатывает quote, backslash, `\n`, `\r`, `\t`, но не все символы U+0000–U+001F. Сообщение или path с backspace/form-feed/NUL может породить невалидный JSON. + +**Рекомендация:** использовать `serde`/`serde_json` для wire format; добавить property tests для всех Unicode/control characters и deterministic field ordering через typed serializable schema. + +### S1-H02 — Path identity остаётся UTF-8-first + +**Приоритет:** HIGH +**Файл:** `crates/fparkan-path/src/lib.rs` + +Legacy data и host filenames могут содержать CP1251 или произвольные byte sequences. `normalize_relative()` сначала вызывает UTF-8 decode и отклоняет invalid UTF-8. Наличие `OriginalPathBytes` не решает проблему, потому что объект создаётся только после успешной UTF-8 normalization. + +**Целевая модель:** canonical legacy identity — bytes; separators/`.`/`..`/drive checks выполняются на ASCII bytes; decoded display name — отдельное необязательное поле. + +### S1-H03 — VFS symlink checks подвержены check/use race + +**Приоритет:** HIGH +**Файл:** `crates/fparkan-vfs/src/lib.rs` + +Код проверяет `symlink_metadata`, затем открывает/read-ит pathname отдельной операцией. Между проверкой и open entry может быть заменён symlink. Для локального trusted corpus риск невысок, но Stage 1 заявляет безопасный substrate и malformed/adversarial tests. + +**Рекомендация:** directory capability/openat traversal, no-follow handles, проверка final object через handle metadata. `follow_symlinks=true` в corpus discovery должен иметь root-confinement и visited-file-id set. + +### S1-H04 — Fingerprint cache может пропустить content replacement + +**Приоритет:** HIGH/MEDIUM + +DirectoryVfs повторно использует SHA по `(path, len, modified)`. На filesystem с грубой timestamp resolution файл можно изменить, сохранив длину и mtime. Generation invalidation тогда не сработает. + +Для correctness-critical licensed reports рекомендуются: + +- unconditional SHA в audit mode; +- либо file identity + ctime/change counter; +- явное разделение fast interactive cache и strict verification mode. + +### S1-M01 — Prefix semantics MemoryVfs требуют segment boundary + +**Приоритет:** MEDIUM + +`list(prefix)` использует byte prefix. `DATA/FOO` может захватить `DATA/FOOBAR`. Нужна семантика `path == prefix || path starts with prefix + '/'` и одинаковые tests для всех VFS implementations. + +### S1-M02 — Собственная SHA-256 реализация увеличивает maintenance surface + +**Приоритет:** MEDIUM + +Криптографическая новизна проекту не нужна. Если custom implementation сохраняется ради zero dependencies/offline build, она должна иметь exhaustive standard vectors, differential tests и fuzzing. Иначе безопаснее использовать широко проверенный crate и закреплённую версию. + +## 5.3 Положительные элементы Stage 1 + +- Bounded little-endian cursor и checked arithmetic уже централизованы. +- NRes имеет strict/compatible read profiles, editor и preserved regions. +- RsLi содержит явные compatibility switches и широкий набор методов decode. +- VFS выявляет ASCII-casefold ambiguity и отвергает symlink entries в обычном path. +- Resource handles имеют generation и stale detection. +- Payload decode выполняется вне repository mutex. +- Cache имеет entry/byte limits и deterministic data structure. +- Corpus manifest использует SHA-256 и sorted traversal. + +## 5.4 Обязательные изменения для полного закрытия Stage 1 + +1. Ввести обязательные `DecodeLimits` + cumulative `AllocationBudget`. +2. Перевести все parsers/decompressors на context API. +3. Сделать path model byte-first. +4. Усилить VFS до handle/capability-based root confinement. +5. Интегрировать structured diagnostics и typed source errors. +6. Исправить JSON serialization. +7. Завершить RsLi editor/writer. +8. Подключить production parsers к corpus report. +9. Добавить strict verification mode fingerprints. +10. Зафиксировать licensed Part 1/2 reports и roundtrip artifacts. + +--- + +# 6. Stage 2 — Prototype graph и CPU assets + +## 6.1 Матрица требований + +| Требование Stage 2 | Статус | Доказательство текущего состояния | Что требуется для закрытия | +|---|---|---|---| +| `objects.rlb` decode | **PARTIAL** | Есть 64-byte record decoder и registry resolution | Corpus variants, lossless model, typed provenance и failure spans | +| Unit DAT decode | **PARTIAL** | Есть component records и binding variant | Формальная variant discrimination, all observed records, hierarchy semantics | +| Inheritance/depth handling | **PARTIAL/UNVERIFIED** | Есть depth-limit constant и resolution code | Cycle path, parent edge nodes, BASE/resource variants, corpus proof | +| Все unit components | **PARTIAL** | Internal graph expansion итерирует records; public `resolve_prototype` всё ещё возвращает только first component | Удалить/ограничить lossy API; graph хранит каждый component и hierarchy | +| Typed graph nodes/edges | **FAIL** | `PrototypeGraph` хранит только roots и flattened prototype requests | Materialized graph arena с stable node IDs и typed edge instances | +| Typed provenance каждого edge | **FAIL** | Есть enum kind и count report, но нет parent chain/edge instance/source span | `Provenance` с mission object, component index, archive/entry/span, parent edge | +| Effect edge | **FAIL** | `PrototypeGraphEdge` не содержит effect path; `fparkan-assets` не зависит от FX crate | Добавить typed effect assets и graph reachability | +| BASE/resource variants | **UNVERIFIED/LIKELY PARTIAL** | В доступных contracts нет полноценной variant model/provenance | Явный enum variant, evidence-backed parser, fixtures | +| `fparkan-assets` — единственный preparation layer | **FAIL** | Prototype crate сам зависит от MSH/material/Texm; runtime напрямую вызывает format/prototype parsers; viewer парсит напрямую | Перестроить dependency DAG и запретить parser deps в apps/runtime | +| Immutable prepared CPU assets | **FAIL/PARTIAL** | `PreparedVisual` в основном содержит keys/counts, а не mesh/material/texture data | Immutable `MeshAsset`, `MaterialAsset`, `TextureAsset`, `MissionAssets` | +| Stable IDs | **PARTIAL/HIGH RISK** | 64-bit FNV-like hash от geometry key без collision registry | Canonical key interner/content hash + collision detection + schema version | +| Structured graph failure parent chain | **FAIL** | Failure содержит root index, edge enum и message string | Full chain и concrete typed cause, not string | +| Optional fallback vs corruption | **FAIL/PARTIAL** | Есть optional read helper, но classification не является graph-wide contract | Requiredness enum + severity policy + explicit fallback provenance | +| No ad-hoc parsing in apps/runtime | **FAIL** | Runtime зависит от NRes/mission/terrain/prototype; viewer вызывает decoders напрямую | Только AssetManager/mission loader ports; lint dependency denylist | +| Deterministic graph order/IDs | **PARTIAL/UNVERIFIED** | Некоторые sorted/BTree structures и stable hasher есть | Canonical traversal spec, golden graph serialization, cross-run/cross-OS tests | +| Licensed Part 1/2 zero-failure reachability | **UNVERIFIED, EXIT BLOCKER** | Нет доступных run artifacts; inspected game corpus test помечен `#[ignore]` | Full mission matrix reports, expected counts, zero unexplained failures | + +## 6.2 Критические замечания Stage 2 + +### S2-B01 — `fparkan-prototype` и `fparkan-assets` нарушают целевое разделение ответственности + +**Приоритет:** BLOCKER +**Файлы:** `crates/fparkan-prototype/Cargo.toml`, `crates/fparkan-assets/Cargo.toml` + +Prototype crate зависит от: + +- material; +- MSH; +- NRes; +- Texm; +- resource/VFS. + +И сам расширяет graph report visual dependencies. Затем `fparkan-assets` повторно декодирует MSH, WEAR, MAT0 и Texm. Возникают два preparation paths, которые со временем неизбежно разойдутся по fallback, diagnostics и budgets. + +**Целевой DAG:** + +```text +mission/prototype formats ──> prototype graph (keys + provenance only) + │ + v +resource repository ─────────> fparkan-assets (all CPU decoding/preparation) + │ + v +runtime/world ────────────────> immutable MissionAssets + │ + v +render bridge ────────────────> backend-neutral draw items +``` + +Prototype layer не должен декодировать render assets. + +### S2-B02 — Runtime не использует `fparkan-assets` + +**Приоритет:** BLOCKER +**Файлы:** `crates/fparkan-runtime/Cargo.toml`, `src/lib.rs` + +Runtime manifest не содержит dependency на `fparkan-assets`, зато напрямую зависит от NRes, mission-format, prototype, terrain-format и resource. `LoadedMissionState` хранит `Vec<EffectivePrototype>`, а не prepared immutable assets. + +Это прямо противоречит exit gate: «runtime получает prepared assets только через asset manager». + +### S2-B03 — Apps продолжают ad-hoc parsing и synthetic resource mapping + +**Приоритет:** BLOCKER + +- Viewer напрямую вызывает NRes/MSH/Texm/terrain decoders. +- Game создаёт `GpuMeshId(slot + 1)`, constant material ID и triangle range, вместо prepared mission assets. +- Ни game, ни viewer не подключают platform/Vulkan adapters. + +Apps должны быть composition roots, а не дополнительным parser/service layer. + +### S2-B04 — `PrototypeGraph` не является materialized dependency graph + +**Приоритет:** BLOCKER +**Файл:** `crates/fparkan-prototype/src/lib.rs` + +Текущая структура содержит только: + +- roots; +- flattened prototype requests. + +Report содержит counts и failures, но не даёт: + +- stable node identity; +- конкретные edge instances; +- parent/child traversal; +- deduplication semantics; +- source spans; +- full provenance chain; +- serialization для golden comparison. + +Для Stage 2 нужен arena/adjacency graph, а report должен вычисляться из него, а не заменять его. + +### S2-B05 — Публичный resolver теряет multi-component unit + +**Приоритет:** BLOCKER/HIGH + +`resolve_prototype()` для DAT вызывает helper, возвращающий первый resolved component. Хотя другой internal path обходит все records, наличие публичного lossy API нарушает invariant Stage 2 и создаёт риск использования «первого visual» в новом caller. + +**Рекомендация:** удалить этот API либо переименовать в явно lossy diagnostic helper; основной API всегда возвращает collection/subgraph. + +### S2-H01 — Missing unit DAT может быть преобразован в пустое успешное expansion + +**Приоритет:** HIGH, требует подтверждающего regression test + +В inspected helper `VfsError::NotFound` превращается в `Ok` с `expected_count = 0` и пустым списком. Если caller не создаёт отдельный failure до/после этого вызова, reachable missing dependency исчезает из failure set. + +Требуемое поведение: + +- reachable + required → typed failure; +- unreachable → warning/report record; +- optional → explicit fallback edge; +- corrupt → error независимо от optionality, если ресурс найден и malformed. + +### S2-H02 — Provenance enum недостаточен и не покрывает весь канонический список + +**Приоритет:** HIGH + +Текущий enum описывает несколько типов переходов, но отсутствуют или не материализованы: + +- prototype inheritance parent; +- BASE/resource variant; +- component hierarchy/link; +- effect/FX dependency; +- fallback source; +- source archive entry/span; +- exact mission object identity. + +`message: String` не заменяет provenance. + +### S2-H03 — `PreparedVisual` является summary, а не prepared asset + +**Приоритет:** HIGH + +Структура содержит ResourceKey и counts (`model_nodes`, `material_count`, …), но не immutable vertex/index streams, materials, decoded texture mip data, lightmap bindings или dependency handles. Она пригодна для audit report, но не как sole CPU asset handoff в renderer/runtime. + +Нужно разделить: + +- `PreparedVisualSummary` для отчётов; +- `MeshAsset`; +- `MaterialAsset`; +- `TextureAsset`; +- `LightmapAsset`; +- `EffectAsset`; +- `VisualAsset` с typed IDs/handles; +- `MissionAssets` как транзакционно подготовленный набор. + +### S2-H04 — Stable ID без collision handling + +**Приоритет:** HIGH/MEDIUM + +64-bit FNV-like hash удобен и детерминирован, но collision не проверяется. Stable ID — часть persistent captures и graph comparison, поэтому молчаливая коллизия недопустима. + +**Рекомендация:** canonical key interner с equality check; ID может быть SHA-256 prefix/128-bit hash либо deterministic ordinal после canonical sort. При hash collision должна возникать explicit error. + +### S2-H05 — Errors теряют concrete causes + +**Приоритет:** HIGH + +Prototype и assets преобразуют Resource/MSH/Material/Texture errors в строки. В результате graph failure не может отличить missing archive, missing entry, malformed offsets, unsupported variant и allocation limit. + +Это одновременно блокирует Stage 1 diagnostics и Stage 2 optional/corrupt policy. + +### S2-M01 — Graph success predicate слишком зависим от aggregate counts + +**Приоритет:** MEDIUM/HIGH + +`PrototypeGraphReport::is_success()` опирается на отсутствие failures и соответствие aggregate resolved count. Такой predicate не доказывает, что: + +- каждый material/texture/lightmap/effect request resolved; +- каждый edge имеет provenance; +- отсутствуют orphan/duplicate nodes; +- requiredness policy применена; +- graph deterministic. + +Success должен вычисляться как validation materialized graph с инвариантами. + +## 6.3 Положительные элементы Stage 2 + +- Unit DAT records сохраняют raw archive/resource/description bytes и parent/link field. +- Есть отдельный binding variant и CP1251-related support. +- Internal expansion обходит несколько component records. +- Есть depth limit для prototype inheritance. +- Report считает mesh, WEAR, material, texture и lightmap requests/resolutions. +- `AssetId<T>` typed на уровне Rust. +- Asset preparation выполняется транзакционно на уровне метода: ошибка прерывает план. +- Stable ordering поддерживается рядом BTree collections и sorted traversal. +- Mission loading имеет staged phases и transactional world registration concepts. + +## 6.4 Обязательные изменения для полного закрытия Stage 2 + +1. Сделать prototype crate graph-only и убрать из него MSH/material/Texm decoding. +2. Создать materialized typed graph с stable nodes/edge instances. +3. Добавить full provenance и typed requiredness/fallback. +4. Завершить variants: direct, inherited, BASE, unit component hierarchy, effects. +5. Превратить `fparkan-assets` в единственный decode/preparation layer. +6. Создать реальные immutable CPU asset types. +7. Подключить AssetManager к runtime и удалить direct parser dependencies. +8. Перевести apps на runtime/asset services. +9. Ввести deterministic ID registry с collision detection. +10. Запустить полный Part 1/2 mission reachability gate и зафиксировать zero failures. + +--- + +# 7. Сквозные архитектурные замечания + +## 7.1 Нужен единый dependency policy, проверяемый Cargo metadata + +Текущая архитектура допускает запрещённые направления зависимостей. Следует формально проверить: + +- `apps/*` не зависят от format parsers; +- `runtime` не зависит от NRes/RsLi/MSH/Texm/material/FX format crates напрямую; +- `prototype` не зависит от visual asset parsers; +- neutral crates не зависят от `ash`, `winit`, raw OS APIs; +- `headless` dependency closure не содержит platform/Vulkan crates; +- Vulkan raw types не выходят из adapter. + +Policy должна использовать `cargo_metadata`, а не поиск строк в TOML. + +## 7.2 Один error taxonomy для Stage 1–2 + +Рекомендуемый общий набор классификаций: + +```text +MissingArchive +MissingEntry +AmbiguousName +InvalidPath +UnsupportedVariant +CorruptHeader +OutOfBounds +LimitExceeded +AllocationBudgetExceeded +DecompressionFailed +IntegrityMismatch +OptionalUnavailable +FallbackApplied +PlatformUnavailable +GpuCapabilityMissing +``` + +Concrete format errors остаются source; верхние layers добавляют context, не превращая source в строку. + +## 7.3 Acceptance report должен быть доказательством, а не count dump + +Каждый stage report должен включать: + +- schema version; +- engine commit SHA; +- toolchain version; +- platform/target; +- corpus manifest fingerprint; +- configuration/profile; +- counts; +- warnings/failures с stable codes; +- evidence status; +- SHA-256 самого report; +- ссылки на dependent artifacts. + +## 7.4 Документация и код должны иметь один source of truth + +Сейчас canonical Vulkan revision находится в Notion, а repository tome и parity docs содержат прежние stages/commands. Рекомендуется: + +- хранить canonical acceptance schema в repository; +- либо экспортировать Notion plan в versioned Markdown; +- CI должен проверять, что workspace adapter names и documented architecture совпадают; +- устаревшие команды должны быть executable doctests или удалены. + +--- + +# 8. План полного закрытия Stage 0–2 + +Ниже приведена рекомендуемая последовательность PR/changesets. Порядок важен: следующий блок не должен объявляться закрытым до прохождения exit gate предыдущего. + +## 8.1 Stage 0 closure train + +### PR S0-01 — Reproducible toolchain и repository metadata + +**Изменения** + +- закрепить exact Rust toolchain; +- добавить `workspace.package.rust-version`; +- документировать supported targets; +- добавить command `xtask doctor` с выводом toolchain/target/SDK versions; +- report всегда включает commit SHA. + +**Acceptance** + +- clean checkout воспроизводит одинаковый metadata report; +- MSRV job собирает neutral crates; +- current pinned toolchain выполняет полный gate. + +### PR S0-02 — Typed xtask configuration и policy engine + +**Изменения** + +- serde/TOML schema для corpus и acceptance manifests; +- `deny_unknown_fields`; +- canonical absolute path rules для licensed manifest; +- `cargo_metadata` для dependency graph/policy; +- tests malformed/duplicate/unknown/missing fields; +- удалить ручной line parser. + +**Acceptance** + +- malformed manifest не принимается частично; +- unknown fields fail; +- dependency policy работает по package IDs и targets/features. + +### PR S0-03 — Полный synthetic CI gate + +**Обязательные команды** + +```text +cargo fmt --all -- --check +cargo test --workspace --all-targets --all-features --locked +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +RUSTDOCFLAGS="-D warnings -D rustdoc::broken_intra_doc_links" cargo doc --workspace --no-deps --all-features --locked +cargo deny check advisories bans licenses sources +cargo xtask policy +cargo xtask acceptance audit --strict +``` + +Добавить denylist legacy adapter names, Python runtime files и unsafe allowlist. + +### PR S0-04 — Redesign `fparkan-platform` + +**Изменения** + +- удалить GL context types; +- убрать `present()` из WindowPort; +- ввести normalized events; +- выделить physical/logical size и scale factor; +- определить lifecycle state machine; +- error context/cause. + +**Synthetic tests** + +- resize coalescing; +- scale-factor change; +- minimized zero-size; +- suspend/resume; +- keyboard repeat/modifiers; +- focus loss clears held input; +- deterministic event ordering. + +### PR S0-05 — `fparkan-platform-winit` + +**Изменения** + +- winit event loop и window; +- raw window/display handles; +- platform-specific lifecycle mapping; +- no GPU ownership. + +**Acceptance** + +- window-only smoke на трёх OS; +- event trace соответствует synthetic model. + +### PR S0-06 — Vulkan low-level boundary + +**Изменения** + +- `ash`/`ash-window` adapter; +- loader/instance/debug messenger; +- physical device records и pure scoring; +- queue selection; +- deterministic capability JSON; +- narrow unsafe module policy. + +**Negative tests** + +- no loader; +- no Vulkan 1.1; +- no graphics queue; +- no present queue; +- missing swapchain extension; +- unsupported required format. + +### PR S0-07 — Swapchain, pipeline и offline shaders + +**Изменения** + +- surface/swapchain; +- color/depth policy; +- render pass; +- indexed triangle; +- semaphores/fences; +- frames-in-flight; +- resize/out-of-date/suboptimal; +- offline SPIR-V compile/validate; +- descriptor/push-constant manifest и hashes. + +### PR S0-08 — macOS portability и packaging proof + +**Изменения** + +- enumerate portability flag; +- detect/enable portability subset; +- MoltenVK bundling strategy; +- deterministic portability report; +- `.app` smoke packaging. + +### PR S0-09 — Composition roots и legacy removal + +**Изменения** + +- game/viewer подключают winit+Vulkan adapters; +- headless остаётся isolated; +- удалить SDL/GL stubs; +- очистить lockfile/policy/docs; +- переименовать CPU IDs в neutral render model. + +### PR S0-10 — Native acceptance matrix + +**Jobs** + +- Windows MSVC + Vulkan runtime; +- Linux X11/Wayland smoke; software Vulkan может быть PR gate, отдельный native-GPU job — release gate; +- macOS Apple Silicon + MoltenVK; +- 300 frames, resize, validation error count = 0; +- capability/shader/validation logs как artifacts. + +**Stage 0 закрывается только после merge всех PR S0-01…S0-10 и зелёных platform artifacts.** + +--- + +## 8.2 Stage 1 closure train + +### PR S1-01 — Unified decode context + +- `DecodeLimits` с per-format overrides; +- thread-safe cumulative `AllocationBudget`; +- reservation guards; +- output budget для decompressors; +- span-aware errors; +- migrate all decoders. + +### PR S1-02 — Byte-first paths и host adapter + +- `LegacyPath(Vec<u8>)`; +- ASCII normalization на bytes; +- optional decoded display; +- Unix `OsStr` bytes path; +- Windows conversion policy с explicit encoding/error; +- strict/compatible contract table. + +### PR S1-03 — VFS hardening + +- capability/openat-style traversal; +- no-follow final open; +- root confinement; +- visited file identity при allowed symlinks; +- segment-safe prefix semantics; +- strict fingerprint mode; +- одинаковый casefold policy во всех implementations. + +### PR S1-04 — Diagnostics integration + +- serde-backed diagnostic schema; +- stable codes; +- source chain; +- archive/entry/span/phase context; +- adapters для all format errors; +- property tests JSON; +- запрет `source.to_string()` в domain errors через policy/lint review. + +### PR S1-05 — NRes finalization + +- decode context integration; +- edit preservation tests; +- stable directory order specification; +- malformed/fuzz corpus; +- Part 1/2 no-edit byte identity. + +### PR S1-06 — RsLi editable/lossless model + +- parsed/preserved/editable segments; +- deterministic table/lookup rebuild; +- packing policy; +- all observed methods with budgets; +- AO/EOF+1/presort quirk evidence registry; +- no-edit and edited roundtrips. + +### PR S1-07 — Resource repository finalization + +- typed source errors; +- cache/decode budgets separated; +- deterministic LRU policy documented; +- concurrent same-entry decode coalescing или explicit duplicate policy; +- stale handle/concurrency tests; +- strict verification mode. + +### PR S1-08 — Production corpus runner + +- parser registry, а не extension counters; +- NRes, RsLi и все Stage 1-relevant production parsers; +- any parser error increments failures и causes non-zero exit; +- stable schema and diff; +- no licensed path leakage in synthetic artifacts. + +### PR S1-09 — Licensed closure artifacts + +Для Part 1 и Part 2 отдельно: + +- corpus manifest SHA; +- archive inventory; +- parser report; +- method/quirk coverage; +- no-edit roundtrip report; +- edit-preservation regression set; +- unexplained failures = 0. + +**Stage 1 закрывается только после S1-01…S1-09 и двух стабильных licensed reports.** + +--- + +## 8.3 Stage 2 closure train + +### PR S2-01 — Typed graph schema + +Ввести: + +```text +NodeId +NodeKind { MissionObject, UnitDat, UnitComponent, Prototype, Model, + Wear, Material, Texture, Lightmap, Effect, Auxiliary } +EdgeId +EdgeKind +Requiredness { Required, Optional, Fallback } +Provenance { source path/archive/entry/span, parent edge, object/component indices } +DependencyGraph { nodes, edges, roots } +``` + +Graph validation проверяет no dangling edges, canonical order, unique node keys и parent chain. + +### PR S2-02 — Prototype resolver variants + +- direct registry; +- inherited parent chain; +- BASE/resource variants; +- unit DAT binding; +- multi-component unit hierarchy; +- cycle/depth diagnostics; +- lossless raw fields; +- remove first-component public API. + +### PR S2-03 — Requiredness и failure semantics + +- reachable required missing = error; +- unreachable missing = warning; +- optional missing = explicit optional record; +- fallback = edge с причиной и chosen source; +- found-but-corrupt = error; +- stable diagnostic codes and full chain. + +### PR S2-04 — Разделение prototype/assets + +- убрать MSH/material/Texm dependencies из prototype; +- graph хранит только resource identities/provenance; +- `fparkan-assets` единолично вызывает visual/effect parsers; +- policy запрещает обратные dependencies. + +### PR S2-05 — Immutable CPU assets и ID registry + +- typed immutable model/material/texture/lightmap/effect assets; +- canonical IDs; +- collision detection; +- deduplication; +- source provenance; +- separate parsed-byte and resident-asset budgets. + +### PR S2-06 — Mission AssetManager transaction + +`AssetManager::prepare_mission(graph)`: + +1. валидирует graph; +2. вычисляет canonical load plan; +3. декодирует resources в budget; +4. собирает immutable assets; +5. не публикует partial result при failure; +6. возвращает `MissionAssets` + report. + +### PR S2-07 — Runtime integration + +- runtime зависит от assets API; +- удалить direct NRes/MSH/Texm/material/effect parsing из runtime; +- `LoadedMissionState` хранит `Arc<MissionAssets>`; +- world objects ссылаются на typed asset IDs; +- render snapshot строится из prepared visuals, а не из object slot. + +### PR S2-08 — App cleanup + +- viewer использует AssetManager service; +- CLI parser-level commands остаются только в dedicated tooling crate; +- game не генерирует synthetic GPU IDs; +- dependency policy запрещает app → format crates. + +### PR S2-09 — Synthetic graph suite + +Обязательные fixtures: + +- direct prototype; +- inherited prototype; +- multi-level inheritance; +- cycle; +- depth limit; +- BASE variant; +- multi-component unit с hierarchy; +- duplicate/casefold ambiguity; +- required missing; +- optional missing; +- corrupt present resource; +- material/texture/lightmap/effect chain; +- stable serialization/IDs на повторном запуске. + +### PR S2-10 — Licensed reachability closure + +Для каждой миссии обеих частей: + +- roots; +- unit components; +- prototype/model/material/texture/lightmap/effect nodes; +- required/optional/fallback counts; +- failure list; +- canonical graph hash; +- asset plan hash. + +Exit conditions: + +- reachable failures = 0; +- каждый resolved edge имеет provenance; +- повторный запуск даёт те же graph/asset hashes; +- runtime parser dependency audit = clean. + +**Stage 2 закрывается только после S2-01…S2-10 и полного licensed mission matrix.** + +--- + +# 9. Требуемая CI/acceptance модель + +## 9.1 Synthetic PR gate + +Должен работать без игровых каталогов и без silent skip: + +1. formatting/lints/docs/security; +2. all unit/integration/property tests; +3. malformed parser corpus; +4. Stage 0 pure policy tests; +5. Stage 1 archive synthetic roundtrips; +6. Stage 2 graph synthetic fixtures; +7. headless dependency assertion; +8. Linux software-Vulkan smoke при доступности; +9. report schema validation. + +Любой test, требующий licensed files, должен быть в отдельном command suite, а не `#[ignore]` внутри общего gate без machine-readable explanation. + +## 9.2 Native platform gate Stage 0 + +| Platform | Минимальный gate | Дополнительное доказательство | +|---|---|---| +| Windows | system Vulkan loader, real swapchain, triangle, resize, 300 frames, validation=0 | NVIDIA/AMD/Intel coverage по release cadence | +| Linux | X11 или Wayland surface, swapchain, resize, validation=0 | software Vulkan PR job + native Mesa/NVIDIA release jobs | +| macOS | MoltenVK, portability enumeration/subset, CAMetalLayer surface, resize, validation=0 | Apple Silicon primary; Intel optional only if declared supported | + +## 9.3 Licensed local/restricted gate + +- absolute roots только из local manifest; +- CI logs не содержат raw licensed paths; +- reports используют logical corpus IDs; +- artifacts не содержат game bytes; +- manifests содержат только path hashes/size/format metrics; +- failures приводят к non-zero exit; +- baseline update требует reviewed diff и reason. + +--- + +# 10. Definition of Done + +## 10.1 Stage 0 DoD + +- [ ] Exact Rust toolchain и MSRV закреплены. +- [ ] Full fmt/test/clippy/doc/security/source/license gate проходит. +- [ ] Typed manifests и `cargo_metadata` используются. +- [ ] Windows/Linux/macOS matrix сохраняет artifacts. +- [ ] `fparkan-platform-winit` реализован. +- [ ] `fparkan-render-vulkan` реализован. +- [ ] Vulkan 1.1 baseline и capability report реализованы. +- [ ] MoltenVK portability path реализован. +- [ ] Offline SPIR-V validation/hash manifest реализован. +- [ ] Legacy SDL/GL adapters и references удалены. +- [ ] Game/viewer используют новые composition adapters. +- [ ] 300-frame + resize smoke проходит на трёх OS без validation errors. +- [ ] Headless dependency closure не содержит window/Vulkan. + +## 10.2 Stage 1 DoD + +- [ ] Path identity byte-first и roundtrip-safe. +- [ ] VFS root/symlink/casefold semantics едины и безопасны. +- [ ] Все decoders принимают общий limits/budget context. +- [ ] Decompression output bounded до allocation. +- [ ] NRes no-edit и edited roundtrips подтверждены. +- [ ] RsLi no-edit и edited roundtrips подтверждены. +- [ ] All observed RsLi methods/quirks имеют evidence records. +- [ ] Structured diagnostics проходят через parsers/repository/runtime. +- [ ] JSON reports валидны для всех Unicode/control inputs. +- [ ] Resource repository сохраняет typed errors, handles и deterministic eviction. +- [ ] Corpus report вызывает production parsers. +- [ ] Part 1/2 reports стабильны; unexplained failures = 0. + +## 10.3 Stage 2 DoD + +- [ ] Materialized typed dependency graph существует. +- [ ] Все edge instances имеют full provenance. +- [ ] Direct/inherited/BASE/unit hierarchy variants реализованы. +- [ ] Все unit components регистрируются; first-component shortcut отсутствует. +- [ ] Effect dependencies включены. +- [ ] Required/optional/fallback/corrupt semantics разделены. +- [ ] `fparkan-assets` — единственный CPU preparation layer. +- [ ] Apps/runtime не зависят от format parsers напрямую. +- [ ] Immutable CPU assets и collision-safe stable IDs реализованы. +- [ ] Runtime хранит/использует `MissionAssets`. +- [ ] Render snapshots используют prepared asset IDs. +- [ ] Synthetic graph fixtures проходят. +- [ ] Все миссии Part 1/2 дают reachable failures = 0. +- [ ] Graph/asset hashes детерминированы. + +--- + +# 11. Рекомендуемые automated policy checks + +Добавить в `cargo xtask policy`: + +1. Запрет workspace members/path names: + - `fparkan-platform-sdl`; + - `fparkan-render-gl`; + - stale OpenGL/GLES profile symbols. +2. Dependency rules: + - apps не зависят от `fparkan-*format`, NRes, RsLi, MSH, Texm, material, FX; + - runtime не зависит от этих crates напрямую; + - prototype не зависит от MSH/material/Texm/FX; + - headless не зависит от winit/ash/ash-window/Vulkan adapter; + - neutral crates не зависят от platform adapters. +3. Unsafe rules: + - unsafe разрешён только в exact Vulkan/FFI modules; + - каждый block содержит `SAFETY:`; + - raw handles не являются public API. +4. Test rules: + - synthetic gate не содержит licensed roots; + - ignored tests должны иметь registered reason и отдельный suite owner; + - acceptance IDs уникальны. +5. Documentation rules: + - documented crates/commands существуют; + - stage schema version совпадает с report schema; + - old backend names отсутствуют в canonical docs. + +--- + +# 12. Риски реализации и способы снижения + +| Риск | Влияние | Снижение | +|---|---|---| +| Vulkan work начнётся до исправления platform contract | Повторная переделка surface/present/lifecycle | Сначала S0-04, затем adapters | +| Global unsafe prohibition будет ослаблен целиком | Рост FFI risk | Изолированный audited crate/module и policy scanner | +| Stage 1 budgets добавят только к top-level files | Nested decompression bomb останется | Общий shared budget, передаваемый во все вложенные decoders | +| RsLi writer будет canonical-only | Потеря unknown/overlay bytes | Segment-preserving editable model и lossless-first tests | +| Graph report останется count-only | False green Stage 2 | Materialized graph + invariant validator + canonical serialization | +| Prototype и assets продолжат оба парсить visuals | Divergent fallback и diagnostics | Жёсткий dependency DAG и policy test | +| Hash IDs столкнутся | Неправильные assets/captures | Collision detection и canonical interner | +| Licensed tests останутся ignored/local-only без artifacts | Невозможность доказать exit gate | Separate command, signed reports, baseline diff process | +| GitHub mirror и primary diverge | Audit не соответствует release | Pin canonical remote + commit SHA в каждом report | +| Documentation останется отдельной от acceptance schema | Повторное рассогласование stages | Versioned repository schema и generated docs/checks | + +--- + +# 13. Приоритетный backlog + +## Немедленно — до любых новых gameplay/render features + +1. S0-01…S0-04: reproducibility, CI, typed config, platform API. +2. S1-01: общий decode/allocation budget. +3. S2-04: запрет дублирующего parsing между prototype/assets. +4. S1-04: typed diagnostics без string erasure. + +## Затем — минимальный доказуемый Vulkan foundation + +1. winit adapter. +2. Vulkan loader/device/surface/swapchain. +3. offline shaders. +4. 3-platform smokes. +5. legacy adapter removal. + +## Затем — архивный exit gate + +1. byte-first paths/VFS hardening; +2. RsLi edit/writer; +3. production corpus runner; +4. licensed reports/roundtrips. + +## Затем — graph/assets exit gate + +1. typed graph/provenance; +2. all variants/components/effects; +3. immutable assets; +4. runtime/app integration; +5. full Part 1/2 reachability. + +--- + +# 14. Реестр доказательств + +## Canonical requirements + +- Notion, Vulkan revision: <https://app.notion.com/p/387e79f2db3981778f94cdf34db5f93f> + +## Workspace/governance + +- Root manifest: <https://github.com/valentineus/fparkan/blob/devel/Cargo.toml> +- Toolchain: <https://github.com/valentineus/fparkan/blob/devel/rust-toolchain.toml> +- Cargo config: <https://github.com/valentineus/fparkan/blob/devel/.cargo/config.toml> +- xtask manifest: <https://github.com/valentineus/fparkan/blob/devel/xtask/Cargo.toml> +- xtask implementation: <https://github.com/valentineus/fparkan/blob/devel/xtask/src/main.rs> +- README: <https://github.com/valentineus/fparkan/blob/devel/README.md> + +## Stage 0 + +- Platform core: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-platform/src/lib.rs> +- SDL stub adapter: <https://github.com/valentineus/fparkan/blob/devel/adapters/fparkan-platform-sdl/src/lib.rs> +- Render core: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-render/src/lib.rs> +- GL stub adapter: <https://github.com/valentineus/fparkan/blob/devel/adapters/fparkan-render-gl/src/lib.rs> +- Game composition: <https://github.com/valentineus/fparkan/blob/devel/apps/fparkan-game/src/main.rs> +- Viewer composition: <https://github.com/valentineus/fparkan/blob/devel/apps/fparkan-viewer/src/main.rs> +- Headless manifest: <https://github.com/valentineus/fparkan/blob/devel/apps/fparkan-headless/Cargo.toml> + +## Stage 1 + +- Binary/limits: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-binary/src/lib.rs> +- Paths: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-path/src/lib.rs> +- VFS: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-vfs/src/lib.rs> +- NRes: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-nres/src/lib.rs> +- RsLi: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-rsli/src/lib.rs> +- Diagnostics: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-diagnostics/src/lib.rs> +- Resource repository: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-resource/src/lib.rs> +- Corpus runner: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-corpus/src/lib.rs> + +## Stage 2 + +- Prototype graph: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-prototype/src/lib.rs> +- Prototype manifest: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-prototype/Cargo.toml> +- Assets: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-assets/src/lib.rs> +- Assets manifest: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-assets/Cargo.toml> +- Mission format: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-mission-format/src/lib.rs> +- Runtime: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-runtime/src/lib.rs> +- Runtime manifest: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-runtime/Cargo.toml> +- FX manifest: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-fx/Cargo.toml> + +## Documentation drift + +- Repository implementation tome: <https://github.com/valentineus/fparkan/blob/devel/docs/tomes/07-implementation.md> +- Parity README: <https://github.com/valentineus/fparkan/blob/devel/parity/README.md> +- Parity cases: <https://github.com/valentineus/fparkan/blob/devel/parity/cases.toml> + +--- + +# 15. Финальное заключение + +Проект не находится в плохом состоянии: у него уже есть заметно более сильная CPU/data foundation, чем обычно бывает на ранней стадии восстановления движка. Однако текущая структура создаёт риск преждевременного объявления stages завершёнными, потому что stubs, count reports и ignored licensed tests могут выглядеть как acceptance evidence. + +Главный принцип закрытия: + +> Stage считается завершённым не тогда, когда существует crate или test с нужным названием, а когда канонический exit gate выполняется на production path, выдаёт воспроизводимый machine-readable artifact и не имеет обходного альтернативного пути. + +Для Stage 0 production path — настоящий Vulkan swapchain на трёх OS. +Для Stage 1 — bounded production parsers плюс lossless licensed roundtrips. +Для Stage 2 — materialized typed graph, immutable assets и runtime, который не обходит AssetManager. + +До выполнения перечисленного рекомендуется маркировать текущий статус как: + +```text +Stage 0: IN PROGRESS / BLOCKED +Stage 1: IN PROGRESS / ARCHIVE FOUNDATION PARTIAL +Stage 2: IN PROGRESS / GRAPH AND ASSET ARCHITECTURE NOT CLOSED +``` diff --git a/parity/README.md b/parity/README.md index dd338bc..a7d1008 100644 --- a/parity/README.md +++ b/parity/README.md @@ -1,20 +1,23 @@ # Render Parity Dataset -This folder stores parity-test input for `crates/render-parity`. +This folder stores parity-test input for legacy render comparison workflows. - `cases.toml`: list of deterministic render cases. - `reference/*.png`: baseline frames captured from the original renderer. Expected workflow: -1. Capture baseline PNG frames from original game/editor for each case. +1. Capture baseline PNG frames for each case. 2. Add entries to `cases.toml`. -3. Run: +3. Run the acceptance renderer capture workflow with fixed profiles and compare + output captures out-of-tree. -```bash -cargo run -p render-parity -- \ - --manifest parity/cases.toml \ - --output-dir target/render-parity/current +```text +1) Prepare `cases.toml` and baseline captures. +2) Run `fparkan-game` (or dedicated acceptance runner) with fixed seed. +3) Compare outputs against baseline in dedicated comparison tooling. ``` -On failure, diff images are saved to `target/render-parity/current/diff`. +The `render-parity` crate is no longer present as a standalone runner in this +workspace snapshot; parity evidence is now produced through the acceptance +artifacts and stage audit tooling. diff --git a/rust-toolchain.toml b/rust-toolchain.toml index d0ead5e..f9cc44b 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,9 @@ [toolchain] -channel = "stable" -components = ["clippy", "rustfmt"] +channel = "1.87.0" +components = ["clippy", "rustfmt", "rust-docs"] +targets = [ + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + "aarch64-apple-darwin", + "x86_64-apple-darwin", +] diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index ce7316f..04f8838 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -7,6 +7,9 @@ repository.workspace = true [dependencies] fparkan-corpus = { path = "../crates/fparkan-corpus" } +cargo_metadata = "0.21" +serde = { version = "1.0", features = ["derive"] } +toml = "0.8" [lints] workspace = true diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 2bf6d07..8f67468 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -2,7 +2,9 @@ #![allow(clippy::print_stderr, clippy::print_stdout)] //! Repository automation for `FParkan`. +use cargo_metadata::MetadataCommand; use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions}; +use serde::Deserialize; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::fmt::Write as _; @@ -13,6 +15,9 @@ use std::process::Command; const CORPORA_MANIFEST_ENV: &str = "FPARKAN_CORPORA_MANIFEST"; const PART1_ROOT_ENV: &str = "FPARKAN_CORPUS_PART1_ROOT"; const PART2_ROOT_ENV: &str = "FPARKAN_CORPUS_PART2_ROOT"; +const CI_ACCEPTANCE_ROADMAP: &str = "fixtures/acceptance/stage_0_2_roadmap.md"; +const CI_ACCEPTANCE_COVERAGE: &str = "fixtures/acceptance/coverage.tsv"; +const CI_ACCEPTANCE_REPORT: &str = "target/fparkan/acceptance/stage-0-2-audit.json"; fn main() { let args = std::env::args().skip(1).collect::<Vec<_>>(); @@ -29,10 +34,27 @@ fn main() { fn run(args: &[String]) -> Result<(), String> { match args { [cmd] if cmd == "ci" => { - run_rustfmt_check(Path::new("."))?; + run_cargo_fmt_check()?; run_policy(Path::new("."))?; - cargo(&["test", "--workspace", "--locked", "--offline"])?; - clippy_rustup(&["--workspace", "--locked", "--offline"])?; + cargo(&["test", "--workspace", "--all-targets", "--all-features", "--locked"])?; + cargo(&[ + "clippy", + "--workspace", + "--all-targets", + "--all-features", + "--locked", + "--", + "-D", + "warnings", + ])?; + run_cargo_doc()?; + run_cargo_deny()?; + run_acceptance_audit(&AuditOptions { + roadmap: PathBuf::from(CI_ACCEPTANCE_ROADMAP), + coverage: PathBuf::from(CI_ACCEPTANCE_COVERAGE), + out: PathBuf::from(CI_ACCEPTANCE_REPORT), + strict: true, + })?; Ok(()) } [cmd] if cmd == "policy" => run_policy(Path::new(".")), @@ -115,63 +137,53 @@ fn cargo_with_env(args: &[&str], envs: &[(&str, &Path)]) -> Result<(), String> { } } -fn clippy_rustup(args: &[&str]) -> Result<(), String> { - let rustup = std::env::var_os("RUSTUP").unwrap_or_else(|| "rustup".into()); - let status = Command::new(rustup) - .args(["run", "stable", "cargo-clippy"]) - .args(args) +fn run_cargo_fmt_check() -> Result<(), String> { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let status = Command::new(cargo) + .args(["fmt", "--all", "--", "--check"]) .status() - .map_err(|err| format!("failed to run cargo-clippy through rustup: {err}"))?; + .map_err(|err| format!("failed to run rustfmt: {err}"))?; if status.success() { Ok(()) } else { - Err(format!("cargo-clippy exited with {status}")) + Err(format!("cargo fmt exited with {status}")) } } -fn run_rustfmt_check(root: &Path) -> Result<(), String> { - let mut files = Vec::new(); - collect_rust_files(root, &mut files)?; - if files.is_empty() { - return Ok(()); - } - - let rustup = std::env::var_os("RUSTUP").unwrap_or_else(|| "rustup".into()); - let status = Command::new(rustup) - .args(["run", "stable", "rustfmt", "--check"]) - .args(files) +fn run_cargo_deny() -> Result<(), String> { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let status = Command::new(cargo) + .args([ + "deny", + "check", + "--workspace", + "--all-features", + "advisories", + "bans", + "licenses", + "sources", + ]) .status() - .map_err(|err| format!("failed to run rustfmt: {err}"))?; + .map_err(|err| format!("failed to run cargo-deny: {err}"))?; if status.success() { Ok(()) } else { - Err(format!("rustfmt exited with {status}")) + Err(format!("cargo-deny exited with {status}")) } } -fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> { - let entries = fs::read_dir(dir).map_err(|err| format!("{}: {err}", dir.display()))?; - for entry in entries { - let entry = entry.map_err(|err| format!("{}: {err}", dir.display()))?; - let path = entry.path(); - if should_skip_policy_path(&path) { - continue; - } - let file_type = entry - .file_type() - .map_err(|err| format!("{}: {err}", path.display()))?; - if file_type.is_dir() { - collect_rust_files(&path, out)?; - } else if file_type.is_file() - && path - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| ext == "rs") - { - out.push(path); - } +fn run_cargo_doc() -> Result<(), String> { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let status = Command::new(cargo) + .args(["doc", "--workspace", "--all-features", "--locked", "--no-deps"]) + .env("RUSTDOCFLAGS", "-D warnings -D rustdoc::broken_intra_doc_links") + .status() + .map_err(|err| format!("failed to run cargo doc: {err}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("cargo doc exited with {status}")) } - Ok(()) } #[derive(Clone, Debug, Eq, PartialEq)] @@ -197,67 +209,77 @@ fn load_licensed_roots(manifest: Option<&Path>) -> Result<LicensedCorpusRoots, S format!( "licensed tests require --manifest or {CORPORA_MANIFEST_ENV}=<absolute corpora.toml>" ) - })?; + })?; parse_licensed_manifest(&manifest) } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct LicensedManifest { + schema: Option<u8>, + #[serde(rename = "corpus")] + corpora: Vec<CorpusEntry>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct CorpusEntry { + id: String, + kind: CorpusKind, + root: String, + expected_profile: Option<String>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum CorpusKind { + Part1, + Part2, +} + fn parse_licensed_manifest(path: &Path) -> Result<LicensedCorpusRoots, String> { let text = fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?; + let manifest: LicensedManifest = toml::from_str(&text) + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + if manifest.schema.is_some_and(|version| version != 1) { + return Err(format!( + "unsupported corpora manifest schema {} (expected 1)", + manifest.schema.unwrap_or(1) + )); + } + let mut part1 = None; let mut part2 = None; - let mut current_kind: Option<String> = None; - let mut current_root: Option<PathBuf> = None; - for raw_line in text.lines() { - let line = raw_line.split('#').next().unwrap_or_default().trim(); - if line.is_empty() { - continue; - } - if line == "[[corpus]]" { - flush_manifest_entry(&mut part1, &mut part2, &mut current_kind, &mut current_root)?; - continue; + for entry in manifest.corpora { + match entry.kind { + CorpusKind::Part1 => { + let root = PathBuf::from(entry.root); + assign_manifest_root(&mut part1, root, "part1")?; + } + CorpusKind::Part2 => { + let root = PathBuf::from(entry.root); + assign_manifest_root(&mut part2, root, "part2")?; + } } - let Some((key, value)) = line.split_once('=') else { - continue; - }; - let key = key.trim(); - match key { - "kind" => current_kind = Some(parse_manifest_string(value.trim())?), - "root" => current_root = Some(PathBuf::from(parse_manifest_string(value.trim())?)), - _ => {} + if entry.expected_profile.is_none() { + return Err(format!( + "{}: corpus entry '{}' must define expected_profile", + path.display(), + entry.id + )); } } - flush_manifest_entry(&mut part1, &mut part2, &mut current_kind, &mut current_root)?; let roots = LicensedCorpusRoots { - part1: part1.ok_or_else(|| "licensed manifest is missing kind = \"part1\"".to_string())?, - part2: part2.ok_or_else(|| "licensed manifest is missing kind = \"part2\"".to_string())?, + part1: part1.ok_or_else(|| "licensed manifest is missing part1 corpus entry".to_string())?, + part2: part2.ok_or_else(|| "licensed manifest is missing part2 corpus entry".to_string())?, }; validate_licensed_part("part1", &roots.part1)?; validate_licensed_part("part2", &roots.part2)?; Ok(roots) } -fn flush_manifest_entry( - part1: &mut Option<PathBuf>, - part2: &mut Option<PathBuf>, - current_kind: &mut Option<String>, - current_root: &mut Option<PathBuf>, -) -> Result<(), String> { - let Some(kind) = current_kind.take() else { - *current_root = None; - return Ok(()); - }; - let root = current_root - .take() - .ok_or_else(|| format!("licensed manifest entry {kind} is missing root"))?; - match kind.as_str() { - "part1" => assign_manifest_root(part1, root, "part1"), - "part2" => assign_manifest_root(part2, root, "part2"), - _ => Ok(()), - } -} - fn assign_manifest_root( target: &mut Option<PathBuf>, root: PathBuf, @@ -269,18 +291,6 @@ fn assign_manifest_root( Ok(()) } -fn parse_manifest_string(value: &str) -> Result<String, String> { - let trimmed = value.trim(); - if let Some(quoted) = trimmed - .strip_prefix('"') - .and_then(|value| value.strip_suffix('"')) - { - Ok(quoted.to_string()) - } else { - Err(format!("manifest value must be a quoted string: {trimmed}")) - } -} - fn validate_licensed_part(kind: &str, root: &Path) -> Result<(), String> { if root.is_dir() { Ok(()) @@ -400,27 +410,24 @@ fn validate_cargo_metadata(root: &Path, failures: &mut Vec<String>) -> Result<() if !manifest.exists() { return Ok(()); } - let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); - let output = Command::new(cargo) - .args([ - "metadata", - "--format-version", - "1", - "--offline", - "--locked", - "--no-deps", - "--manifest-path", - ]) - .arg(&manifest) - .output() - .map_err(|err| format!("failed to run cargo metadata: {err}"))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + let metadata = MetadataCommand::new() + .manifest_path(&manifest) + .no_deps(true) + .other_options(["--offline".to_string(), "--locked".to_string()]) + .exec() + .map_err(|error| { + format!( + "{}: cargo metadata failed: {}", + manifest.display(), + error + ) + })?; + if metadata.workspace_members.is_empty() { failures.push(format!( - "{}: cargo metadata failed: {}", - manifest.display(), - stderr.trim() + "{}: cargo metadata produced no workspace members", + manifest.display() )); + return Ok(()); } Ok(()) } @@ -490,34 +497,173 @@ fn validate_dependency_boundaries(root: &Path, failures: &mut Vec<String>) -> Re let Some(package) = parse_package_name(&text) else { continue; }; + if is_removed_legacy_adapter_manifest(root, &manifest) { + failures.push(format!( + "{}: legacy SDL/OpenGL adapter crate must be removed: {package}", + manifest.display() + )); + continue; + } let dependencies = parse_manifest_dependencies(&text); - if is_domain_manifest(root, &manifest) { + if !is_adapter_like_package(&package) { for dependency in &dependencies { - if is_forbidden_domain_dependency(dependency) { + if is_forbidden_gui_dependency(dependency) { failures.push(format!( - "{}: domain package {package} depends on forbidden GUI/adapter package {dependency}", + "{}: package {package} depends on forbidden GUI/adapter package {dependency}", manifest.display() )); } } } - if package == "fparkan-headless" { + if is_app_package(&package) { + if let Some(forbidden) = first_forbidden_parser_dependency(&dependencies) { + failures.push(format!( + "{}: app package {package} depends on parser crate {forbidden}", + manifest.display() + )); + } for dependency in &dependencies { - if matches!( - dependency.as_str(), - "fparkan-platform-sdl" | "fparkan-render-gl" - ) { + if is_forbidden_runtime_bridge_dependency(dependency) { failures.push(format!( - "{}: fparkan-headless depends on forbidden platform/render adapter {dependency}", + "{}: app package {package} depends on forbidden bridge dependency {dependency}", manifest.display() )); } } } + + if package == "fparkan-runtime" { + if let Some(forbidden) = first_forbidden_parser_dependency(&dependencies) { + failures.push(format!( + "{}: runtime package {package} depends on parser crate {forbidden}", + manifest.display() + )); + } + if let Some(forbidden) = first_forbidden_platform_bridge_dependency(&dependencies) { + failures.push(format!( + "{}: runtime package {package} depends on forbidden platform/driver crate {forbidden}", + manifest.display() + )); + } + } + + if package == "fparkan-prototype" { + if let Some(forbidden) = first_forbidden_visual_dependency(&dependencies) { + failures.push(format!( + "{}: prototype package {package} depends on forbidden visual parser {forbidden}", + manifest.display() + )); + } + } } Ok(()) } +fn is_app_package(package: &str) -> bool { + matches!( + package, + "fparkan-cli" | "fparkan-game" | "fparkan-headless" | "fparkan-viewer" + ) +} + +fn is_adapter_like_package(package: &str) -> bool { + matches!( + package, + "fparkan-platform-winit" | "fparkan-render-vulkan" + ) +} + +fn first_forbidden_parser_dependency(dependencies: &BTreeSet<String>) -> Option<&str> { + [ + "fparkan-msh", + "fparkan-nres", + "fparkan-rsli", + "fparkan-terrain-format", + "fparkan-texm", + "fparkan-mission-format", + "fparkan-material", + "fparkan-fx", + ] + .iter() + .find_map(|forbidden| { + if dependencies.contains(*forbidden) { + Some(*forbidden) + } else { + None + } + }) +} + +fn first_forbidden_visual_dependency(dependencies: &BTreeSet<String>) -> Option<&str> { + [ + "fparkan-msh", + "fparkan-material", + "fparkan-texm", + "fparkan-fx", + "fparkan-terrain-format", + ] + .iter() + .find_map(|forbidden| { + if dependencies.contains(*forbidden) { + Some(*forbidden) + } else { + None + } + }) +} + +fn first_forbidden_platform_bridge_dependency(dependencies: &BTreeSet<String>) -> Option<&str> { + [ + "fparkan-platform-winit", + "fparkan-render-vulkan", + "winit", + "ash", + "ash-window", + ] + .iter() + .find_map(|forbidden| { + if dependencies.contains(*forbidden) { + Some(*forbidden) + } else { + None + } + }) +} + +fn is_forbidden_runtime_bridge_dependency(dependency: &str) -> bool { + matches!( + dependency, + "fparkan-platform-winit" | "fparkan-render-vulkan" | "winit" | "ash" | "ash-window" + ) +} + +fn is_forbidden_domain_dependency(dependency: &str) -> bool { + matches!( + dependency, "fparkan-cli" + | "fparkan-game" + | "fparkan-headless" + | "fparkan-viewer" + | "fparkan-platform-sdl" + | "fparkan-render-gl" + | "sdl2" + | "gl" + | "glow" + | "glium" + | "glutin" + ) +} + +fn is_forbidden_gui_dependency(dependency: &str) -> bool { + is_forbidden_domain_dependency(dependency) || is_forbidden_platform_dependency(dependency) +} + +fn is_forbidden_platform_dependency(dependency: &str) -> bool { + matches!( + dependency, + "fparkan-platform-winit" | "fparkan-render-vulkan" | "winit" | "ash" | "ash-window" + ) +} + fn collect_cargo_manifests(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> { let entries = fs::read_dir(dir).map_err(|err| format!("{}: {err}", dir.display()))?; for entry in entries { @@ -610,30 +756,10 @@ fn parse_toml_string_value(line: &str) -> Option<String> { Some(value.trim_matches('"').to_string()) } -fn is_domain_manifest(root: &Path, manifest: &Path) -> bool { - let relative = manifest.strip_prefix(root).unwrap_or(manifest); - relative - .components() - .next() - .is_some_and(|component| component.as_os_str() == "crates") -} - -fn is_forbidden_domain_dependency(dependency: &str) -> bool { - matches!( - dependency, - "fparkan-platform-sdl" - | "fparkan-render-gl" - | "fparkan-cli" - | "fparkan-game" - | "fparkan-headless" - | "fparkan-viewer" - | "sdl2" - | "gl" - | "glow" - | "glium" - | "glutin" - | "winit" - ) +fn is_removed_legacy_adapter_manifest(root: &Path, manifest: &Path) -> bool { + let normalized = manifest.strip_prefix(root).unwrap_or(manifest); + normalized.starts_with("adapters/fparkan-platform-sdl") + || normalized.starts_with("adapters/fparkan-render-gl") } fn scan_policy_dir(dir: &Path, failures: &mut Vec<String>) -> Result<(), String> { @@ -752,18 +878,27 @@ fn scan_policy_file(path: &Path, failures: &mut Vec<String>) -> Result<(), Strin path.display() )); } + let mut previous_line_has_safety_comment = false; for (index, line) in text.lines().enumerate() { let trimmed = line.trim_start(); - if trimmed.starts_with("//") || trimmed.starts_with("//!") || trimmed.starts_with("///") { + if is_comment_line(trimmed) { + previous_line_has_safety_comment = has_safety_comment(trimmed); + continue; + } + if trimmed.is_empty() { + previous_line_has_safety_comment = false; continue; } if contains_unsafe_construct(trimmed) { - failures.push(format!( - "{}:{}: unsafe construct in workspace source", - path.display(), - index + 1 - )); + if !is_authorized_unsafe_construct(path, trimmed, previous_line_has_safety_comment) { + failures.push(format!( + "{}:{}: unsafe construct in workspace source", + path.display(), + index + 1 + )); + } } + previous_line_has_safety_comment = false; } Ok(()) } @@ -775,6 +910,34 @@ fn contains_unsafe_construct(line: &str) -> bool { || line.contains(concat!("extern ", "\"C\"")) } +fn is_comment_line(line: &str) -> bool { + line.starts_with("//") + || line.starts_with("//!") + || line.starts_with("///") +} + +fn has_safety_comment(line: &str) -> bool { + line.contains("SAFETY:") +} + +const AUDITED_UNSAFE_SOURCE_FILES: &[&str] = &["adapters/fparkan-render-vulkan/src/lib.rs"]; + +fn is_audited_unsafe_source(path: &Path) -> bool { + let as_path = path.as_os_str().to_string_lossy(); + AUDITED_UNSAFE_SOURCE_FILES.iter().any(|candidate| as_path.ends_with(candidate)) +} + +fn is_authorized_unsafe_construct( + path: &Path, + line: &str, + previous_line_has_safety_comment: bool, +) -> bool { + if !is_audited_unsafe_source(path) { + return false; + } + previous_line_has_safety_comment || has_safety_comment(line) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Stage { All, @@ -805,8 +968,8 @@ const ALL_WORKSPACE_PACKAGES: &[&str] = &[ "fparkan-texm", "fparkan-vfs", "fparkan-world", - "fparkan-platform-sdl", - "fparkan-render-gl", + "fparkan-platform-winit", + "fparkan-render-vulkan", "fparkan-cli", "fparkan-game", "fparkan-headless", @@ -1033,11 +1196,11 @@ fn run_acceptance_audit(options: &AuditOptions) -> Result<(), String> { fs::write(&options.out, render_audit_json(&audit)) .map_err(|err| format!("{}: {err}", options.out.display()))?; println!("{}", options.out.display()); - let unverified = audit.unverified(); - if options.strict && (!unverified.is_empty() || !audit.unknown_coverage.is_empty()) { + let strict_failures = audit.strict_failures(); + if options.strict && (!strict_failures.is_empty() || !audit.unknown_coverage.is_empty()) { Err(format!( - "acceptance coverage incomplete: {} unverified, {} unknown", - unverified.len(), + "acceptance coverage incomplete: {} strict failures, {} unknown", + strict_failures.len(), audit.unknown_coverage.len() )) } else { @@ -1093,6 +1256,14 @@ impl AcceptanceAudit { .cloned() .collect() } + + fn strict_failures(&self) -> Vec<String> { + self.partial + .iter() + .chain(&self.missing) + .cloned() + .collect() + } } fn extract_acceptance_ids(text: &str) -> BTreeSet<String> { @@ -1666,7 +1837,7 @@ fparkan-render = { path = "../fparkan-render" } "quoted-dep" = "1" [dev-dependencies] -fparkan-render-gl = { path = "../../adapters/fparkan-render-gl" } +fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" } "#; assert_eq!( @@ -1676,13 +1847,14 @@ fparkan-render-gl = { path = "../../adapters/fparkan-render-gl" } let deps = parse_manifest_dependencies(manifest); assert!(deps.contains("fparkan-render")); assert!(deps.contains("quoted-dep")); - assert!(deps.contains("fparkan-render-gl")); + assert!(deps.contains("fparkan-render-vulkan")); } #[test] fn detects_forbidden_domain_dependencies() { - assert!(is_forbidden_domain_dependency("fparkan-render-gl")); + assert!(!is_forbidden_domain_dependency("fparkan-render-vulkan")); assert!(is_forbidden_domain_dependency("sdl2")); + assert!(is_forbidden_domain_dependency("fparkan-platform-sdl")); assert!(!is_forbidden_domain_dependency("fparkan-render")); assert!(!is_forbidden_domain_dependency("fparkan-platform")); } |
