29 Commits
Author SHA1 Message Date
Valentin Popov d41add32c4 feat: create Vulkan swapchain probe
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Successful in 2m36s
Test / Test (push) Failing after 2m52s
Test / Render parity (push) Has been skipped
2026-06-24 01:05:31 +04:00
Valentin Popov 159731664f feat: probe Vulkan logical device creation 2026-06-24 00:14:26 +04:00
Valentin Popov e6b7fa1896 feat: probe live Vulkan runtime capabilities 2026-06-24 00:05:46 +04:00
Valentin Popov 0e127117e9 feat: require created Vulkan smoke surface 2026-06-23 23:59:07 +04:00
Valentin Popov 4d19728c39 feat: create native smoke window handles 2026-06-23 23:56:40 +04:00
Valentin Popov 54f07ee3be feat: audit native smoke artifacts 2026-06-23 23:51:38 +04:00
Valentin Popov ed2b540abf feat: add target triple to native smoke report 2026-06-23 23:48:31 +04:00
Valentin Popov 00ae9067d8 feat: require swapchain recreation evidence in smoke reports 2026-06-23 23:45:08 +04:00
Valentin Popov c71e706d69 feat: add native smoke window preflight 2026-06-23 23:42:20 +04:00
Valentin Popov aa2133d82b feat: add Vulkan surface preflight to smoke runner 2026-06-23 23:38:09 +04:00
Valentin Popov 71ead678c0 feat: add Vulkan instance probe to smoke runner 2026-06-23 23:35:41 +04:00
Valentin Popov f15ea95bf2 feat: add Vulkan loader probe to smoke runner 2026-06-23 23:33:42 +04:00
Valentin Popov 99bcbf388f refactor: remove xtask native smoke generator 2026-06-23 23:30:26 +04:00
Valentin Popov 227d95fc49 feat: add Vulkan smoke runner entrypoint 2026-06-23 23:27:47 +04:00
Valentin Popov dceea70122 ci: add native smoke artifact schema 2026-06-23 23:22:29 +04:00
Valentin Popov fd452f6016 ci: add acceptance artifact metadata 2026-06-23 23:18:36 +04:00
Valentin Popov 1d0244c3e4 ci: enforce reproducible Rust toolchain 2026-06-23 23:16:50 +04:00
Valentin Popov 5d9e1cbe38 feat: add Vulkan frame submission plan 2026-06-23 23:13:52 +04:00
Valentin Popov 0e76c2ed7c ci: add built-in supply chain policy fallback 2026-06-23 23:10:16 +04:00
Valentin Popov 4c1edef21b test: verify headless dependency closure 2026-06-23 23:05:01 +04:00
Valentin Popov e6778d43af feat: add Vulkan shader manifest validation 2026-06-23 23:01:34 +04:00
Valentin Popov ec8f6599fc feat: add Vulkan swapchain planning policy 2026-06-23 22:57:03 +04:00
Valentin Popov f5fae8e84a feat: add Vulkan surface bootstrap boundary 2026-06-23 22:53:54 +04:00
Valentin Popov a0a4089e4b feat: expose native window handles 2026-06-23 22:50:32 +04:00
Valentin Popov dc7e72961a feat: add Vulkan instance bootstrap plan 2026-06-23 22:47:20 +04:00
Valentin Popov 8ea1fd5c18 feat: probe Vulkan loader boundary 2026-06-23 22:43:54 +04:00
Valentin Popov 69c032acca feat: add Vulkan capability selection boundary 2026-06-23 22:40:01 +04:00
Valentin Popov 9cc24e715d fix: close stage 0-2 synthetic gates 2026-06-23 22:32:50 +04:00
Valentin Popov f8e447ffee 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.
2026-06-23 22:05:16 +04:00
60 changed files with 13026 additions and 1429 deletions
+90
View File
@@ -0,0 +1,90 @@
name: fparkan-ci
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
msrv-backend-neutral:
name: MSRV backend-neutral crates
runs-on: ubuntu-latest
env:
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.87.0
- name: Test backend-neutral crates
run: >
cargo test
-p fparkan-animation
-p fparkan-binary
-p fparkan-corpus
-p fparkan-diagnostics
-p fparkan-fx
-p fparkan-inspection
-p fparkan-material
-p fparkan-mission-format
-p fparkan-msh
-p fparkan-nres
-p fparkan-path
-p fparkan-platform
-p fparkan-prototype
-p fparkan-render
-p fparkan-resource
-p fparkan-rsli
-p fparkan-runtime
-p fparkan-terrain
-p fparkan-terrain-format
-p fparkan-texm
-p fparkan-vfs
-p fparkan-world
--all-targets
--locked
stage0-matrix:
name: Stage 0-2 CI (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
smoke_platform: linux
- os: windows-latest
smoke_platform: windows
- os: macos-latest
smoke_platform: macos
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: Record native Vulkan smoke status
if: always()
shell: bash
run: >
cargo run -p fparkan-vulkan-smoke --locked --
--platform "${{ matrix.smoke_platform }}"
--out "target/fparkan/native-smoke/${{ runner.os }}.json"
--status blocked
--probe-surface
--reason "native Vulkan smoke runner is not enabled on this CI lane yet"
- 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
target/fparkan/native-smoke/*.json
if-no-files-found: ignore
Generated
+2316 -18
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -7,6 +7,7 @@ members = [
"crates/fparkan-corpus", "crates/fparkan-corpus",
"crates/fparkan-diagnostics", "crates/fparkan-diagnostics",
"crates/fparkan-fx", "crates/fparkan-fx",
"crates/fparkan-inspection",
"crates/fparkan-material", "crates/fparkan-material",
"crates/fparkan-mission-format", "crates/fparkan-mission-format",
"crates/fparkan-msh", "crates/fparkan-msh",
@@ -24,11 +25,12 @@ members = [
"crates/fparkan-texm", "crates/fparkan-texm",
"crates/fparkan-vfs", "crates/fparkan-vfs",
"crates/fparkan-world", "crates/fparkan-world",
"adapters/fparkan-platform-sdl", "adapters/fparkan-platform-winit",
"adapters/fparkan-render-gl", "adapters/fparkan-render-vulkan",
"apps/fparkan-cli", "apps/fparkan-cli",
"apps/fparkan-game", "apps/fparkan-game",
"apps/fparkan-headless", "apps/fparkan-headless",
"apps/fparkan-vulkan-smoke",
"apps/fparkan-viewer", "apps/fparkan-viewer",
"xtask", "xtask",
] ]
@@ -36,6 +38,7 @@ members = [
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
rust-version = "1.87"
license = "GPL-2.0-only" license = "GPL-2.0-only"
repository = "https://github.com/valentineus/fparkan" repository = "https://github.com/valentineus/fparkan"
-123
View File
@@ -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(())
}
}
@@ -1,5 +1,5 @@
[package] [package]
name = "fparkan-platform-sdl" name = "fparkan-platform-winit"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@@ -7,6 +7,8 @@ repository.workspace = true
[dependencies] [dependencies]
fparkan-platform = { path = "../../crates/fparkan-platform" } fparkan-platform = { path = "../../crates/fparkan-platform" }
raw-window-handle = "0.6"
winit = "0.30"
[lints] [lints]
workspace = true workspace = true
+509
View File
@@ -0,0 +1,509 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Minimal `winit`-backed platform adapter shim.
use fparkan_platform::{
EventSource, MonotonicClock, MonotonicInstant, NativeWindowHandles, PhysicalSize,
PlatformError, PlatformEvent, RenderRequest, WindowHandle, WindowPort,
};
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use std::collections::VecDeque;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use winit::application::ApplicationHandler;
use winit::dpi::PhysicalSize as WinitPhysicalSize;
use winit::event::{Event, MouseButton, WindowEvent};
use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::platform::scancode::PhysicalKeyExtScancode;
use winit::window::{Window, WindowId};
static NEXT_WINDOW_HANDLE_ID: AtomicU64 = AtomicU64::new(1);
const DEFAULT_SMOKE_WIDTH: u32 = 1280;
const DEFAULT_SMOKE_HEIGHT: u32 = 720;
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 + index,
}
}
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(())
}
}
/// Window creation plan for native smoke entrypoints.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct WinitWindowPlan {
/// Requested drawable width in physical pixels.
pub width: u32,
/// Requested drawable height in physical pixels.
pub height: u32,
/// Whether native window/display handles are required by the caller.
pub requires_native_handles: bool,
}
impl WinitWindowPlan {
/// Returns the Stage 0 native smoke window plan.
#[must_use]
pub const fn smoke() -> Self {
Self {
width: DEFAULT_SMOKE_WIDTH,
height: DEFAULT_SMOKE_HEIGHT,
requires_native_handles: true,
}
}
/// Validates the window plan before a native event loop is entered.
///
/// # Errors
///
/// Returns [`PlatformError`] when the drawable extent is zero.
pub fn validate(self) -> Result<Self, PlatformError> {
if self.width == 0 || self.height == 0 {
return Err(PlatformError::Backend {
context: "winit window plan",
message: "drawable extent must be non-zero".to_string(),
});
}
Ok(self)
}
}
/// Native smoke window creation result.
#[derive(Clone, Copy, Debug)]
pub struct WinitSmokeWindowProbe {
/// Validated creation plan.
pub plan: WinitWindowPlan,
/// Captured window descriptor.
pub window: WinitWindow,
}
impl WinitSmokeWindowProbe {
/// Returns raw native handles captured from the native window.
#[must_use]
pub fn native_handles(&self) -> Option<NativeWindowHandles> {
self.window.native_handles()
}
}
/// Creates a native smoke window, captures raw handles, then exits the event loop.
///
/// # Errors
///
/// Returns [`PlatformError`] when the plan is invalid, the event loop/window
/// cannot be created, or raw native handles are unavailable.
pub fn probe_smoke_window() -> Result<WinitSmokeWindowProbe, PlatformError> {
let plan = WinitWindowPlan::smoke().validate()?;
let event_loop = EventLoop::new().map_err(|err| PlatformError::Backend {
context: "winit event loop",
message: err.to_string(),
})?;
let mut app = SmokeWindowApp::new(plan);
event_loop
.run_app(&mut app)
.map_err(|err| PlatformError::Backend {
context: "winit event loop",
message: err.to_string(),
})?;
app.into_probe()
}
struct SmokeWindowApp {
plan: WinitWindowPlan,
window: Option<WinitWindow>,
error: Option<String>,
}
impl SmokeWindowApp {
const fn new(plan: WinitWindowPlan) -> Self {
Self {
plan,
window: None,
error: None,
}
}
fn into_probe(self) -> Result<WinitSmokeWindowProbe, PlatformError> {
if let Some(message) = self.error {
return Err(PlatformError::Backend {
context: "winit smoke window",
message,
});
}
let window = self.window.ok_or_else(|| PlatformError::Backend {
context: "winit smoke window",
message: "event loop exited before creating a window".to_string(),
})?;
if self.plan.requires_native_handles && window.native_handles().is_none() {
return Err(PlatformError::Backend {
context: "winit smoke window",
message: "native window/display handles are unavailable".to_string(),
});
}
Ok(WinitSmokeWindowProbe {
plan: self.plan,
window,
})
}
}
impl ApplicationHandler for SmokeWindowApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() || self.error.is_some() {
event_loop.exit();
return;
}
let attributes = Window::default_attributes()
.with_title("FParkan Vulkan smoke")
.with_inner_size(WinitPhysicalSize::new(self.plan.width, self.plan.height));
match event_loop.create_window(attributes) {
Ok(window) => {
self.window = Some(WinitWindow::from_window(&window));
}
Err(err) => {
self.error = Some(err.to_string());
}
}
event_loop.exit();
}
fn window_event(
&mut self,
_event_loop: &ActiveEventLoop,
_window_id: WindowId,
_event: WindowEvent,
) {
}
}
/// Minimal window view over a `winit` window.
#[derive(Clone, Copy, Debug)]
pub struct WinitWindow {
handle: WindowHandle,
width: u32,
height: u32,
scale: f64,
focused: bool,
minimized: bool,
occluded: bool,
native_handles: Option<NativeWindowHandles>,
}
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,
native_handles: native_handles(window),
}
}
/// 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,
native_handles: None,
}
}
/// 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
}
fn native_handles(&self) -> Option<NativeWindowHandles> {
self.native_handles
}
}
fn native_handles(window: &Window) -> Option<NativeWindowHandles> {
let display = window.display_handle().ok()?.as_raw();
let window = window.window_handle().ok()?.as_raw();
Some(NativeWindowHandles { display, window })
}
#[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
}
);
assert!(window.native_handles().is_none());
}
#[test]
fn smoke_window_plan_requires_native_handles_and_nonzero_extent() -> Result<(), PlatformError> {
let plan = WinitWindowPlan::smoke().validate()?;
assert_eq!(plan.width, DEFAULT_SMOKE_WIDTH);
assert_eq!(plan.height, DEFAULT_SMOKE_HEIGHT);
assert!(plan.requires_native_handles);
Ok(())
}
#[test]
fn smoke_window_plan_rejects_zero_extent() {
let plan = WinitWindowPlan {
width: 0,
height: DEFAULT_SMOKE_HEIGHT,
requires_native_handles: true,
};
assert!(matches!(
plan.validate(),
Err(PlatformError::Backend {
context: "winit window plan",
..
})
));
}
#[test]
fn smoke_window_app_requires_created_native_window() {
let app = SmokeWindowApp::new(WinitWindowPlan::smoke());
assert!(matches!(
app.into_probe(),
Err(PlatformError::Backend {
context: "winit smoke window",
..
})
));
}
#[test]
fn smoke_window_app_rejects_synthetic_window_without_native_handles() {
let mut app = SmokeWindowApp::new(WinitWindowPlan::smoke());
app.window = Some(WinitWindow::synthetic(
DEFAULT_SMOKE_WIDTH,
DEFAULT_SMOKE_HEIGHT,
));
assert!(matches!(
app.into_probe(),
Err(PlatformError::Backend {
context: "winit smoke window",
..
})
));
}
#[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.
-12
View File
@@ -1,12 +0,0 @@
[package]
name = "fparkan-render-gl"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-render = { path = "../../crates/fparkan-render" }
[lints]
workspace = true
-242
View File
@@ -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,
],
}
}
}
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "fparkan-render-vulkan"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
ash = "0.38"
ash-window = "0.13"
fparkan-binary = { path = "../../crates/fparkan-binary" }
fparkan-platform = { path = "../../crates/fparkan-platform" }
fparkan-render = { path = "../../crates/fparkan-render" }
[lints.rust]
unsafe_code = "allow"
missing_docs = "warn"
unreachable_pub = "warn"
unused_must_use = "deny"
[lints.clippy]
all = { level = "deny", priority = -1 }
pedantic = { level = "warn", priority = -1 }
unwrap_used = "deny"
expect_used = "deny"
panic = "deny"
todo = "deny"
unimplemented = "deny"
dbg_macro = "deny"
print_stdout = "warn"
print_stderr = "warn"
lossy_float_literal = "deny"
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -6,11 +6,11 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-assets = { path = "../../crates/fparkan-assets" }
fparkan-corpus = { path = "../../crates/fparkan-corpus" } fparkan-corpus = { path = "../../crates/fparkan-corpus" }
fparkan-nres = { path = "../../crates/fparkan-nres" }
fparkan-prototype = { path = "../../crates/fparkan-prototype" } fparkan-prototype = { path = "../../crates/fparkan-prototype" }
fparkan-inspection = { path = "../../crates/fparkan-inspection" }
fparkan-resource = { path = "../../crates/fparkan-resource" } fparkan-resource = { path = "../../crates/fparkan-resource" }
fparkan-rsli = { path = "../../crates/fparkan-rsli" }
fparkan-runtime = { path = "../../crates/fparkan-runtime" } fparkan-runtime = { path = "../../crates/fparkan-runtime" }
fparkan-vfs = { path = "../../crates/fparkan-vfs" } fparkan-vfs = { path = "../../crates/fparkan-vfs" }
+42 -28
View File
@@ -1,11 +1,31 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
#![allow(clippy::print_stderr, clippy::print_stdout)] #![allow(clippy::print_stderr, clippy::print_stdout)]
//! `FParkan` command-line tools. //! `FParkan` command-line tools.
use fparkan_assets::extend_graph_report_with_visual_dependencies;
use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions}; use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
use fparkan_prototype::{ use fparkan_inspection::inspect_archive_file;
build_prototype_graph_report, extend_graph_report_with_visual_dependencies, use fparkan_inspection::ArchiveInspection;
}; use fparkan_prototype::build_prototype_graph_report;
use fparkan_resource::{resource_name, CachedResourceRepository}; use fparkan_resource::{resource_name, CachedResourceRepository};
use fparkan_runtime::{ use fparkan_runtime::{
create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest, create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest,
@@ -134,7 +154,7 @@ fn inspect_prototype(args: &[String]) -> Result<(), String> {
let roots = [resource_name(key.as_bytes())]; let roots = [resource_name(key.as_bytes())];
let (graph, resolved, mut report) = let (graph, resolved, mut report) =
build_prototype_graph_report(&repository, vfs.as_ref(), &roots); 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)); println!("{}", prototype_inspect_json(&key, &graph, &report));
Ok(()) Ok(())
} }
@@ -202,43 +222,37 @@ fn graph_mission(args: &[String]) -> Result<(), String> {
fn inspect_archive(args: &[String]) -> Result<(), String> { fn inspect_archive(args: &[String]) -> Result<(), String> {
let path = parse_archive_path(args)?; let path = parse_archive_path(args)?;
let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?; let inspection = inspect_archive_file(&path, 0).map_err(|err| err.to_string())?;
if bytes.starts_with(b"NRes") {
let document = fparkan_nres::decode( match inspection {
Arc::from(bytes.into_boxed_slice()), ArchiveInspection::Nres {
fparkan_nres::ReadProfile::Compatible, entries,
) lookup_order_valid,
.map_err(|err| err.to_string())?; ..
} => {
println!( println!(
"{}", "{}",
archive_inspect_json( archive_inspect_json(
&path.display().to_string(), &path.display().to_string(),
"NRes", "NRes",
document.entries().len(), entries,
Some(document.lookup_order_valid()), Some(lookup_order_valid),
) )
); );
return Ok(()); Ok(())
} }
if bytes.get(0..4) == Some(b"NL\0\x01") { ArchiveInspection::Rsli { entries } => {
let document = fparkan_rsli::decode(
Arc::from(bytes.into_boxed_slice()),
fparkan_rsli::ReadProfile::Compatible,
)
.map_err(|err| err.to_string())?;
println!( println!(
"{}", "{}",
archive_inspect_json( archive_inspect_json(&path.display().to_string(), "RsLi", entries, None)
&path.display().to_string(),
"RsLi",
document.entries().len(),
None
)
); );
return Ok(()); Ok(())
} }
ArchiveInspection::Unsupported => {
Err(format!("{}: unsupported archive magic", path.display())) Err(format!("{}: unsupported archive magic", path.display()))
} }
}
}
fn archive_inspect_json( fn archive_inspect_json(
path: &str, path: &str,
@@ -278,7 +292,7 @@ fn json_string(value: &str) -> String {
'\r' => out.push_str("\\r"), '\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"), '\t' => out.push_str("\\t"),
c if c.is_control() => { 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), c => out.push(c),
} }
+4
View File
@@ -6,7 +6,11 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-assets = { path = "../../crates/fparkan-assets" }
fparkan-platform = { path = "../../crates/fparkan-platform" }
fparkan-render = { path = "../../crates/fparkan-render" } 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-runtime = { path = "../../crates/fparkan-runtime" }
fparkan-vfs = { path = "../../crates/fparkan-vfs" } fparkan-vfs = { path = "../../crates/fparkan-vfs" }
fparkan-world = { path = "../../crates/fparkan-world" } fparkan-world = { path = "../../crates/fparkan-world" }
+67 -10
View File
@@ -1,13 +1,37 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
#![allow(clippy::print_stderr, clippy::print_stdout)] #![allow(clippy::print_stderr, clippy::print_stdout)]
//! `FParkan` rendered game composition root. //! `FParkan` rendered game composition root.
use fparkan_assets::PreparedVisual;
use fparkan_platform::WindowPort;
use fparkan_platform_winit::WinitWindow;
use fparkan_render::{ use fparkan_render::{
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RecordingBackend, RenderBackend, DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderBackend, RenderCommand,
RenderCommand, RenderCommandList, RenderPhase, RenderCommandList, RenderPhase,
}; };
use fparkan_render_vulkan::VulkanBackend;
use fparkan_runtime::{ use fparkan_runtime::{
create, frame, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest, create, frame, load_mission, loaded_mission_assets, EngineConfig, EngineMode, EngineServices,
MissionAssets, MissionRequest,
}; };
use fparkan_vfs::DirectoryVfs; use fparkan_vfs::DirectoryVfs;
use fparkan_world::WorldSnapshot; use fparkan_world::WorldSnapshot;
@@ -47,7 +71,11 @@ fn run(args: &[String]) -> Result<String, String> {
) )
.map_err(|err| err.to_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_draw_count = 0usize;
let mut last_tick = 0u64; let mut last_tick = 0u64;
let mut last_hash = [0u8; 32]; let mut last_hash = [0u8; 32];
@@ -55,7 +83,8 @@ fn run(args: &[String]) -> Result<String, String> {
let result = frame(&mut engine).map_err(|err| err.to_string())?; let result = frame(&mut engine).map_err(|err| err.to_string())?;
last_tick = result.snapshot.tick.0; last_tick = result.snapshot.tick.0;
last_hash = result.snapshot.hash.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 last_draw_count = commands
.commands .commands
.iter() .iter()
@@ -66,6 +95,8 @@ fn run(args: &[String]) -> Result<String, String> {
.map_err(|err| format!("render backend: {err}"))?; .map_err(|err| format!("render backend: {err}"))?;
} }
let capture_report = backend.report();
Ok(format!( Ok(format!(
"{{\"mission\":{},\"objects\":{},\"frames\":{},\"tick\":{},\"draws\":{},\"captures\":{},\"last_capture_bytes\":{},\"hash\":{}}}", "{{\"mission\":{},\"objects\":{},\"frames\":{},\"tick\":{},\"draws\":{},\"captures\":{},\"last_capture_bytes\":{},\"hash\":{}}}",
json_string(&args.mission), json_string(&args.mission),
@@ -73,17 +104,43 @@ fn run(args: &[String]) -> Result<String, String> {
args.frames, args.frames,
last_tick, last_tick,
last_draw_count, last_draw_count,
backend.captures().len(), capture_report.submissions,
backend.last_capture().map_or(0, <[u8]>::len), capture_report.last_capture_size,
json_hash(&last_hash) json_hash(&last_hash)
)) ))
} }
#[cfg(test)]
fn render_snapshot_commands(snapshot: &WorldSnapshot) -> RenderCommandList { 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); let mut commands = Vec::with_capacity(snapshot.objects.len() + 2);
commands.push(RenderCommand::BeginFrame); commands.push(RenderCommand::BeginFrame);
for (index, handle) in snapshot.objects.iter().enumerate() { for (index, handle) in snapshot.objects.iter().enumerate() {
let stable_order = u64::from(handle.slot); 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(PreparedVisual::primary_material_id)
.map_or(GpuMaterialId(1), |material_id| {
GpuMaterialId(material_id.raw())
});
let draw_id = snapshot let draw_id = snapshot
.tick .tick
.0 .0
@@ -93,8 +150,8 @@ fn render_snapshot_commands(snapshot: &WorldSnapshot) -> RenderCommandList {
id: DrawId(draw_id), id: DrawId(draw_id),
phase: RenderPhase::Opaque, phase: RenderPhase::Opaque,
object_id: None, object_id: None,
mesh: GpuMeshId(u64::from(handle.slot) + 1), mesh,
material: GpuMaterialId(1), material,
transform: identity_transform(index_to_f32(index)), transform: identity_transform(index_to_f32(index)),
range: IndexRange { start: 0, count: 3 }, range: IndexRange { start: 0, count: 3 },
stable_order, stable_order,
@@ -178,7 +235,7 @@ fn json_string(value: &str) -> String {
'\t' => out.push_str("\\t"), '\t' => out.push_str("\\t"),
c if c.is_control() => { c if c.is_control() => {
use std::fmt::Write as _; 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), c => out.push(c),
} }
+19
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
#![allow(clippy::print_stderr, clippy::print_stdout)] #![allow(clippy::print_stderr, clippy::print_stdout)]
//! `FParkan` headless runtime entrypoint. //! `FParkan` headless runtime entrypoint.
+1 -7
View File
@@ -6,14 +6,8 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-msh = { path = "../../crates/fparkan-msh" } fparkan-inspection = { path = "../../crates/fparkan-inspection" }
fparkan-nres = { path = "../../crates/fparkan-nres" }
fparkan-resource = { path = "../../crates/fparkan-resource" }
fparkan-render = { path = "../../crates/fparkan-render" } 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] [lints]
workspace = true workspace = true
+91 -95
View File
@@ -1,20 +1,36 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
#![allow(clippy::print_stderr, clippy::print_stdout)] #![allow(clippy::print_stderr, clippy::print_stdout)]
//! `FParkan` asset viewer composition root. //! `FParkan` asset viewer composition root.
use fparkan_msh::{decode_msh, validate_msh}; use fparkan_inspection::{
use fparkan_nres::{decode as decode_nres, ReadProfile as NresReadProfile}; inspect_land_file, inspect_model_from_root, inspect_texture_from_root, ArchiveInspection,
LandFileKind, MapInspection, NresEntrySummary,
};
use fparkan_render::{ use fparkan_render::{
build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderPhase, build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderPhase,
RenderProfile, RenderSnapshot, RenderSnapshotDraw, 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::fmt::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
fn main() { fn main() {
let args = std::env::args().skip(1).collect::<Vec<_>>(); let args = std::env::args().skip(1).collect::<Vec<_>>();
@@ -44,35 +60,27 @@ fn run(args: &[String]) -> Result<String, String> {
fn inspect_archive(args: &[String]) -> Result<String, String> { fn inspect_archive(args: &[String]) -> Result<String, String> {
let file = parse_file(args)?; let file = parse_file(args)?;
let limit = parse_limit(args)?; let limit = parse_limit(args)?;
let bytes = std::fs::read(&file).map_err(|err| format!("{}: {err}", file.display()))?; let inspection = fparkan_inspection::inspect_archive_file(&file, limit)?;
if bytes.starts_with(b"NRes") {
let document = decode_nres( match inspection {
Arc::from(bytes.into_boxed_slice()), ArchiveInspection::Nres {
NresReadProfile::Compatible, entries,
) lookup_order_valid,
.map_err(|err| err.to_string())?; sample,
let sample = render_nres_entries(&document, limit); } => Ok(format!(
return Ok(format!(
"{{\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}", "{{\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}",
json_string(&file.display().to_string()), json_string(&file.display().to_string()),
document.entries().len(), entries,
document.lookup_order_valid(), lookup_order_valid,
sample render_nres_entries(&sample)
)); )),
} ArchiveInspection::Rsli { entries } => Ok(format!(
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!(
"{{\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}", "{{\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}",
json_string(&file.display().to_string()), 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> { fn inspect_model(args: &[String]) -> Result<String, String> {
@@ -81,21 +89,18 @@ fn inspect_model(args: &[String]) -> Result<String, String> {
} }
let query = parse_resource_query(args)?; let query = parse_resource_query(args)?;
let bytes = read_resource(&query)?; let inspection = inspect_model_from_root(&query.root, &query.archive, &query.name)?;
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())?;
Ok(format!( Ok(format!(
"{{\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}", "{{\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}",
json_string(&query.archive), json_string(&query.archive),
json_string(&query.name), json_string(&query.name),
document.streams().len(), inspection.streams,
model.node_count, inspection.nodes,
model.slots.len(), inspection.slots,
model.positions.len(), inspection.positions,
model.indices.len(), inspection.indices,
model.batches.len() inspection.batches
)) ))
} }
@@ -139,54 +144,58 @@ impl ViewerModelService {
fn inspect_texture(args: &[String]) -> Result<String, String> { fn inspect_texture(args: &[String]) -> Result<String, String> {
let query = parse_resource_query(args)?; 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!( Ok(format!(
"{{\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}", "{{\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}",
json_string(&query.archive), json_string(&query.archive),
json_string(&query.name), json_string(&query.name),
document.width(), inspection.width,
document.height(), inspection.height,
json_string(&format!("{:?}", document.format())), json_string(&inspection.format),
document.mip_count(), inspection.mips,
document.page_rects().len() inspection.pages
)) ))
} }
fn inspect_map(args: &[String]) -> Result<String, String> { fn inspect_map(args: &[String]) -> Result<String, String> {
let file = parse_file(args)?; let file = parse_file(args)?;
let kind = parse_option(args, &["--kind"]).ok_or_else(|| "missing --kind".to_string())?; 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 inspection = inspect_land_file(
let nres = decode_nres( &file,
Arc::from(bytes.into_boxed_slice()),
NresReadProfile::Compatible,
)
.map_err(|err| err.to_string())?;
match kind.as_str() { match kind.as_str() {
"land-msh" => { "land-msh" => LandFileKind::LandMsh,
let land = decode_land_msh(&nres).map_err(|err| err.to_string())?; "land-map" => LandFileKind::LandMap,
Ok(format!( _ => 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\":{}}}", "{{\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}",
json_string(&file.display().to_string()), json_string(path),
land.streams.len(), inspection.streams,
land.positions.len(), inspection.positions,
land.faces.len(), inspection.faces,
land.slots.slots_raw.len() inspection.slots
)) ),
} "land-map" => format!(
"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\":{}}}", "{{\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}",
json_string(&file.display().to_string()), json_string(path),
land.areals.len(), inspection.areals,
land.areal_count, inspection.declared_areals,
land.grid.cells_x, inspection.grid_width,
land.grid.cells_y inspection.grid_height
)) ),
} _ => unreachable!("invalid land kind: {kind}"),
_ => Err(format!("unknown map kind: {kind}")),
} }
} }
@@ -205,19 +214,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> { fn parse_file(args: &[String]) -> Result<PathBuf, String> {
parse_path_option(args, &["--file"], "--file") parse_path_option(args, &["--file"], "--file")
} }
@@ -233,19 +229,19 @@ fn parse_limit(args: &[String]) -> Result<usize, String> {
.map(|value| value.unwrap_or(0)) .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(); 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 { if index > 0 {
out.push(','); out.push(',');
} }
let name = String::from_utf8_lossy(entry.name_bytes()); let name = &entry.name;
let _ = write!( let _ = write!(
out, out,
"{{\"name\":{},\"type\":{},\"size\":{}}}", "{{\"name\":{},\"type\":{},\"size\":{}}}",
json_string(&name), json_string(name),
entry.meta().type_id, entry.type_id,
entry.meta().data_size entry.data_size
); );
} }
out out
@@ -278,7 +274,7 @@ fn json_string(value: &str) -> String {
'\r' => out.push_str("\\r"), '\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"), '\t' => out.push_str("\\t"),
c if c.is_control() => { 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), c => out.push(c),
} }
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "fparkan-vulkan-smoke"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-platform = { path = "../../crates/fparkan-platform" }
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+19
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
#![allow(clippy::cast_precision_loss)] #![allow(clippy::cast_precision_loss)]
//! Deterministic animation sampling contracts. //! Deterministic animation sampling contracts.
//! //!
+3
View File
@@ -10,9 +10,12 @@ fparkan-material = { path = "../fparkan-material" }
fparkan-msh = { path = "../fparkan-msh" } fparkan-msh = { path = "../fparkan-msh" }
fparkan-nres = { path = "../fparkan-nres" } fparkan-nres = { path = "../fparkan-nres" }
fparkan-path = { path = "../fparkan-path" } fparkan-path = { path = "../fparkan-path" }
fparkan-mission-format = { path = "../fparkan-mission-format" }
fparkan-prototype = { path = "../fparkan-prototype" } fparkan-prototype = { path = "../fparkan-prototype" }
fparkan-resource = { path = "../fparkan-resource" } fparkan-resource = { path = "../fparkan-resource" }
fparkan-texm = { path = "../fparkan-texm" } fparkan-texm = { path = "../fparkan-texm" }
fparkan-terrain = { path = "../fparkan-terrain" }
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
[dev-dependencies] [dev-dependencies]
fparkan-vfs = { path = "../fparkan-vfs" } fparkan-vfs = { path = "../fparkan-vfs" }
File diff suppressed because it is too large Load Diff
+19
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Bounded little-endian binary cursor and checked layout helpers. //! Bounded little-endian binary cursor and checked layout helpers.
use std::fmt; use std::fmt;
+8
View File
@@ -7,8 +7,16 @@ repository.workspace = true
[dependencies] [dependencies]
fparkan-binary = { path = "../fparkan-binary" } 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-nres = { path = "../fparkan-nres" }
fparkan-prototype = { path = "../fparkan-prototype" }
fparkan-path = { path = "../fparkan-path" } fparkan-path = { path = "../fparkan-path" }
fparkan-rsli = { path = "../fparkan-rsli" }
fparkan-texm = { path = "../fparkan-texm" }
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
[lints] [lints]
workspace = true workspace = true
+317 -22
View File
@@ -1,8 +1,36 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Licensed corpus discovery and aggregate reports. //! Licensed corpus discovery and aggregate reports.
use fparkan_binary::{sha256, sha256_hex, Sha256Digest}; 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_mission_format::{decode_tma, TmaProfile};
use fparkan_msh::{decode_msh, validate_msh};
use fparkan_nres::NresDocument;
use fparkan_path::{ascii_lookup_key, normalize_relative, PathPolicy}; 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_terrain_format::{decode_land_map, decode_land_msh};
use fparkan_texm::decode_texm;
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::fmt; use std::fmt;
use std::fs; use std::fs;
@@ -10,6 +38,8 @@ use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
const TEXM_KIND: u32 = 0x6d78_6554;
/// Corpus kind. /// Corpus kind.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CorpusKind { pub enum CorpusKind {
@@ -336,9 +366,11 @@ fn inspect_report_file(
} }
}; };
if bytes.starts_with(b"NRes") { if bytes.starts_with(b"NRes") {
if variant == "file" {
variant = "nres".to_string(); variant = "nres".to_string();
}
bump(metrics, "nres_files", 1); bump(metrics, "nres_files", 1);
if let Err(message) = inspect_nres_metrics(bytes, metrics) { if let Err(message) = inspect_nres_metrics(&bytes, metrics) {
return CorpusFileRecord { return CorpusFileRecord {
path: entry.path.clone(), path: entry.path.clone(),
status: CorpusFileStatus::Error, status: CorpusFileStatus::Error,
@@ -346,9 +378,58 @@ fn inspect_report_file(
message: Some(message), message: Some(message),
}; };
} }
if variant == "land_msh" {
if 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" {
if 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") { } else if bytes.starts_with(b"NL") {
variant = "rsli".to_string(); variant = "rsli".to_string();
bump(metrics, "rsli_files", 1); 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 { CorpusFileRecord {
path: entry.path.clone(), path: entry.path.clone(),
@@ -379,26 +460,31 @@ fn inspect_path_metrics(lower: &str, metrics: &mut BTreeMap<String, u64>) -> Str
variant.to_string() variant.to_string()
} }
fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) -> Result<(), String> { fn inspect_nres_metrics(bytes: &[u8], metrics: &mut BTreeMap<String, u64>) -> Result<(), String> {
let entries = inspect_nres_entries(bytes)?; let document = inspect_nres_document(bytes)?;
bump(metrics, "nres_entries", entries.len() as u64); bump(metrics, "nres_entries", document.entries().len() as u64);
for entry in entries { for entry in document.entries() {
let name = String::from_utf8_lossy(entry.name_bytes()).to_ascii_lowercase(); let name = String::from_utf8_lossy(entry.name_bytes()).to_ascii_lowercase();
if has_extension(&name, "msh") { if has_extension(&name, "msh") {
bump(metrics, "msh_entries", 1); bump(metrics, "msh_entries", 1);
validate_nres_msh_payload(&document, entry)?;
} }
match entry.meta().type_id { match entry.meta().type_id {
0x3054_414D => { MAT0_KIND => {
bump(metrics, "mat0_entries", 1); bump(metrics, "mat0_entries", 1);
validate_nres_mat0_payload(&document, entry)?;
} }
0x6D78_6554 => { TEXM_KIND => {
bump(metrics, "texm_entries", 1); bump(metrics, "texm_entries", 1);
validate_nres_texm_payload(&document, entry)?;
} }
0x4449_5846 => { FXID_KIND => {
bump(metrics, "fxid_entries", 1); bump(metrics, "fxid_entries", 1);
validate_nres_fxid_payload(&document, entry)?;
} }
0x5241_4557 => { WEAR_KIND => {
bump(metrics, "wear_entries", 1); bump(metrics, "wear_entries", 1);
validate_nres_wear_payload(&document, entry)?;
} }
_ => {} _ => {}
} }
@@ -406,6 +492,110 @@ fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) ->
Ok(()) 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) { fn bump(metrics: &mut BTreeMap<String, u64>, key: &str, delta: u64) {
if let Some(value) = metrics.get_mut(key) { if let Some(value) = metrics.get_mut(key) {
*value = value.saturating_add(delta); *value = value.saturating_add(delta);
@@ -418,15 +608,6 @@ fn has_extension(path: &str, expected: &str) -> bool {
.is_some_and(|extension| extension.eq_ignore_ascii_case(expected)) .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. /// Computes stable manifest fingerprint.
#[must_use] #[must_use]
pub fn fingerprint(manifest: &CorpusManifest) -> Sha256Digest { pub fn fingerprint(manifest: &CorpusManifest) -> Sha256Digest {
@@ -686,15 +867,129 @@ mod tests {
let report = report(&root, &manifest).expect("report"); let report = report(&root, &manifest).expect("report");
assert_eq!(report.failures, 0); assert_eq!(report.failures, 1);
assert_eq!(report.records.len(), 1); assert_eq!(report.records.len(), 1);
assert_eq!(report.records[0].status, CorpusFileStatus::Ok); assert_eq!(report.records[0].status, CorpusFileStatus::Error);
assert_eq!(report.records[0].variant, "nres"); assert_eq!(report.records[0].variant, "nres");
assert_eq!(report.metrics["nres_files"], 1); assert_eq!(report.metrics["nres_files"], 1);
assert_eq!(report.metrics["nres_entries"], 3); assert_eq!(report.metrics["nres_entries"], 3);
assert_eq!(report.metrics["msh_entries"], 1); assert_eq!(report.metrics["msh_entries"], 1);
assert_eq!(report.metrics["mat0_entries"], 1); assert_eq!(report.metrics["mat0_entries"], 0);
assert_eq!(report.metrics["texm_entries"], 1); assert_eq!(report.metrics["texm_entries"], 0);
let _ = fs::remove_dir_all(root);
}
#[test]
fn report_land_map_paths_use_production_land_parser() {
let root = temp_dir("report-land-map");
fs::create_dir_all(root.join("WORLD/MAP")).expect("land map dir");
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::create_dir_all(root.join("WORLD/MAP")).expect("land msh dir");
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::create_dir_all(root.join("MISSIONS/test")).expect("tma dir");
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::create_dir_all(root.join("units")).expect("unit dir");
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); let _ = fs::remove_dir_all(root);
} }
+2
View File
@@ -6,6 +6,8 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[lints] [lints]
workspace = true workspace = true
+50 -102
View File
@@ -1,8 +1,30 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Structured diagnostics shared by `FParkan` crates. //! Structured diagnostics shared by `FParkan` crates.
use serde::Serialize;
/// Diagnostic severity. /// Diagnostic severity.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity { pub enum Severity {
/// Informational note. /// Informational note.
Info, Info,
@@ -15,7 +37,8 @@ pub enum Severity {
} }
/// Evidence level for a contract or interpretation. /// 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 { pub enum EvidenceStatus {
/// Described by project documentation. /// Described by project documentation.
Documented, Documented,
@@ -30,7 +53,8 @@ pub enum EvidenceStatus {
} }
/// Operation phase where a diagnostic was produced. /// 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 { pub enum Phase {
/// Discovery. /// Discovery.
Discover, Discover,
@@ -55,7 +79,7 @@ pub enum Phase {
} }
/// Byte span in an input source. /// Byte span in an input source.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
pub struct SourceSpan { pub struct SourceSpan {
/// Start offset. /// Start offset.
pub offset: u64, pub offset: u64,
@@ -64,26 +88,31 @@ pub struct SourceSpan {
} }
/// Stable diagnostic code. /// Stable diagnostic code.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
pub struct DiagnosticCode(pub &'static str); pub struct DiagnosticCode(pub &'static str);
/// Context attached to a diagnostic. /// Context attached to a diagnostic.
#[derive(Clone, Debug, Default, Eq, PartialEq)] #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub struct DiagnosticContext { pub struct DiagnosticContext {
/// Phase. /// Phase.
#[serde(skip_serializing_if = "Option::is_none")]
pub phase: Option<Phase>, pub phase: Option<Phase>,
/// Redacted or logical path. /// Redacted or logical path.
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>, pub path: Option<String>,
/// Archive entry name. /// Archive entry name.
#[serde(skip_serializing_if = "Option::is_none")]
pub archive_entry: Option<String>, pub archive_entry: Option<String>,
/// Object/prototype key. /// Object/prototype key.
#[serde(skip_serializing_if = "Option::is_none")]
pub object_key: Option<String>, pub object_key: Option<String>,
/// Input span. /// Input span.
#[serde(skip_serializing_if = "Option::is_none")]
pub span: Option<SourceSpan>, pub span: Option<SourceSpan>,
} }
/// Structured diagnostic with cause chain. /// Structured diagnostic with cause chain.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct Diagnostic { pub struct Diagnostic {
/// Stable code. /// Stable code.
pub code: DiagnosticCode, pub code: DiagnosticCode,
@@ -145,105 +174,14 @@ pub fn render_human(diagnostic: &Diagnostic) -> String {
out out
} }
/// Renders deterministic JSON without requiring a serialization dependency. /// Renders deterministic JSON using the typed diagnostic schema.
#[must_use] #[must_use]
pub fn render_json(diagnostic: &Diagnostic) -> String { pub fn render_json(diagnostic: &Diagnostic) -> String {
fn esc(value: &str) -> String { match serde_json::to_string(diagnostic) {
let mut out = String::with_capacity(value.len() + 2); Ok(json) => json,
for ch in value.chars() { Err(err) => format!("{{\"error\":\"diagnostic serialization failed: {err}\"}}"),
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('"');
}
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)] #[cfg(test)]
mod tests { mod tests {
@@ -298,4 +236,14 @@ mod tests {
assert!(json.contains("\"code\":\"CAUSE\"")); assert!(json.contains("\"code\":\"CAUSE\""));
assert!(json.contains("\"span\":{\"offset\":16,\"length\":8}")); 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("\\t"));
assert!(!json.contains('\t'));
assert!(!json.contains('\r'));
}
} }
+19
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! FXID effect contracts. //! FXID effect contracts.
//! //!
//! FXID decoding and command framing are implemented as compatibility //! FXID decoding and command framing are implemented as compatibility
+18
View File
@@ -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
+327
View File
@@ -0,0 +1,327 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! 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, ResourceRepository};
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;
use std::fs;
use std::path::Path;
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.
///
/// # Errors
///
/// Returns a string error when the archive cannot be read or decoded.
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.
///
/// # Errors
///
/// Returns a string error when the resource cannot be resolved or parsed as a
/// valid model payload.
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.
///
/// # Errors
///
/// Returns a string error when the resource cannot be resolved or parsed as a
/// valid texture payload.
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.
///
/// # Errors
///
/// Returns a string error when the file cannot be read or parsed as the
/// requested terrain payload kind.
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 _;
use std::path::PathBuf;
#[test]
fn inspect_rsli_rejects_malformed_archive() {
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 error = inspect_archive_file(&path, 0).expect_err("malformed archive");
assert!(error.contains("entry table out of bounds"));
}
#[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
}
}
+19
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! WEAR/MAT0 material contracts. //! WEAR/MAT0 material contracts.
use encoding_rs::WINDOWS_1251; use encoding_rs::WINDOWS_1251;
+19
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Count-driven mission format primitives. //! Count-driven mission format primitives.
use encoding_rs::WINDOWS_1251; use encoding_rs::WINDOWS_1251;
+20 -1
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Stage-3 MSH asset contract. //! Stage-3 MSH asset contract.
use encoding_rs::WINDOWS_1251; use encoding_rs::WINDOWS_1251;
@@ -1689,7 +1708,7 @@ mod tests {
out out
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::similar_names, clippy::too_many_arguments)]
fn batch_record( fn batch_record(
batch_flags: u16, batch_flags: u16,
material_index: u16, material_index: u16,
+19
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Strict and lossless `NRes` archive support. //! Strict and lossless `NRes` archive support.
use fparkan_binary::{Cursor, DecodeError}; use fparkan_binary::{Cursor, DecodeError};
+82 -18
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Legacy path normalization and ASCII lookup semantics. //! Legacy path normalization and ASCII lookup semantics.
use std::fmt; use std::fmt;
@@ -24,13 +43,28 @@ impl OriginalPathBytes {
/// Normalized relative path. /// Normalized relative path.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct NormalizedPath(String); pub struct NormalizedPath {
raw: Vec<u8>,
display: String,
}
impl NormalizedPath { impl NormalizedPath {
/// Returns string view. /// Returns string view.
#[must_use] #[must_use]
pub fn as_str(&self) -> &str { 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 +125,6 @@ pub enum PathError {
ParentTraversal, ParentTraversal,
/// Host path escape. /// Host path escape.
EscapesRoot, EscapesRoot,
/// Invalid UTF-8 after normalization.
InvalidUtf8,
} }
impl fmt::Display for PathError { impl fmt::Display for PathError {
@@ -103,7 +135,6 @@ impl fmt::Display for PathError {
Self::Absolute => write!(f, "path must be relative and cannot be absolute"), Self::Absolute => write!(f, "path must be relative and cannot be absolute"),
Self::ParentTraversal => write!(f, "path attempts to traverse outside its root"), Self::ParentTraversal => write!(f, "path attempts to traverse outside its root"),
Self::EscapesRoot => write!(f, "normalized path escapes the configured 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 +146,7 @@ impl std::error::Error for PathError {}
/// # Errors /// # Errors
/// ///
/// Returns [`PathError`] when the input is empty, absolute, contains an /// Returns [`PathError`] when the input is empty, absolute, contains an
/// embedded NUL, attempts parent traversal, or is not valid UTF-8 after /// embedded NUL, attempts parent traversal, or has an invalid drive prefix.
/// legacy separator normalization.
pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPath, PathError> { pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPath, PathError> {
if raw.is_empty() { if raw.is_empty() {
return Err(PathError::Empty); return Err(PathError::Empty);
@@ -124,22 +154,21 @@ pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPa
if raw.contains(&0) { if raw.contains(&0) {
return Err(PathError::EmbeddedNul); return Err(PathError::EmbeddedNul);
} }
let text = std::str::from_utf8(raw).map_err(|_| PathError::InvalidUtf8)?; if raw.starts_with(b"/") || raw.starts_with(b"\\") || has_drive_prefix(raw) {
if text.starts_with('/') || text.starts_with('\\') || has_drive_prefix(text) {
return Err(PathError::Absolute); return Err(PathError::Absolute);
} }
let mut parts = Vec::new(); let mut parts = Vec::new();
for part in text.split(['/', '\\']) { for part in raw.split(|byte| *byte == b'/' || *byte == b'\\') {
if part.is_empty() || part == "." { if part.is_empty() || part == b"." {
if policy == PathPolicy::StrictLegacy { if policy == PathPolicy::StrictLegacy {
return Err(PathError::ParentTraversal); return Err(PathError::ParentTraversal);
} }
continue; continue;
} }
if part == ".." { if part == b".." {
return Err(PathError::ParentTraversal); return Err(PathError::ParentTraversal);
} }
if policy == PathPolicy::StrictLegacy && part.contains(':') { if policy == PathPolicy::StrictLegacy && part.contains(&b':') {
return Err(PathError::Absolute); return Err(PathError::Absolute);
} }
parts.push(part); parts.push(part);
@@ -147,7 +176,18 @@ pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPa
if parts.is_empty() { if parts.is_empty() {
return Err(PathError::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);
}
let display = String::from_utf8_lossy(&normalized).into_owned();
Ok(NormalizedPath {
raw: normalized,
display,
})
} }
/// Normalizes a relative path while preserving its original bytes. /// Normalizes a relative path while preserving its original bytes.
@@ -166,8 +206,7 @@ pub fn normalize_relative_with_original(
}) })
} }
fn has_drive_prefix(text: &str) -> bool { fn has_drive_prefix(bytes: &[u8]) -> bool {
let bytes = text.as_bytes();
bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
} }
@@ -184,7 +223,11 @@ pub fn ascii_lookup_key(raw: &[u8]) -> LookupKey {
/// Returns [`PathError::ParentTraversal`] when a normalized segment attempts /// Returns [`PathError::ParentTraversal`] when a normalized segment attempts
/// to address a parent directory. /// to address a parent directory.
pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> { 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) Err(PathError::ParentTraversal)
} else { } else {
Ok(()) Ok(())
@@ -198,7 +241,20 @@ pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
/// Returns [`PathError`] if the normalized path fails the escape check. /// Returns [`PathError`] if the normalized path fails the escape check.
pub fn join_under(root: &Path, rel: &NormalizedPath) -> Result<PathBuf, PathError> { pub fn join_under(root: &Path, rel: &NormalizedPath) -> Result<PathBuf, PathError> {
reject_escape(rel)?; 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)] #[cfg(test)]
@@ -292,6 +348,14 @@ mod tests {
assert_eq!(&ascii_lookup_key(raw).0[5..13], &raw[5..13]); assert_eq!(&ascii_lookup_key(raw).0[5..13], &raw[5..13]);
} }
#[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] #[test]
fn original_separators_and_raw_bytes_are_preserved() { fn original_separators_and_raw_bytes_are_preserved() {
let raw = b"DATA\\Maps/Intro\\Land.msh"; let raw = b"DATA\\Maps/Intro\\Land.msh";
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
raw-window-handle = "0.6"
[lints] [lints]
workspace = true workspace = true
+179 -39
View File
@@ -1,39 +1,125 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
//! Platform ports for clocks, input, events, windows, and graphics requests. #![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Platform ports for clocks, event sources and window descriptors.
/// Monotonic instant. use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
/// Monotonic instant measured in milliseconds since process start.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct MonotonicInstant(pub u64); pub struct MonotonicInstant(pub u64);
/// Monotonic clock. /// Platform clock.
pub trait MonotonicClock { pub trait MonotonicClock {
/// Current instant. /// Current instant.
fn now(&self) -> MonotonicInstant; fn now(&self) -> MonotonicInstant;
} }
/// Platform event. /// Platform event.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub enum PlatformEvent { pub enum PlatformEvent {
/// Quit requested. /// Window/application requested to quit.
Quit, QuitRequested,
/// Window focus changed.
FocusChanged {
/// Whether the window is focused.
focused: bool,
},
/// Window resize or move to a new drawable size.
Resize {
/// Drawable width in physical pixels.
width: u32,
/// Drawable height in physical pixels.
height: u32,
},
/// Device pixel ratio changed.
DpiChanged {
/// Logical-to-physical scale factor.
scale: f64,
},
/// Window minimized/hidden.
Minimized {
/// Whether the window is minimized.
minimized: bool,
},
/// Window occlusion state changed.
Occluded {
/// Whether the window is 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)] #[derive(Debug)]
pub enum PlatformError { pub enum PlatformError {
/// Backend failed. /// Backend/backend-specific failure.
Backend, Backend {
/// Operation or subsystem.
context: &'static str,
/// Human-readable details.
message: String,
},
} }
impl std::fmt::Display for PlatformError { impl std::fmt::Display for PlatformError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 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 {} impl std::error::Error for PlatformError {}
/// Event source. /// Event source contract for polling platform events.
pub trait EventSource { pub trait EventSource {
/// Polls events. /// Polls events.
/// ///
@@ -43,7 +129,7 @@ pub trait EventSource {
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError>; fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError>;
} }
/// Physical size. /// Physical window size.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PhysicalSize { pub struct PhysicalSize {
/// Width. /// Width.
@@ -52,42 +138,96 @@ pub struct PhysicalSize {
pub height: u32, 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,
}
/// Native raw window/display handles for render surface creation.
#[derive(Clone, Copy, Debug)]
pub struct NativeWindowHandles {
/// Raw display handle.
pub display: RawDisplayHandle,
/// Raw window handle.
pub window: RawWindowHandle,
}
/// Window presentation and lifecycle port.
///
/// Presentation is not owned by the window abstraction. Render adapters
/// own swapchain and present lifecycle.
pub trait WindowPort { pub trait WindowPort {
/// Drawable size. /// Current drawable size.
fn drawable_size(&self) -> PhysicalSize; fn drawable_size(&self) -> PhysicalSize;
/// Presents. /// DPI scale for this window.
/// fn dpi_scale(&self) -> f64;
/// # Errors /// Whether the window is focused.
/// fn has_focus(&self) -> bool;
/// Returns [`PlatformError`] when the backend cannot present the current /// Whether the window is minimized.
/// frame. fn is_minimized(&self) -> bool;
fn present(&mut self) -> Result<(), PlatformError>; /// Whether the window is occluded.
fn is_occluded(&self) -> bool;
/// Opaque window identity.
fn handle(&self) -> WindowHandle;
/// Raw native handles for render surface creation, when backed by a native window.
fn native_handles(&self) -> Option<NativeWindowHandles> {
None
}
} }
/// Graphics profile. /// Render backend request contract.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GraphicsProfile { pub struct RenderRequest {
/// Desktop core. /// Preferred color-space profile.
DesktopCore, pub color_space: ColorSpace,
/// Embedded profile. /// Preferred presentation mode.
Embedded, pub presentation: PresentationMode,
/// Requested depth/stencil format.
pub depth: DepthStencilSupport,
} }
/// Version. /// Color-space profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Version { pub enum ColorSpace {
/// Major. /// sRGB nonlinear.
pub major: u8, Srgb,
/// Minor. /// Linear color-space.
pub minor: u8, Linear,
} }
/// Graphics context request. /// Presentation mode.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct GraphicsContextRequest { pub enum PresentationMode {
/// Profile. /// `VSync`.
pub profile: GraphicsProfile, Fifo,
/// Version. /// No `VSync`.
pub version: Version, 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,
},
}
}
} }
+3 -4
View File
@@ -8,13 +8,12 @@ repository.workspace = true
[dependencies] [dependencies]
encoding_rs = "0.8" encoding_rs = "0.8"
fparkan-binary = { path = "../fparkan-binary" } 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-path = { path = "../fparkan-path" }
fparkan-resource = { path = "../fparkan-resource" } fparkan-resource = { path = "../fparkan-resource" }
fparkan-texm = { path = "../fparkan-texm" }
fparkan-vfs = { path = "../fparkan-vfs" } fparkan-vfs = { path = "../fparkan-vfs" }
[lints] [lints]
workspace = true workspace = true
[dev-dependencies]
fparkan-nres = { path = "../fparkan-nres" }
File diff suppressed because it is too large Load Diff
+19
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Backend-neutral render commands and deterministic captures. //! Backend-neutral render commands and deterministic captures.
use fparkan_world::OriginalObjectId; use fparkan_world::OriginalObjectId;
+19
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Resource identity and repository ports. //! Resource identity and repository ports.
use fparkan_binary::Sha256Digest; use fparkan_binary::Sha256Digest;
+374
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Stage-1 `RsLi` archive contract. //! Stage-1 `RsLi` archive contract.
use std::fmt; use std::fmt;
@@ -59,6 +78,71 @@ pub enum WriteProfile {
Lossless, 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. /// `RsLi` compatibility switches.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RsliCompatibilityProfile { pub struct RsliCompatibilityProfile {
@@ -493,6 +577,199 @@ impl RsliDocument {
WriteProfile::Lossless => self.bytes.to_vec(), 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 entry_id = EntryId(u32::try_from(id).map_err(|_| RsliError::IntegerOverflow)?);
let packed = self.packed_slice(entry_id, 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.
///
/// # Errors
///
/// Returns [`RsliMutationError`] when the entry id is unknown or the packed
/// payload is too large for the archive directory format.
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: u32::MAX as usize,
})?;
entry.packed = packed;
entry.meta.unpacked_size = unpacked_size;
self.dirty = true;
Ok(())
}
/// Replaces entry packing method in-place.
///
/// # Errors
///
/// Returns [`RsliMutationError`] when the entry id is unknown.
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.
///
/// # Errors
///
/// Returns [`RsliMutationError`] when the entry id is unknown or the name
/// cannot be represented in the fixed authoring 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 =
u16::try_from(self.header.xor_seed & 0xFFFF).map_err(|_| RsliError::IntegerOverflow)?;
let encrypted = xor_stream(&table_plain, seed);
output.splice(32..32, encrypted);
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(RsliMutationError::EntryNotFound { id })
}
} }
impl RsliDocument { impl RsliDocument {
@@ -833,6 +1110,23 @@ fn decode_name(name: &[u8]) -> String {
name.iter().map(|byte| char::from(*byte)).collect() 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] { fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len()); let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len());
&raw[..len] &raw[..len]
@@ -1814,6 +2108,86 @@ mod tests {
assert_eq!(doc.encode(WriteProfile::Lossless), bytes); assert_eq!(doc.encode(WriteProfile::Lossless), bytes);
} }
#[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", 14)
.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::Stored
);
}
#[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] #[test]
fn generated_supported_methods_decode_expected_bytes() { fn generated_supported_methods_decode_expected_bytes() {
let cases = [ let cases = [
+1 -4
View File
@@ -6,15 +6,12 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-mission-format = { path = "../fparkan-mission-format" } fparkan-assets = { path = "../fparkan-assets" }
fparkan-nres = { path = "../fparkan-nres" }
fparkan-path = { path = "../fparkan-path" } fparkan-path = { path = "../fparkan-path" }
fparkan-platform = { path = "../fparkan-platform" } fparkan-platform = { path = "../fparkan-platform" }
fparkan-prototype = { path = "../fparkan-prototype" } fparkan-prototype = { path = "../fparkan-prototype" }
fparkan-render = { path = "../fparkan-render" } fparkan-render = { path = "../fparkan-render" }
fparkan-resource = { path = "../fparkan-resource" } fparkan-resource = { path = "../fparkan-resource" }
fparkan-terrain = { path = "../fparkan-terrain" }
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
fparkan-vfs = { path = "../fparkan-vfs" } fparkan-vfs = { path = "../fparkan-vfs" }
fparkan-world = { path = "../fparkan-world" } fparkan-world = { path = "../fparkan-world" }
+106 -79
View File
@@ -1,19 +1,37 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Runtime orchestration for headless and rendered modes. //! Runtime orchestration for headless and rendered modes.
use fparkan_mission_format::{ use fparkan_assets::{
decode_tma, decode_tma_land_path, LpString, MissionDocument, MissionError, TmaProfile, decode_mission_land_path, decode_mission_payload, decode_nres_payload,
derive_mission_land_paths, extend_graph_report_with_visual_dependencies, prepare_terrain_world,
AssetError as AssetPreparationError, AssetManager, BuildCategory, MissionAssetPlan,
MissionDocument, MissionError, MissionTerrainPaths, NresError, TerrainFormatError,
TerrainPreparationError, TerrainWorld, TmaProfile,
}; };
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy}; use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
use fparkan_prototype::{ use fparkan_prototype::{
build_prototype_graph_report, extend_graph_report_with_visual_dependencies, EffectivePrototype, build_prototype_graph_report, PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport,
PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport,
}; };
use fparkan_resource::{resource_name, CachedResourceRepository}; 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_vfs::{Vfs, VfsError};
use fparkan_world::{ use fparkan_world::{
construct_object, new as new_world, register_object, step, InputSnapshot, ObjectDraft, construct_object, new as new_world, register_object, step, InputSnapshot, ObjectDraft,
@@ -21,6 +39,8 @@ use fparkan_world::{
}; };
use std::sync::Arc; use std::sync::Arc;
pub use fparkan_assets::MissionAssets;
/// Engine mode. /// Engine mode.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EngineMode { pub enum EngineMode {
@@ -167,6 +187,8 @@ pub struct LoadedMission {
pub graph_unit_component_count: usize, pub graph_unit_component_count: usize,
/// Mission prototype graph root count. /// Mission prototype graph root count.
pub graph_root_count: usize, 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. /// Expanded prototype requests resolved to effective prototypes.
pub graph_resolved_count: usize, pub graph_resolved_count: usize,
/// Reached mesh dependency count. /// Reached mesh dependency count.
@@ -189,6 +211,14 @@ pub struct LoadedMission {
pub graph_lightmap_request_count: usize, pub graph_lightmap_request_count: usize,
/// Lightmap Texm entries decoded. /// Lightmap Texm entries decoded.
pub graph_lightmap_resolved_count: usize, 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. /// Frame result.
@@ -222,7 +252,8 @@ struct LoadedMissionState {
build_categories: Vec<BuildCategory>, build_categories: Vec<BuildCategory>,
prototype_graph: PrototypeGraph, prototype_graph: PrototypeGraph,
prototype_report: PrototypeGraphReport, prototype_report: PrototypeGraphReport,
resolved_prototypes: Vec<EffectivePrototype>, mission_assets: MissionAssets,
asset_plan: MissionAssetPlan,
} }
/// Engine error. /// Engine error.
@@ -251,7 +282,7 @@ pub enum EngineError {
/// Resource path. /// Resource path.
path: String, path: String,
/// Source error. /// Source error.
source: fparkan_nres::NresError, source: NresError,
}, },
/// Mission decode error. /// Mission decode error.
Mission { Mission {
@@ -268,12 +299,19 @@ pub enum EngineError {
source: TerrainFormatError, source: TerrainFormatError,
}, },
/// Terrain runtime build error. /// Terrain runtime build error.
Terrain(fparkan_terrain::TerrainError), Terrain(fparkan_assets::TerrainError),
/// Prototype graph errors. /// Prototype graph errors.
PrototypeGraph { PrototypeGraph {
/// Root failures. /// Root failures.
failures: Vec<PrototypeGraphFailure>, failures: Vec<PrototypeGraphFailure>,
}, },
/// Asset preparation errors.
AssetPreparation {
/// Mission key.
mission: String,
/// Source error.
source: AssetPreparationError,
},
/// World error. /// World error.
World(fparkan_world::WorldError), World(fparkan_world::WorldError),
/// Scheduler phase order was violated. /// Scheduler phase order was violated.
@@ -319,6 +357,9 @@ impl std::fmt::Display for EngineError {
Self::PrototypeGraph { failures } => { Self::PrototypeGraph { failures } => {
write!(f, "mission prototype graph has {} failures", failures.len()) 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::World(source) => write!(f, "{source}"),
Self::SchedulerPhaseOrder { previous, current } => write!( Self::SchedulerPhaseOrder { previous, current } => write!(
f, f,
@@ -346,6 +387,7 @@ impl std::error::Error for EngineError {
Self::TerrainFormat { source, .. } => Some(source), Self::TerrainFormat { source, .. } => Some(source),
Self::Terrain(source) => Some(source), Self::Terrain(source) => Some(source),
Self::World(source) => Some(source), Self::World(source) => Some(source),
Self::AssetPreparation { source, .. } => Some(source),
Self::MissingVfs Self::MissingVfs
| Self::PrototypeGraph { .. } | Self::PrototypeGraph { .. }
| Self::SchedulerPhaseOrder { .. } | Self::SchedulerPhaseOrder { .. }
@@ -410,44 +452,52 @@ fn load_mission_with_options(
let mission_bytes = read_vfs(&vfs, &mission_path)?; let mission_bytes = read_vfs(&vfs, &mission_path)?;
trace.phases.push(MissionLoadPhase::Map); 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 { EngineError::Mission {
path: mission_path.as_str().to_string(), path: mission_path.as_str().to_string(),
source, source,
} }
})?; })?;
let (land_msh_path, land_map_path) = terrain_paths_from_land_path(&land_path)?; let MissionTerrainPaths {
let land_msh_nres = decode_nres(&vfs, &land_msh_path)?; land_msh: land_msh_path,
let land_map_nres = decode_nres(&vfs, &land_map_path)?; land_map: land_map_path,
let land_msh = } = derive_mission_land_paths(&land_path).map_err(|source| EngineError::Path {
decode_land_msh(&land_msh_nres).map_err(|source| EngineError::TerrainFormat { 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(), path: land_msh_path.as_str().to_string(),
source, source,
}
})?; })?;
let land_map = let land_map_nres = decode_nres_payload(read_vfs(&vfs, &land_map_path)?).map_err(|source| {
decode_land_map(&land_map_nres).map_err(|source| EngineError::TerrainFormat { EngineError::Nres {
path: land_map_path.as_str().to_string(), path: land_map_path.as_str().to_string(),
source, 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_path = normalize_engine_path("BuildDat", "BuildDat.lst")?;
let build_dat = read_vfs(&vfs, &build_dat_path)?; let build_dat = read_vfs(&vfs, &build_dat_path)?;
let build_categories = let (terrain, build_categories) =
decode_build_dat(&build_dat).map_err(|source| EngineError::TerrainFormat { 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(), path: build_dat_path.as_str().to_string(),
source, source,
},
TerrainPreparationError::Runtime(source) => EngineError::Terrain(source),
}
})?; })?;
trace.phases.push(MissionLoadPhase::Tma); trace.phases.push(MissionLoadPhase::Tma);
let mission = let mission = decode_mission_payload(mission_bytes, TmaProfile::Strict).map_err(|source| {
decode_tma(mission_bytes, TmaProfile::Strict).map_err(|source| EngineError::Mission { EngineError::Mission {
path: mission_path.as_str().to_string(), path: mission_path.as_str().to_string(),
source, 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 trace.transforms = mission
.objects .objects
.iter() .iter()
@@ -471,6 +521,7 @@ fn load_mission_with_options(
extend_graph_report_with_visual_dependencies( extend_graph_report_with_visual_dependencies(
&repository, &repository,
&mut prototype_report, &mut prototype_report,
&prototype_graph,
&resolved_prototypes, &resolved_prototypes,
); );
if !prototype_report.is_success() { if !prototype_report.is_success() {
@@ -478,6 +529,16 @@ fn load_mission_with_options(
failures: prototype_report.failures.clone(), 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); trace.phases.push(MissionLoadPhase::Assets);
let mut new_runtime_world = new_world(WorldConfig); let mut new_runtime_world = new_world(WorldConfig);
@@ -519,6 +580,7 @@ fn load_mission_with_options(
graph_direct_reference_count: prototype_report.direct_reference_count, graph_direct_reference_count: prototype_report.direct_reference_count,
graph_unit_component_count: prototype_report.unit_component_count, graph_unit_component_count: prototype_report.unit_component_count,
graph_root_count: prototype_report.root_count, graph_root_count: prototype_report.root_count,
asset_visual_count: mission_asset_plan.visual_count,
graph_resolved_count: prototype_report.resolved_count, graph_resolved_count: prototype_report.resolved_count,
graph_mesh_dependency_count: prototype_report.mesh_dependency_count, graph_mesh_dependency_count: prototype_report.mesh_dependency_count,
graph_failure_count: prototype_report.failures.len(), graph_failure_count: prototype_report.failures.len(),
@@ -530,6 +592,10 @@ fn load_mission_with_options(
graph_texture_resolved_count: prototype_report.texture_resolved_count, graph_texture_resolved_count: prototype_report.texture_resolved_count,
graph_lightmap_request_count: prototype_report.lightmap_request_count, graph_lightmap_request_count: prototype_report.lightmap_request_count,
graph_lightmap_resolved_count: prototype_report.lightmap_resolved_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; engine.world = new_runtime_world;
@@ -540,7 +606,8 @@ fn load_mission_with_options(
build_categories, build_categories,
prototype_graph, prototype_graph,
prototype_report, prototype_report,
resolved_prototypes, mission_assets,
asset_plan: mission_asset_plan,
}); });
Ok((summary, trace)) Ok((summary, trace))
} }
@@ -618,13 +685,16 @@ pub fn loaded_prototype_graph_report(engine: &Engine) -> Option<&PrototypeGraphR
engine.loaded.as_ref().map(|state| &state.prototype_report) 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] #[must_use]
pub fn loaded_resolved_prototypes(engine: &Engine) -> Option<&[EffectivePrototype]> { pub fn loaded_mission_asset_plan(engine: &Engine) -> Option<&MissionAssetPlan> {
engine engine.loaded.as_ref().map(|state| &state.asset_plan)
.loaded }
.as_ref()
.map(|state| state.resolved_prototypes.as_slice()) /// 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)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -716,49 +786,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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+19
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Terrain disk format primitives. //! Terrain disk format primitives.
use fparkan_binary::{checked_count_bytes, Cursor, DecodeError}; use fparkan_binary::{checked_count_bytes, Cursor, DecodeError};
+37 -15
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Validated terrain runtime queries. //! Validated terrain runtime queries.
use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument}; use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument};
@@ -524,26 +543,29 @@ struct RuntimeGrid {
impl RuntimeGrid { impl RuntimeGrid {
fn from_land_map(map: &LandMapDocument) -> Result<Self, TerrainError> { fn from_land_map(map: &LandMapDocument) -> Result<Self, TerrainError> {
let mut min = [f32::INFINITY, f32::INFINITY]; let mut bounds_min = [f32::INFINITY, f32::INFINITY];
let mut max = [f32::NEG_INFINITY, f32::NEG_INFINITY]; let mut bounds_max = [f32::NEG_INFINITY, f32::NEG_INFINITY];
for areal in &map.areals { for areal in &map.areals {
for vertex in &areal.vertices { for vertex in &areal.vertices {
min[0] = min[0].min(vertex[0]); bounds_min[0] = bounds_min[0].min(vertex[0]);
min[1] = min[1].min(vertex[2]); bounds_min[1] = bounds_min[1].min(vertex[2]);
max[0] = max[0].max(vertex[0]); bounds_max[0] = bounds_max[0].max(vertex[0]);
max[1] = max[1].max(vertex[2]); bounds_max[1] = bounds_max[1].max(vertex[2]);
} }
} }
if !min[0].is_finite() || !min[1].is_finite() || !max[0].is_finite() || !max[1].is_finite() if !bounds_min[0].is_finite()
|| !bounds_min[1].is_finite()
|| !bounds_max[0].is_finite()
|| !bounds_max[1].is_finite()
{ {
min = [0.0, 0.0]; bounds_min = [0.0, 0.0];
max = [1.0, 1.0]; bounds_max = [1.0, 1.0];
} }
if (min[0] - max[0]).abs() <= f32::EPSILON { if (bounds_min[0] - bounds_max[0]).abs() <= f32::EPSILON {
max[0] += 1.0; bounds_max[0] += 1.0;
} }
if (min[1] - max[1]).abs() <= f32::EPSILON { if (bounds_min[1] - bounds_max[1]).abs() <= f32::EPSILON {
max[1] += 1.0; bounds_max[1] += 1.0;
} }
let mut cells = Vec::with_capacity(map.grid.cells.len()); let mut cells = Vec::with_capacity(map.grid.cells.len());
@@ -568,8 +590,8 @@ impl RuntimeGrid {
Ok(Self { Ok(Self {
cells_x: map.grid.cells_x, cells_x: map.grid.cells_x,
cells_y: map.grid.cells_y, cells_y: map.grid.cells_y,
min, min: bounds_min,
max, max: bounds_max,
cells, cells,
}) })
} }
+19
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Dev-only synthetic builders and fake ports. //! Dev-only synthetic builders and fake ports.
use fparkan_render::{FrameOutput, RenderBackend, RenderCommandList, RenderError}; use fparkan_render::{FrameOutput, RenderBackend, RenderCommandList, RenderError};
+19
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Stage-3 Texm texture contract. //! Stage-3 Texm texture contract.
use std::sync::Arc; use std::sync::Arc;
+130 -22
View File
@@ -1,10 +1,33 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Virtual filesystem ports for resource loading. //! Virtual filesystem ports for resource loading.
use fparkan_binary::{sha256, Sha256Digest}; use fparkan_binary::{sha256, Sha256Digest};
use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath}; use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fs; use std::fs;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
#[cfg(windows)]
use std::os::windows::fs::MetadataExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::SystemTime; use std::time::SystemTime;
@@ -110,6 +133,7 @@ impl DirectoryVfs {
struct CachedHostFingerprint { struct CachedHostFingerprint {
len: u64, len: u64,
modified: Option<SystemTime>, modified: Option<SystemTime>,
identity: Option<u64>,
fingerprint: Sha256Digest, fingerprint: Sha256Digest,
} }
@@ -120,14 +144,23 @@ impl Vfs for DirectoryVfs {
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> { fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
let host = self.host_path(path)?; let host = self.host_path(path)?;
if fs::symlink_metadata(&host) let pre_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
.map_err(VfsError::Io)? if pre_metadata.file_type().is_symlink() || !pre_metadata.is_file() {
.file_type() return Err(VfsError::Path);
.is_symlink() }
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); return Err(VfsError::Path);
} }
let bytes = fs::read(host).map_err(VfsError::Io)?;
Ok(Arc::from(bytes.into_boxed_slice())) Ok(Arc::from(bytes.into_boxed_slice()))
} }
@@ -248,7 +281,11 @@ fn metadata_from_host_file_with_cache(
.map_err(|_| VfsError::Path)? .map_err(|_| VfsError::Path)?
.get(path) .get(path)
.cloned() .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 { return Ok(VfsMetadata {
len, len,
@@ -266,6 +303,7 @@ fn metadata_from_host_file_with_cache(
CachedHostFingerprint { CachedHostFingerprint {
len, len,
modified, modified,
identity: file_identity(metadata),
fingerprint, fingerprint,
}, },
); );
@@ -275,15 +313,15 @@ fn metadata_from_host_file_with_cache(
/// In-memory VFS. /// In-memory VFS.
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct MemoryVfs { pub struct MemoryVfs {
files: BTreeMap<String, Arc<[u8]>>, files: BTreeMap<Vec<u8>, Arc<[u8]>>,
lookup: BTreeMap<Vec<u8>, Vec<String>>, lookup: BTreeMap<Vec<u8>, Vec<Vec<u8>>>,
} }
impl MemoryVfs { impl MemoryVfs {
/// Inserts a file. /// Inserts a file.
#[allow(clippy::needless_pass_by_value)] #[allow(clippy::needless_pass_by_value)]
pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) { 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.files.insert(path, bytes);
self.rebuild_lookup(); self.rebuild_lookup();
} }
@@ -292,7 +330,7 @@ impl MemoryVfs {
self.lookup.clear(); self.lookup.clear();
for path in self.files.keys() { for path in self.files.keys() {
self.lookup self.lookup
.entry(ascii_lookup_key(path.as_bytes()).0) .entry(ascii_lookup_key(path).0)
.or_default() .or_default()
.push(path.clone()); .push(path.clone());
} }
@@ -301,20 +339,41 @@ impl MemoryVfs {
} }
} }
fn resolve_path(&self, path: &NormalizedPath) -> Result<&str, VfsError> { fn resolve_path(&self, path: &NormalizedPath) -> Result<&[u8], VfsError> {
let key = ascii_lookup_key(path.as_str().as_bytes()).0; let key = ascii_lookup_key(path.as_bytes()).0;
let matches = self let matches = self
.lookup .lookup
.get(&key) .get(&key)
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?; .ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
match matches.as_slice() { match matches.as_slice() {
[single] => Ok(single.as_str()), [single] => Ok(single.as_slice()),
[] => Err(VfsError::NotFound(path.as_str().to_string())), [] => Err(VfsError::NotFound(path.as_str().to_string())),
_ => Err(VfsError::Ambiguous(path.as_str().to_string())), _ => Err(VfsError::Ambiguous(path.as_str().to_string())),
} }
} }
} }
#[cfg(unix)]
#[allow(clippy::unnecessary_wraps)]
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
Some(metadata.dev().rotate_left(32) ^ metadata.ino())
}
#[cfg(windows)]
#[allow(clippy::unnecessary_wraps)]
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 { impl Vfs for MemoryVfs {
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> { fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
let resolved = self.resolve_path(path)?; let resolved = self.resolve_path(path)?;
@@ -339,15 +398,9 @@ impl Vfs for MemoryVfs {
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> { fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
let mut out = Vec::new(); let mut out = Vec::new();
for (path, bytes) in &self.files { for (path, bytes) in &self.files {
if path if has_segment_boundary_prefix_bytes(path, prefix.as_bytes()) {
.as_bytes() let normalized =
.get(..prefix.as_str().len()) fparkan_path::normalize_relative(path, fparkan_path::PathPolicy::StrictLegacy)
.is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_str().as_bytes()))
{
let normalized = fparkan_path::normalize_relative(
path.as_bytes(),
fparkan_path::PathPolicy::StrictLegacy,
)
.map_err(|_| VfsError::Path)?; .map_err(|_| VfsError::Path)?;
out.push(VfsEntry { out.push(VfsEntry {
path: normalized, path: normalized,
@@ -362,6 +415,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. /// Layered VFS with deterministic first-layer precedence.
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct OverlayVfs { pub struct OverlayVfs {
@@ -507,6 +579,22 @@ mod tests {
std::fs::remove_dir_all(root).expect("cleanup"); std::fs::remove_dir_all(root).expect("cleanup");
} }
#[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] #[test]
fn directory_vfs_fingerprint_changes_for_same_length_content() { fn directory_vfs_fingerprint_changes_for_same_length_content() {
let root = unique_test_dir("content-fingerprint"); let root = unique_test_dir("content-fingerprint");
@@ -589,6 +677,26 @@ mod tests {
assert!(matches!(vfs.read(&query), Err(VfsError::Ambiguous(_)))); assert!(matches!(vfs.read(&query), Err(VfsError::Ambiguous(_))));
} }
#[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] #[test]
fn overlay_vfs_uses_first_matching_layer() { fn overlay_vfs_uses_first_matching_layer() {
let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path"); let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path");
+19
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Deterministic world identity, queue, lifecycle, and snapshots. //! Deterministic world identity, queue, lifecycle, and snapshots.
use fparkan_binary::sha256; use fparkan_binary::sha256;
+8 -15
View File
@@ -114,23 +114,16 @@ key, configuration, device profile, initial state, input/time script и верс
## Local evidence requests ## Local evidence requests
На текущем рабочем месте закрыты статические, corpus и headless runtime gates. На текущем рабочем месте закрыты статические, corpus и headless runtime gates.
Для macOS Desktop GL есть только безопасный command/state trace и исторический Для локально воспроизводимого Desktop backend подтверждено только command/state trace
одноразовый offscreen pixel probe: в существующем GL-воркфлоу:
- `cargo test -p fparkan-render-gl --offline desktop_gl33_triangle_command_capture`; - `fixtures/acceptance/macos-gl33-triangle-capture.json`;
- `fixtures/acceptance/macos-gl33-triangle-capture.json`.
`S3-GL-001` не считается закрытым: временный `rustc` probe создал CGL/OpenGL `S3-GL-001` пока не закрыт: текущая evidence не отражает полноценный
offscreen FBO, выполнил shader-based triangle draw, прочитал RGBA pixels и `winit`+`fparkan-render-vulkan` path с real surface/present pipeline.
сохранил hash capture, но постоянный workspace adapter по-прежнему не создаёт Для закрытия требования требуется постоянный workspace-владельческий backend на
SDL window, GL context, GPU resources, shader programs, draw calls или present. `winit`/`fparkan-platform-winit` + `fparkan-render-vulkan` с реальным
Probe не добавляет project-owned `unsafe` в workspace и остаётся только external surface/present pipeline, command/state parity и licensed frame capture.
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.
Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2 Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2
backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же
+7 -5
View File
@@ -3,11 +3,13 @@
Baseline command: Baseline command:
```text ```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`; - canonical pipeline now uses a fixed MSRV/toolchain, policy checks,
- doctests fail in this shell because `rustdoc` is not in PATH unless `RUSTDOC` is also set to the real toolchain binary; full-format workspace test command, `clippy`/`doc`/`cargo deny` gates and
- full online dependency resolution is unavailable in the sandbox. typed manifest parsing in `xtask`;
- `rpath`/offline mode is still useful for synthetic local checks;
- full online dependency resolution remains unavailable in the sandbox.
+3 -3
View File
@@ -34,7 +34,7 @@ behavior unit state machines, target and path requests
physics control systems, collision proxies and contacts physics control systems, collision proxies and contacts
animation pose sampling, hierarchy and blending animation pose sampling, hierarchy and blending
audio sample cache, sources, listener and buses 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 network game message schema plus transport adapters
tools validators, extractors, viewers, captures and editors tools validators, extractors, viewers, captures and editors
``` ```
@@ -103,8 +103,8 @@ CPU assets и GPU resources имеют отдельные бюджеты и от
### Backend adapters ### Backend adapters
Render, audio, input и network получают отдельные adapters. Legacy compatibility Render, audio, input и network получают отдельные adapters. Compatibility state
state живёт выше Vulkan, D3D11 или Metal backend; DirectPlay compatibility живёт живёт вне Vulkan, D3D11 или Metal backend; DirectPlay compatibility живёт
отдельно от modern transport. Так можно заменить платформу, не меняя форматы, отдельно от modern transport. Так можно заменить платформу, не меняя форматы,
игровую семантику и regression corpus. игровую семантику и regression corpus.
+47 -3
View File
@@ -7,10 +7,15 @@ L0-P2-001 covered cargo test -p fparkan-corpus --offline licensed_part2_manifest
L0-P2-002 covered cargo test -p fparkan-corpus --offline licensed_part2_has_no_casefold_relative_path_collisions L0-P2-002 covered cargo test -p fparkan-corpus --offline licensed_part2_has_no_casefold_relative_path_collisions
S0-ARCH-001 covered cargo xtask policy runs cargo metadata --offline --no-deps successfully S0-ARCH-001 covered cargo xtask policy runs cargo metadata --offline --no-deps successfully
S0-ARCH-002 covered cargo xtask policy rejects forbidden GUI/adapter dependencies from domain crates S0-ARCH-002 covered cargo xtask policy rejects forbidden GUI/adapter dependencies from domain crates
S0-ARCH-003 covered cargo xtask policy rejects platform/render adapter dependencies from fparkan-headless S0-ARCH-003 covered cargo xtask policy rejects platform/render adapter dependencies from the transitive fparkan-headless workspace manifest closure
S0-ARCH-004 covered cargo xtask policy scans workspace-owned Rust/TOML for unsafe constructs and workspace lints forbid unsafe_code S0-ARCH-004 covered cargo xtask policy scans workspace-owned Rust/TOML for unsafe constructs and workspace lints forbid unsafe_code
S0-ARCH-005 covered cargo xtask policy rejects Python source files, Python shebangs, and Python CI workflow steps while allowing docs requirements.txt S0-ARCH-005 covered cargo xtask policy rejects Python source files, Python shebangs, and Python CI workflow steps while allowing docs requirements.txt
S0-ARCH-006 covered cargo xtask policy rejects non-fparkan package directories under crates/ S0-ARCH-006 covered cargo xtask policy rejects non-fparkan package directories under crates/
S0-ARCH-007 covered cargo xtask ci runs fmt, policy, workspace test, clippy, rustdoc warnings, cargo-deny or built-in supply-chain fallback, and strict acceptance audit
S0-ARCH-008 covered cargo xtask policy rejects moving Rust toolchains and workspace rust-version drift
S0-ARCH-009 covered .github/workflows/ci.yml runs a pinned MSRV backend-neutral crate job
S0-ARCH-010 covered cargo xtask acceptance audit emits commit_sha, rust_toolchain, and msrv metadata into the JSON artifact
S0-ARCH-011 blocked cargo run -p fparkan-vulkan-smoke emits explicit per-platform blocked artifacts until real Vulkan 300-frame validation=0 runner is available
S0-DIAG-001 covered cargo test -p fparkan-diagnostics --offline diagnostic_chain_preserves_context S0-DIAG-001 covered cargo test -p fparkan-diagnostics --offline diagnostic_chain_preserves_context
S0-DIAG-002 covered cargo test -p fparkan-diagnostics --offline json_is_stable S0-DIAG-002 covered cargo test -p fparkan-diagnostics --offline json_is_stable
S0-CORPUS-001 covered cargo test -p fparkan-corpus --offline deterministic_traversal_is_creation_order_independent S0-CORPUS-001 covered cargo test -p fparkan-corpus --offline deterministic_traversal_is_creation_order_independent
@@ -21,7 +26,44 @@ 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-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-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-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-PLAT-001 covered cargo test -p fparkan-platform-winit --offline window_port_reports_default_request_profile
S0-PLAT-002 covered cargo clippy -p fparkan-platform -p fparkan-platform-winit --all-targets --all-features --locked -- -D warnings
S0-PLAT-003 covered cargo test -p fparkan-platform-winit --offline smoke_window_plan_requires_native_handles_and_nonzero_extent smoke_window_plan_rejects_zero_extent
S0-PLAT-004 covered cargo test -p fparkan-platform-winit --offline smoke_window_app_requires_created_native_window smoke_window_app_rejects_synthetic_window_without_native_handles
S0-VK-001 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents
S0-VK-002 covered cargo test -p fparkan-render-vulkan --offline device_scoring_is_deterministic_and_prefers_discrete_unified_queue
S0-VK-003 covered cargo test -p fparkan-render-vulkan --offline portability_subset_is_reported_and_enabled_when_exposed
S0-VK-004 covered cargo test -p fparkan-render-vulkan --offline rejects_missing_graphics_present_swapchain_and_format
S0-VK-005 covered cargo test -p fparkan-render-vulkan --offline capability_report_json_is_stable
S0-VK-006 covered cargo test -p fparkan-render-vulkan --offline loader_probe_report_json_is_stable
S0-VK-007 covered cargo xtask policy
S0-VK-008 covered cargo test -p fparkan-render-vulkan --offline instance_plan_is_sorted_deduplicated_and_portability_aware
S0-VK-009 covered cargo test -p fparkan-render-vulkan --offline instance_plan_adds_portability_extension_when_requested
S0-VK-010 covered cargo test -p fparkan-render-vulkan --offline invalid_instance_extension_name_is_reported_before_loader_use
S0-VK-011 covered cargo test -p fparkan-render-vulkan --offline surface_plan_requires_native_handles
S0-VK-012 covered cargo test -p fparkan-render-vulkan --offline surface_plan_json_is_stable
S0-VK-013 covered cargo test -p fparkan-render-vulkan --offline static_surface_extension_name_is_decoded
S0-VK-014 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_prefers_srgb_mailbox_and_clamps_extent
S0-VK-015 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_uses_fifo_and_current_extent_fallbacks
S0-VK-016 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_rejects_missing_surface_data_and_empty_extent
S0-VK-017 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_json_and_recreation_reports_are_stable
S0-VK-018 covered cargo test -p fparkan-render-vulkan --offline triangle_shader_manifest_hashes_are_stable
S0-VK-019 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_report_json_is_stable
S0-VK-020 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_rejects_invalid_spirv_containers
S0-VK-021 covered cargo test -p fparkan-render-vulkan --offline frame_submission_plan_json_is_stable
S0-VK-022 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents
S0-VK-023 covered cargo test -p fparkan-vulkan-smoke --offline rejects_false_pass_without_full_evidence blocked_report_includes_shader_manifest_and_bootstrap_status
S0-VK-024 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_loader_probe formats_vulkan_api_version
S0-VK-025 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_instance_probe parses_instance_probe_as_loader_probe
S0-VK-026 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_window_probe rejects_passed_without_surface_probe parses_surface_probe_as_instance_probe
S0-VK-027 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_swapchain_recreation blocked_report_includes_shader_manifest_and_bootstrap_status
S0-VK-028 covered cargo test -p fparkan-vulkan-smoke --offline reports_rustc_host_triple blocked_report_includes_shader_manifest_and_bootstrap_status
S0-VK-029 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_three_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
S0-VK-030 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_with_failed_surface
S0-VK-031 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_selected_device
S0-VK-032 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_swapchain
S0-VK-033 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_logical_device
S0-VK-034 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_three_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow 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 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 L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
@@ -71,6 +113,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-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-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-PATH-008 covered cargo test -p fparkan-path --offline original_separators_and_raw_bytes_are_preserved
S1-PATH-009 covered cargo test -p fparkan-path --offline accepts_non_utf8_legacy_bytes
S1-VFS-005 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-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-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 S1-RSLI-003 covered cargo test -p fparkan-rsli --offline rejects_entry_table_bounds
@@ -222,7 +266,7 @@ S3-RENDER-008 covered cargo test -p fparkan-render --offline recording_backend_s
S3-RENDER-009 covered cargo xtask policy 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-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-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 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-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 S4-ANIM-002 covered cargo test -p fparkan-animation --offline frame_map_decodes_u16_and_uses_attr_frame_count
1 # Acceptance coverage manifest.
7 L0-P2-002
8 S0-ARCH-001
9 S0-ARCH-002
10 S0-ARCH-003
11 S0-ARCH-004
12 S0-ARCH-005
13 S0-ARCH-006
14 S0-ARCH-007
15 S0-ARCH-008
16 S0-ARCH-009
17 S0-ARCH-010
18 S0-ARCH-011
19 S0-DIAG-001
20 S0-DIAG-002
21 S0-CORPUS-001
26 S0-CORPUS-006
27 S0-CLI-001
28 S0-CLI-002
29 S0-GL-001 S0-PLAT-001
30 S0-PLAT-002
31 S0-PLAT-003
32 S0-PLAT-004
33 S0-VK-001
34 S0-VK-002
35 S0-VK-003
36 S0-VK-004
37 S0-VK-005
38 S0-VK-006
39 S0-VK-007
40 S0-VK-008
41 S0-VK-009
42 S0-VK-010
43 S0-VK-011
44 S0-VK-012
45 S0-VK-013
46 S0-VK-014
47 S0-VK-015
48 S0-VK-016
49 S0-VK-017
50 S0-VK-018
51 S0-VK-019
52 S0-VK-020
53 S0-VK-021
54 S0-VK-022
55 S0-VK-023
56 S0-VK-024
57 S0-VK-025
58 S0-VK-026
59 S0-VK-027
60 S0-VK-028
61 S0-VK-029
62 S0-VK-030
63 S0-VK-031
64 S0-VK-032
65 S0-VK-033
66 S0-VK-034
67 S0-LIMIT-001
68 S0-LIMIT-002
69 L1-P1-NRES-001
113 S1-PATH-006
114 S1-PATH-007
115 S1-PATH-008
116 S1-PATH-009
117 S1-VFS-005
118 S1-RSLI-001
119 S1-RSLI-002
120 S1-RSLI-003
266 S3-RENDER-009
267 S3-GL-001
268 S3-GL-002
269 S3-GL-003
270 S3-VIEWER-001
271 S4-ANIM-001
272 S4-ANIM-002
+409
View File
@@ -0,0 +1,409 @@
# 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-ARCH-007`
`S0-ARCH-008`
`S0-ARCH-009`
`S0-ARCH-010`
`S0-ARCH-011`
`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-PLAT-001`
`S0-PLAT-002`
`S0-PLAT-003`
`S0-PLAT-004`
`S0-VK-001`
`S0-VK-002`
`S0-VK-003`
`S0-VK-004`
`S0-VK-005`
`S0-VK-006`
`S0-VK-007`
`S0-VK-008`
`S0-VK-009`
`S0-VK-010`
`S0-VK-011`
`S0-VK-012`
`S0-VK-013`
`S0-VK-014`
`S0-VK-015`
`S0-VK-016`
`S0-VK-017`
`S0-VK-018`
`S0-VK-019`
`S0-VK-020`
`S0-VK-021`
`S0-VK-022`
`S0-VK-023`
`S0-VK-024`
`S0-VK-025`
`S0-VK-026`
`S0-VK-027`
`S0-VK-028`
`S0-VK-029`
`S0-VK-030`
`S0-VK-031`
`S0-VK-032`
`S0-VK-033`
`S0-VK-034`
`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-PATH-009`
`S1-VFS-005`
`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`
+643
View File
@@ -0,0 +1,643 @@
# FParkan — аудит Stage 0 и план полного закрытия
**Проект:** `valentineus/fparkan`
**Проверенная ветка:** `devel` GitHub-зеркала
**Дата аудита:** 23 июня 2026 года
**Область:** только Stage 0 — Governance, reproducibility и Vulkan foundation
**Метод:** статический архитектурный и кодовый аудит
**Сборка и исполнение:** не выполнялись; `cargo build`, `cargo test`, Vulkan smoke и validation jobs не запускались
---
## 1. Итоговый вердикт
**Stage 0 не закрыт и находится в статусе `BLOCKED`.**
Главный критерий Stage 0 — воспроизводимый репозиторий и минимальный настоящий Vulkan vertical slice на Windows, Linux и macOS. В проверенном состоянии:
- отсутствует `fparkan-platform-winit`;
- отсутствует `fparkan-render-vulkan`;
- отсутствуют Vulkan instance/device/surface/swapchain;
- `fparkan-game` использует `RecordingBackend`, а не GPU backend;
- workspace по-прежнему содержит SDL/OpenGL stub adapters;
- Rust toolchain закреплён только как изменяемый канал `stable`;
- `cargo xtask ci` не реализует полный канонический gate;
- нет подтверждённых артефактов Windows/Linux/macOS smoke jobs.
### Сводная оценка
| Группа требований | Статус | Основной блокер |
|---|---|---|
| Reproducibility и toolchain | **FAIL** | Toolchain не закреплён точной версией, MSRV не объявлен |
| Repository policy и CI | **FAIL** | Неполные fmt/test/clippy/doc/security gates |
| Platform abstraction | **FAIL** | Core API содержит OpenGL-specific contract; `winit` adapter отсутствует |
| Vulkan backend | **FAIL** | Нет Vulkan loader/device/surface/swapchain/pipeline |
| macOS portability | **FAIL** | Нет MoltenVK integration и portability handling |
| Offline shaders | **FAIL** | Нет SPIR-V build/validation/hash pipeline |
| Legacy cleanup | **FAIL** | SDL/GL stubs остаются workspace members |
| Headless isolation | **PASS на manifest-level** | Автоматическое доказательство dependency closure ещё требуется |
| Native acceptance | **FAIL / NOT RUNNABLE** | Нет реального backend и platform artifacts |
Stage 0 можно объявить закрытым только после прохождения реального Vulkan smoke на всех трёх системах и публикации machine-readable артефактов.
---
## 2. Область и ограничения аудита
Канонические требования взяты из документа:
- «План реализации stage 05: Vulkan revision»;
- <https://app.notion.com/p/387e79f2db3981778f94cdf34db5f93f>.
Проверялась ветка:
- <https://github.com/valentineus/fparkan/tree/devel>.
Ограничения:
1. Ветка `devel` является движущейся ссылкой. Следующий formal audit следует выполнять на закреплённом commit SHA или tag.
2. README указывает self-hosted repository как primary. Его закрытые CI runners и artifacts не были доступны.
3. Код не собирался и не запускался по условию аудита.
4. Vulkan runtime, validation layers, MoltenVK и native window creation не проверялись динамически.
5. Статический анализ достаточен для определения текущих архитектурных блокеров: требуемых adapters и зависимостей в workspace нет.
---
## 3. Матрица требований Stage 0
| Требование | Статус | Текущее состояние | Необходимо для закрытия |
|---|---|---|---|
| Exact stable Rust toolchain | **FAIL** | `rust-toolchain.toml`: `channel = "stable"` | Закрепить точную версию, например `1.xx.y` |
| Объявленный MSRV | **FAIL** | `workspace.package.rust-version` отсутствует | Добавить `rust-version` и отдельный MSRV job |
| Полный `cargo xtask ci` | **FAIL** | Есть custom rustfmt, policy, workspace test и clippy | Добавить канонические fmt/test/clippy/doc/security gates |
| `--all-targets --all-features` | **FAIL** | Не используются текущим `ci` | Добавить к test/clippy/doc gates |
| Clippy `-D warnings` | **FAIL** | Явно не передаётся | Сделать предупреждения blocking |
| Rustdoc broken-link gate | **FAIL** | Отсутствует | Добавить `RUSTDOCFLAGS=-D warnings -D rustdoc::broken_intra_doc_links` |
| License/advisory/source policy | **PARTIAL / UNVERIFIED** | Есть custom policy и GPL workspace license | Подключить `cargo-deny` или эквивалент и хранить versioned policy |
| Typed TOML parsing | **FAIL** | Licensed manifest разбирается вручную построчно | `serde` + TOML schema + `deny_unknown_fields` |
| `cargo_metadata` policy | **FAIL** | Dependency rules не опираются на typed Cargo graph | Добавить `cargo_metadata` и package-ID based checks |
| CI matrix Windows/Linux/macOS | **UNVERIFIED / BLOCKER** | Доступных platform artifacts нет | Создать native matrix и сохранять reports |
| Backend-neutral platform API | **FAIL** | В core есть `GraphicsProfile`, GL/GLES versions и `WindowPort::present()` | Удалить GL context concepts; present перенести в renderer |
| `fparkan-platform-winit` | **FAIL** | В workspace только SDL-named stub | Реализовать настоящий event loop/window adapter |
| `fparkan-render-vulkan` | **FAIL** | В workspace только GL-named recording stub | Реализовать настоящий Vulkan backend |
| Vulkan loader/instance/device | **FAIL** | Vulkan bindings отсутствуют | Добавить `ash`, instance, device selection, queues |
| Surface/swapchain/present | **FAIL** | Отсутствуют | Реализовать platform surface и swapchain lifecycle |
| Indexed triangle | **FAIL** | Есть только command capture | Нарисовать реальный indexed triangle |
| Resize/out-of-date/suboptimal | **FAIL** | Swapchain отсутствует | Реализовать полную recreation policy |
| Deterministic capability report | **FAIL** | Device discovery отсутствует | Pure scoring policy + JSON capability report |
| macOS portability | **FAIL** | MoltenVK integration отсутствует | Portability enumeration, subset и packaged MoltenVK |
| Offline SPIR-V pipeline | **FAIL** | GL stub проверяет только synthetic markers | Pinned compiler, validator, descriptor manifest и hashes |
| Legacy adapter removal | **FAIL** | SDL/GL crates входят в workspace | Удалить crates и все references после замены |
| Game/viewer composition | **FAIL** | Game использует `RecordingBackend`; viewer — CLI inspector | Подключить winit + Vulkan только в composition roots |
| Headless isolation | **PASS на manifest-level** | Нет window/Vulkan dependency | Добавить automated Cargo metadata assertion |
| 300 frames + resize + validation=0 | **FAIL** | Невозможно выполнить без backend | Native smoke jobs на трёх OS |
| Negative Vulkan tests | **FAIL** | Нет Vulkan error model | Loader/device/queue/format failure fixtures |
---
## 4. Замечания
### 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 и содержит in-memory stubs;
- GL adapter не зависит от OpenGL и только сохраняет canonical command captures;
- их tests доказывают deterministic stub behavior, а не platform/GPU integration.
Это создаёт ложноположительный сигнал готовности backend-а.
**Рекомендация:**
1. До появления замены пометить crates как `legacy-proof` и исключить из default production composition.
2. Добавить policy, запрещающий приложениям зависеть от них.
3. После подключения `platform-winit` и `render-vulkan` удалить crates, lockfile references, docs и tests.
### S0-B02 — Core platform contract остаётся OpenGL-specific
**Приоритет:** BLOCKER
**Файл:** `crates/fparkan-platform/src/lib.rs`
Проблемы:
- `GraphicsProfile::DesktopCore/Embedded` описывает GL/GLES profile;
- `GraphicsContextRequest` описывает создание GL context;
- `WindowPort::present()` ошибочно закрепляет presentation за window abstraction;
- `PlatformEvent` содержит только `Quit`;
- отсутствуют resize, scale factor, focus, keyboard, mouse, suspend/resume и raw handles;
- `PlatformError::Backend` не содержит source/context.
Для Vulkan окно не выполняет present. Surface, swapchain, image acquisition и queue presentation принадлежат render adapter.
**Рекомендация:** platform crate должен предоставлять только:
- event/lifecycle model;
- physical и logical size;
- scale factor;
- normalized input;
- raw window/display handles;
- structured platform errors.
### S0-B03 — Реального Vulkan code path нет
**Приоритет:** BLOCKER
В inspected manifests отсутствуют `ash`, `ash-window`, `winit` и `raw-window-handle`. Следовательно, текущий код не может создать Vulkan instance/device/surface/swapchain.
`fparkan-game` выполняет backend-neutral capture через `RecordingBackend`. Это полезный CPU oracle, но не Vulkan renderer.
**Definition of fixed:** отдельный smoke executable открывает окно, создаёт Vulkan swapchain, рисует indexed triangle, обрабатывает resize и корректно завершается.
### S0-B04 — `cargo xtask ci` не соответствует exit gate
**Приоритет:** BLOCKER
**Файл:** `xtask/src/main.rs`
Текущий gate не подтверждает:
- все targets и features;
- clippy с `-D warnings`;
- rustdoc warnings и broken links;
- advisory/source policy;
- dependency denylist;
- отсутствие project-owned unsafe вне разрешённого Vulkan boundary;
- корректность typed acceptance manifests;
- platform-native smoke jobs.
Custom recursive rustfmt также может расходиться с canonical `cargo fmt --all -- --check`.
### S0-B05 — Toolchain не воспроизводим
**Приоритет:** BLOCKER
**Файл:** `rust-toolchain.toml`
Канал `stable` изменяется. Один и тот же commit может использовать разные компиляторы в разные дни. MSRV также не объявлен.
**Рекомендация:**
- закрепить точный Rust release;
- указать `rust-version`;
- обновлять toolchain отдельным reviewed PR;
- сохранять toolchain и SDK versions в acceptance report.
### S0-H01 — Нужен изолированный audited unsafe boundary
**Приоритет:** HIGH
`unsafe_code = "forbid"` правильно сохранять для backend-neutral crates. Однако Vulkan FFI требует локальных unsafe calls.
Нельзя ослаблять policy всему workspace.
**Целевая схема:**
- unsafe разрешён только в `fparkan-render-vulkan` low-level modules;
- `unsafe_op_in_unsafe_fn = deny`;
- каждый block имеет `// SAFETY:` comment;
- ownership/lifetime rules документированы;
- raw Vulkan handles не выходят в public neutral API;
- custom policy scanner проверяет allowlist.
### S0-H02 — Neutral render IDs не должны быть GPU allocation IDs
**Приоритет:** HIGH, не блокирует первый hardcoded triangle
**Файл:** `crates/fparkan-render/src/lib.rs`
`GpuMeshId` и `GpuMaterialId` появляются до существования GPU registry. Это смешивает CPU asset identity и backend-local allocation identity.
**Рекомендация:** использовать neutral `MeshAssetId`/`MaterialAssetId`; Vulkan adapter должен самостоятельно отображать их на buffers, images и descriptors.
### S0-M01 — Документация рассогласована с Vulkan revision
**Приоритет:** MEDIUM
`docs/tomes/07-implementation.md` сохраняет старую последовательность и multi-backend формулировки. Parity documentation ссылается на отсутствующий workspace crate, а active parity cases не определены.
**Рекомендация:** один versioned source of truth для stages и автоматическая проверка упомянутых crates, commands и backend names.
---
## 5. Сильные стороны, которые следует сохранить
- Workspace lint policy строгая и подходит для backend-neutral crates.
- `Cargo.lock` присутствует, а команды используют `--locked`.
- Synthetic и licensed corpus paths концептуально разделены.
- `fparkan-headless` не зависит от platform/render adapters на manifest-level.
- `fparkan-render` уже предоставляет deterministic command ordering, validation и canonical capture.
- Composition roots отделены от большинства core crates.
Эти элементы позволяют построить Vulkan foundation без переписывания CPU/data foundation.
---
## 6. Целевая архитектура Stage 0
```text
apps/fparkan-game, apps/fparkan-viewer
├── fparkan-platform-winit
│ └── winit + raw-window-handle
└── fparkan-render-vulkan
├── ash-window
├── ash
├── surface / swapchain
├── device / queues
├── shaders / pipelines
└── synchronization / presentation
apps/fparkan-headless
└── runtime/core only
no winit, ash, MoltenVK or window dependencies
```
Разделение ответственности:
- `fparkan-platform`: события, input, lifecycle, sizes и handle access;
- `fparkan-platform-winit`: concrete window/event-loop implementation;
- `fparkan-render`: backend-neutral command/snapshot contracts;
- `fparkan-render-vulkan`: Vulkan resources, synchronization и present;
- game/viewer: composition root;
- headless: полностью изолированный путь.
---
## 7. План полного закрытия Stage 0
Порядок PR важен. Vulkan adapter не следует строить поверх текущего GL-oriented platform contract.
### PR S0-01 — Reproducible toolchain и metadata
**Изменения**
- закрепить exact Rust toolchain;
- добавить `workspace.package.rust-version`;
- зафиксировать supported triples;
- добавить `cargo xtask doctor`;
- включать commit SHA, Rust version и platform SDK versions в reports.
**Acceptance**
- clean checkout формирует одинаковый metadata report;
- MSRV job собирает backend-neutral crates;
- pinned toolchain проходит полный synthetic gate.
### PR S0-02 — Typed xtask configuration
**Изменения**
- `serde` + TOML schemas для corpus/acceptance manifests;
- `deny_unknown_fields`;
- duplicate/missing/unknown-field validation;
- absolute canonical paths для local licensed manifest;
- `cargo_metadata` для dependency и workspace policy;
- удалить ручной line parser.
**Acceptance**
- malformed manifest всегда даёт non-zero exit;
- неизвестные поля не игнорируются;
- dependency policy работает по Cargo package IDs, targets и features.
### PR S0-03 — Полный synthetic CI gate
Обязательные команды:
```bash
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
```
Добавить reports для каждого gate и запрет silent skip.
### PR S0-04 — Redesign `fparkan-platform`
**Изменения**
- удалить `GraphicsProfile`, `GraphicsContextRequest` и GL version negotiation;
- убрать `present()` из window port;
- добавить normalized keyboard/mouse events;
- physical/logical size и scale factor;
- focus, minimize, occlusion, suspend/resume;
- deterministic lifecycle state machine;
- structured errors с source chain.
**Synthetic tests**
- resize coalescing;
- zero-size/minimized window;
- scale-factor changes;
- focus loss clears held input;
- key repeat и modifiers;
- suspend/resume;
- deterministic event ordering.
### PR S0-05 — `fparkan-platform-winit`
**Изменения**
- winit event loop;
- native window lifecycle;
- raw window/display handles;
- platform-specific event normalization;
- отсутствие GPU ownership.
**Acceptance**
- window-only smoke на Windows, Linux и macOS;
- native event trace соответствует synthetic model.
### PR S0-06 — Vulkan low-level boundary
**Изменения**
- `ash` и `ash-window`;
- dynamic Vulkan loader;
- instance и debug messenger;
- physical device capability records;
- pure deterministic device scoring;
- graphics/present queue selection;
- deterministic capability JSON;
- audited unsafe allowlist.
**Negative tests**
- loader отсутствует;
- Vulkan 1.1 недоступен;
- graphics queue отсутствует;
- present queue отсутствует;
- `VK_KHR_swapchain` отсутствует;
- required surface format отсутствует.
### PR S0-07 — Swapchain, triangle и offline shaders
**Изменения**
- surface и swapchain;
- format/present-mode/image-count policy;
- render pass и graphics pipeline;
- indexed triangle;
- command pools/buffers;
- binary semaphores и fences;
- frames in flight;
- resize/out-of-date/suboptimal/zero extent handling;
- pinned offline shader compiler;
- SPIR-V validation;
- descriptor/push-constant manifest;
- shader content hashes.
### PR S0-08 — macOS portability proof
**Изменения**
- `VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR`;
- portability extension enumeration;
- `VK_KHR_portability_subset` enablement, если объявлен device;
- MoltenVK packaging strategy;
- deterministic portability report;
- `.app` bundle smoke.
### PR S0-09 — Composition roots и legacy removal
**Изменения**
- game/viewer подключают winit + Vulkan adapters;
- headless остаётся без window/GPU graph;
- удалить SDL/GL stub crates;
- очистить lockfile, policy и docs;
- заменить GPU-named neutral IDs на asset IDs;
- запретить stale backend names automated policy check-ом.
### PR S0-10 — Native acceptance matrix
**Jobs**
- Windows MSVC + system Vulkan loader;
- Linux X11 или Wayland surface;
- macOS Apple Silicon + MoltenVK;
- отдельный software-Vulkan Linux PR job допустим как быстрый gate;
- native GPU jobs остаются release evidence.
**Обязательный сценарий**
1. Создать окно.
2. Создать real Vulkan swapchain.
3. Показать indexed triangle.
4. Выполнить не менее 300 frames.
5. Изменить размер окна.
6. Пересоздать swapchain.
7. Корректно завершить event loop.
8. Получить `validation_error_count = 0`.
9. Сохранить capability, shader и validation reports как artifacts.
**Stage 0 закрывается только после merge S0-01…S0-10 и зелёных native artifacts.**
---
## 8. Требуемая CI/acceptance модель
### 8.1 Synthetic PR gate
Должен работать без игровых каталогов и без silent skip:
1. fmt, clippy, docs, security и policy;
2. все unit/integration tests;
3. platform lifecycle state-machine tests;
4. device scoring tests на synthetic capability records;
5. swapchain policy tests;
6. shader manifest/hash tests;
7. Vulkan negative-path tests без обязательного GPU;
8. headless dependency assertion;
9. report schema validation.
Tests, требующие native GPU или licensed data, должны иметь отдельные suites и machine-readable ownership/reason, а не оставаться обычными `#[ignore]` без evidence trail.
### 8.2 Native platform gate
| Platform | Минимальный gate | Дополнительное evidence |
|---|---|---|
| Windows | system loader, swapchain, triangle, resize, 300 frames, validation=0 | Периодическая NVIDIA/AMD/Intel coverage |
| Linux | X11 или Wayland surface, swapchain, resize, validation=0 | Software Vulkan PR job + Mesa/NVIDIA native release jobs |
| macOS | MoltenVK, portability enumeration/subset, CAMetalLayer surface, resize, validation=0 | Apple Silicon как primary target |
### 8.3 Формат machine-readable отчёта
Минимальные поля:
```json
{
"schema": 1,
"commit": "<sha>",
"target": "x86_64-pc-windows-msvc",
"rustc": "1.xx.y",
"vulkan_api": "1.1",
"device_name": "...",
"driver": "...",
"portability_subset": false,
"frames": 300,
"resize_count": 1,
"swapchain_recreate_count": 1,
"validation_error_count": 0,
"shader_manifest_hash": "...",
"result": "pass"
}
```
---
## 9. Definition of Done
Stage 0 считается закрытым, когда выполнены **все** пункты:
- [ ] Exact Rust toolchain закреплён.
- [ ] MSRV объявлен и проверяется.
- [ ] Full fmt/test/clippy/doc/security/source/license gate проходит.
- [ ] Typed TOML manifests используются.
- [ ] Dependency policy работает через `cargo_metadata`.
- [ ] Windows/Linux/macOS matrix сохраняет artifacts.
- [ ] `fparkan-platform` больше не содержит GL-specific context concepts.
- [ ] `fparkan-platform-winit` реализован.
- [ ] `fparkan-render-vulkan` реализован.
- [ ] Vulkan 1.1 instance/device/queues/surface/swapchain реализованы.
- [ ] Deterministic device scoring и capability report реализованы.
- [ ] Indexed triangle рисуется настоящим Vulkan backend.
- [ ] Resize, zero extent, out-of-date и suboptimal обработаны.
- [ ] MoltenVK portability path реализован.
- [ ] Offline SPIR-V validation и hash manifest реализованы.
- [ ] Unsafe разрешён только в audited Vulkan/FFI modules.
- [ ] Legacy SDL/GL adapters и references удалены.
- [ ] Game/viewer используют новые composition adapters.
- [ ] Headless dependency graph не содержит winit/Vulkan/MoltenVK.
- [ ] 300-frame + resize smoke проходит на трёх OS.
- [ ] Validation error count равен нулю на трёх OS.
- [ ] Acceptance reports включают commit SHA и сохраняются как artifacts.
Наличие crates или unit tests с соответствующими названиями само по себе не является закрытием Stage 0.
---
## 10. Рекомендуемые automated policy checks
Добавить в `cargo xtask policy`:
### Workspace denylist
- запрещены `fparkan-platform-sdl` и `fparkan-render-gl` после миграции;
- запрещены stale symbols `GraphicsProfile`, `DesktopCore`, `Embedded`, `Gles2` в canonical platform/render API;
- canonical docs не содержат OpenGL как production backend.
### Dependency rules
- headless не зависит от `winit`, `raw-window-handle`, `ash`, `ash-window` или Vulkan adapter;
- backend-neutral crates не зависят от concrete platform/render adapters;
- только composition roots связывают platform и renderer;
- raw Vulkan types не экспортируются из adapter public boundary.
### Unsafe rules
- project-owned unsafe разрешён только в exact allowlisted files/modules;
- каждый block содержит `SAFETY:`;
- `unsafe_op_in_unsafe_fn` запрещён;
- изменение allowlist требует отдельного reviewed diff.
### Test и report rules
- synthetic gate не получает licensed paths;
- ignored tests обязаны иметь registered reason и owner;
- acceptance IDs уникальны;
- reports проходят schema validation;
- report всегда содержит commit SHA и target triple.
### Documentation rules
- документированные crates и commands существуют;
- canonical stage version совпадает с acceptance schema;
- старые backend names отсутствуют;
- README не объявляет незакрытый Vulkan path реализованным.
---
## 11. Основные риски
| Риск | Последствие | Снижение |
|---|---|---|
| Vulkan adapter начнут до redesign platform API | Повторная переделка surface/lifecycle/present | Сначала S0-04, затем S0-05/S0-06 |
| `unsafe_code` ослабят всему workspace | Рост FFI и lifetime рисков | Изолированный audited adapter и allowlist scanner |
| Stubs будут приняты за production backend | Ложное закрытие Stage 0 | Удаление legacy crates и real native smoke |
| Linux software Vulkan будет единственным evidence | Не выявятся vendor-driver проблемы | Native Mesa/NVIDIA jobs перед release |
| macOS будет проверен без portability subset report | Скрытая несовместимость MoltenVK | Обязательное capability evidence |
| Shader compiler останется неприкреплённым | Невоспроизводимый SPIR-V | Pinned compiler + manifest hashes |
| GitHub mirror и primary repository разойдутся | Audit и release относятся к разному коду | Commit SHA, canonical remote и artifact metadata |
| Документация останется отдельным source of truth | Повторное рассогласование | Versioned stage schema и automated doc checks |
---
## 12. Реестр доказательств
### Canonical requirement
- 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>
### Platform и render
- 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>
### Documentation drift
- 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>
---
## 13. Финальное заключение
У проекта уже имеется пригодный backend-neutral фундамент: deterministic render commands, строгие neutral-crate lints, отдельный headless composition root и разделение synthetic/licensed tests. Однако Stage 0 пока представлен интерфейсными proof/stub crates, а не настоящим Vulkan vertical slice.
Критический путь:
```text
reproducible toolchain
→ complete CI/policy gate
→ backend-neutral platform redesign
→ winit adapter
→ Vulkan loader/device/surface/swapchain
→ indexed triangle + shaders + synchronization
→ MoltenVK portability
→ composition integration
→ legacy removal
→ three-platform acceptance artifacts
```
До прохождения этого пути рекомендуемый статус:
```text
Stage 0: IN PROGRESS / BLOCKED
```
Главный критерий закрытия:
> Stage 0 завершён не тогда, когда существуют crates с названиями `winit` и `vulkan`, а когда один закреплённый commit создаёт настоящий Vulkan swapchain, рисует triangle, переживает resize и завершается без validation errors на Windows, Linux и macOS, сохраняя воспроизводимые machine-readable artifacts.
+11 -8
View File
@@ -1,20 +1,23 @@
# Render Parity Dataset # 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. - `cases.toml`: list of deterministic render cases.
- `reference/*.png`: baseline frames captured from the original renderer. - `reference/*.png`: baseline frames captured from the original renderer.
Expected workflow: 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`. 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 ```text
cargo run -p render-parity -- \ 1) Prepare `cases.toml` and baseline captures.
--manifest parity/cases.toml \ 2) Run `fparkan-game` (or dedicated acceptance runner) with fixed seed.
--output-dir target/render-parity/current 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.
+8 -2
View File
@@ -1,3 +1,9 @@
[toolchain] [toolchain]
channel = "stable" channel = "1.87.0"
components = ["clippy", "rustfmt"] components = ["clippy", "rustfmt", "rust-docs"]
targets = [
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
"aarch64-apple-darwin",
"x86_64-apple-darwin",
]
+4
View File
@@ -7,6 +7,10 @@ repository.workspace = true
[dependencies] [dependencies]
fparkan-corpus = { path = "../crates/fparkan-corpus" } fparkan-corpus = { path = "../crates/fparkan-corpus" }
cargo_metadata = "0.21"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
[lints] [lints]
workspace = true workspace = true
+1053 -168
View File
File diff suppressed because it is too large Load Diff