aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/ci.yml37
-rw-r--r--Cargo.toml6
-rw-r--r--adapters/fparkan-platform-sdl/src/lib.rs123
-rw-r--r--adapters/fparkan-platform-winit/Cargo.toml (renamed from adapters/fparkan-platform-sdl/Cargo.toml)3
-rw-r--r--adapters/fparkan-platform-winit/src/lib.rs257
-rw-r--r--adapters/fparkan-render-gl/src/lib.rs242
-rw-r--r--adapters/fparkan-render-vulkan/Cargo.toml (renamed from adapters/fparkan-render-gl/Cargo.toml)3
-rw-r--r--adapters/fparkan-render-vulkan/src/lib.rs175
-rw-r--r--apps/fparkan-cli/Cargo.toml3
-rw-r--r--apps/fparkan-cli/src/main.rs78
-rw-r--r--apps/fparkan-game/Cargo.toml2
-rw-r--r--apps/fparkan-game/src/main.rs49
-rw-r--r--apps/fparkan-viewer/Cargo.toml8
-rw-r--r--apps/fparkan-viewer/src/main.rs169
-rw-r--r--crates/fparkan-assets/Cargo.toml3
-rw-r--r--crates/fparkan-assets/src/lib.rs837
-rw-r--r--crates/fparkan-corpus/Cargo.toml8
-rw-r--r--crates/fparkan-corpus/src/lib.rs281
-rw-r--r--crates/fparkan-diagnostics/Cargo.toml2
-rw-r--r--crates/fparkan-diagnostics/src/lib.rs128
-rw-r--r--crates/fparkan-inspection/Cargo.toml18
-rw-r--r--crates/fparkan-inspection/src/lib.rs286
-rw-r--r--crates/fparkan-path/src/lib.rs80
-rw-r--r--crates/fparkan-platform/src/lib.rs165
-rw-r--r--crates/fparkan-prototype/Cargo.toml7
-rw-r--r--crates/fparkan-prototype/src/lib.rs824
-rw-r--r--crates/fparkan-rsli/src/lib.rs335
-rw-r--r--crates/fparkan-runtime/Cargo.toml5
-rw-r--r--crates/fparkan-runtime/src/lib.rs160
-rw-r--r--crates/fparkan-vfs/src/lib.rs123
-rw-r--r--docs/appendices/knowledge-boundaries.md27
-rw-r--r--docs/baseline/current-project-audit.md12
-rw-r--r--docs/tomes/07-implementation.md6
-rw-r--r--fixtures/acceptance/coverage.tsv6
-rw-r--r--fixtures/acceptance/stage_0_2_roadmap.md367
-rw-r--r--fparkan_stage_0_2_audit_2026-06-23.md1280
-rw-r--r--parity/README.md19
-rw-r--r--rust-toolchain.toml10
-rw-r--r--xtask/Cargo.toml3
-rw-r--r--xtask/src/main.rs504
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
diff --git a/Cargo.toml b/Cargo.toml
index 7c791db..daa0fb8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"));
}