aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-23 21:05:16 +0300
committerValentin Popov <valentin@popov.link>2026-06-23 21:05:16 +0300
commitf8e447ffee746cfe6580cc0e78a8a225aa39b546 (patch)
treee37ebc6c5edd908fd9f44cd3aaf7bffed8de8a88
parent83d763dd70ef20b7d30a905c15cad3d5531ebc6a (diff)
downloadfparkan-f8e447ffee746cfe6580cc0e78a8a225aa39b546.tar.xz
fparkan-f8e447ffee746cfe6580cc0e78a8a225aa39b546.zip
feat: close stage 0-2 audit groundwork
Remove legacy SDL/OpenGL adapters from the workspace and introduce winit/Vulkan adapter boundaries for the rendered composition root. Add reproducible toolchain and xtask CI coverage for formatting, tests, clippy, docs, policy, deny, acceptance auditing, and hosted OS matrix evidence. Strengthen Stage 1 data contracts with byte-first paths, VFS hardening, structured diagnostics, RsLi writer/edit scaffolding, corpus reporting, and resource error classification. Advance Stage 2 asset preparation by moving mission loading through assets/runtime boundaries, materializing prototype graph data, preserving provenance, and adding inspection/viewer integration. Record the Stage 0-2 audit input, acceptance roadmap, coverage updates, and documentation notes for follow-up evidence.
-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"));
}