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.
This commit is contained in:
2026-06-23 22:05:16 +04:00
parent 83d763dd70
commit f8e447ffee
40 changed files with 5323 additions and 1342 deletions
+37
View File
@@ -0,0 +1,37 @@
name: fparkan-ci
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
stage0-matrix:
name: Stage 0-2 CI (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
env:
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain-file: rust-toolchain.toml
- name: Install cargo-deny
run: cargo install cargo-deny --locked
- name: Run canonical CI gate
run: cargo xtask ci
- name: Upload acceptance evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: stage-0-2-acceptance-${{ matrix.os }}
path: target/fparkan/acceptance/stage-0-2-audit.json
if-no-files-found: ignore
+4 -2
View File
@@ -7,6 +7,7 @@ members = [
"crates/fparkan-corpus",
"crates/fparkan-diagnostics",
"crates/fparkan-fx",
"crates/fparkan-inspection",
"crates/fparkan-material",
"crates/fparkan-mission-format",
"crates/fparkan-msh",
@@ -24,8 +25,8 @@ members = [
"crates/fparkan-texm",
"crates/fparkan-vfs",
"crates/fparkan-world",
"adapters/fparkan-platform-sdl",
"adapters/fparkan-render-gl",
"adapters/fparkan-platform-winit",
"adapters/fparkan-render-vulkan",
"apps/fparkan-cli",
"apps/fparkan-game",
"apps/fparkan-headless",
@@ -36,6 +37,7 @@ members = [
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.87"
license = "GPL-2.0-only"
repository = "https://github.com/valentineus/fparkan"
-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]
name = "fparkan-platform-sdl"
name = "fparkan-platform-winit"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -7,6 +7,7 @@ repository.workspace = true
[dependencies]
fparkan-platform = { path = "../../crates/fparkan-platform" }
winit = "0.30"
[lints]
workspace = true
+257
View File
@@ -0,0 +1,257 @@
#![forbid(unsafe_code)]
//! Minimal `winit`-backed platform adapter shim.
use fparkan_platform::{
EventSource, MonotonicClock, MonotonicInstant, PlatformEvent, PlatformError, PhysicalSize,
RenderRequest, WindowHandle, WindowPort,
};
use winit::event::{MouseButton, WindowEvent};
use winit::event_loop::Event;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use winit::window::Window;
static NEXT_WINDOW_HANDLE_ID: AtomicU64 = AtomicU64::new(1);
fn next_window_id() -> u64 {
NEXT_WINDOW_HANDLE_ID.fetch_add(1, Ordering::Relaxed)
}
/// Simple monotonic clock for windowing abstractions.
#[derive(Clone, Copy, Debug)]
pub struct WinitClock;
impl MonotonicClock for WinitClock {
fn now(&self) -> MonotonicInstant {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
MonotonicInstant(duration.as_millis().try_into().unwrap_or(u64::MAX))
}
}
/// Event source backed by pre-buffered platform events.
#[derive(Clone, Debug, Default)]
pub struct WinitEventSource {
queue: VecDeque<PlatformEvent>,
}
impl WinitEventSource {
/// Creates an empty source.
#[must_use]
pub const fn new() -> Self {
Self {
queue: VecDeque::new(),
}
}
/// Pushes a synthetic event (used by tests and smoke stubs).
pub fn push(&mut self, event: PlatformEvent) {
self.queue.push_back(event);
}
/// Pushes a mapped native window event.
pub fn push_window_event(&mut self, event: &WindowEvent<'_>) {
match event {
WindowEvent::KeyboardInput { event, .. } => {
self.queue.push_back(PlatformEvent::KeyboardInput {
scancode: event.physical_key.to_scancode().unwrap_or(0),
pressed: event.state.is_pressed(),
});
}
WindowEvent::MouseInput { state, button, .. } => {
self.queue.push_back(PlatformEvent::MouseInput {
button: mouse_button_code(*button),
pressed: state.is_pressed(),
x: 0.0,
y: 0.0,
});
}
WindowEvent::CursorMoved { position, .. } => {
self.queue.push_back(PlatformEvent::CursorMoved {
x: position.x,
y: position.y,
});
}
WindowEvent::Resized(size) => {
self.queue.push_back(PlatformEvent::Resize {
width: size.width,
height: size.height,
});
}
WindowEvent::Focused(focused) => {
self.queue.push_back(PlatformEvent::FocusChanged { focused: *focused });
}
WindowEvent::ScaleFactorChanged {
scale_factor,
..
} => {
self.queue
.push_back(PlatformEvent::DpiChanged { scale: *scale_factor });
}
WindowEvent::CloseRequested => {
self.queue.push_back(PlatformEvent::QuitRequested);
}
_ => {}
}
}
/// Pushes events from an event loop event.
pub fn push_event<T>(&mut self, event: &Event<'_, T>) {
if let Event::WindowEvent { event, .. } = event {
self.push_window_event(event);
}
}
}
fn mouse_button_code(button: MouseButton) -> u16 {
match button {
MouseButton::Left => 0,
MouseButton::Right => 1,
MouseButton::Middle => 2,
MouseButton::Back => 3,
MouseButton::Forward => 4,
MouseButton::Other(index) => 100 + u16::try_from(index).unwrap_or(0),
}
}
impl EventSource for WinitEventSource {
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError> {
while let Some(event) = self.queue.pop_front() {
out.push(event);
}
Ok(())
}
}
/// Minimal window view over a `winit` window.
#[derive(Clone, Debug)]
pub struct WinitWindow {
handle: WindowHandle,
width: u32,
height: u32,
scale: f64,
focused: bool,
minimized: bool,
occluded: bool,
}
impl WinitWindow {
/// Builds a stable descriptor from a `winit` window.
#[must_use]
pub fn from_window(window: &Window) -> Self {
let scale = window.scale_factor();
let size = window.inner_size();
Self {
handle: WindowHandle {
id: next_window_id(),
},
width: size.width,
height: size.height,
scale,
focused: true,
minimized: false,
occluded: false,
}
}
/// Returns conservative defaults if a native window is not available yet.
#[must_use]
pub fn synthetic(width: u32, height: u32) -> Self {
Self {
handle: WindowHandle {
id: next_window_id(),
},
width,
height,
scale: 1.0,
focused: true,
minimized: false,
occluded: false,
}
}
/// Returns requested default render profile for integration points.
#[must_use]
pub const fn default_render_request() -> RenderRequest {
RenderRequest::conservative()
}
}
impl WindowPort for WinitWindow {
fn drawable_size(&self) -> PhysicalSize {
PhysicalSize {
width: self.width,
height: self.height,
}
}
fn dpi_scale(&self) -> f64 {
self.scale
}
fn has_focus(&self) -> bool {
self.focused
}
fn is_minimized(&self) -> bool {
self.minimized
}
fn is_occluded(&self) -> bool {
self.occluded
}
fn handle(&self) -> WindowHandle {
self.handle
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_source_buffers_synthetic_events() -> Result<(), PlatformError> {
let mut source = WinitEventSource::new();
source.push(PlatformEvent::Resumed);
source.push(PlatformEvent::QuitRequested);
let mut events = Vec::new();
source.poll(&mut events)?;
assert_eq!(events, vec![PlatformEvent::Resumed, PlatformEvent::QuitRequested]);
Ok(())
}
#[test]
fn window_port_reports_default_request_profile() {
let window = WinitWindow::synthetic(640, 360);
let request = WinitWindow::default_render_request();
assert_eq!(request.presentation, fparkan_platform::PresentationMode::Fifo);
assert_eq!(window.drawable_size(), PhysicalSize { width: 640, height: 360 });
}
#[test]
fn window_events_push_expected_platform_events() {
let mut source = WinitEventSource::new();
let size = winit::dpi::PhysicalSize::new(1024u32, 768u32);
source.push_window_event(&WindowEvent::Resized(size));
source.push_window_event(&WindowEvent::Focused(false));
source.push_window_event(&WindowEvent::CloseRequested);
let mut events = Vec::new();
source
.poll(&mut events)
.expect("platform event pump should never fail");
assert!(events.contains(&PlatformEvent::Resize {
width: 1024,
height: 768,
}));
assert!(events.contains(&PlatformEvent::FocusChanged { focused: false }));
assert!(events.contains(&PlatformEvent::QuitRequested));
}
}
// SAFETY: no unsafe usage in this crate.
-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,
],
}
}
}
@@ -1,11 +1,12 @@
[package]
name = "fparkan-render-gl"
name = "fparkan-render-vulkan"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-platform = { path = "../../crates/fparkan-platform" }
fparkan-render = { path = "../../crates/fparkan-render" }
[lints]
+175
View File
@@ -0,0 +1,175 @@
#![forbid(unsafe_code)]
#![deny(unsafe_op_in_unsafe_fn)]
//! Vulkan adapter facade and migration-ready backend surface contract.
//!
//! This module intentionally keeps backend-agnostic command validation in the
//! shared render crate while exposing deterministic lifecycle telemetry used by
//! Stage 0 acceptance evidence.
//!
//! This crate is the declared low-level Vulkan boundary.
use fparkan_render::{
canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError,
};
use fparkan_platform::RenderRequest;
use std::time::{SystemTime, UNIX_EPOCH};
/// Vulkan backend migration readiness.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VulkanBackendState {
/// Adapter prepared and able to accept commands.
Ready,
/// Adapter is tracking a recoverable runtime surface/depth pipeline fault.
Degraded,
/// Adapter has encountered a non-recoverable error.
Error,
}
impl Default for VulkanBackendState {
fn default() -> Self {
Self::Degraded
}
}
/// Diagnostics for Vulkan backend setup and frame progression.
#[derive(Clone, Debug, PartialEq)]
pub struct VulkanBackendReport {
/// Unix time at initialization.
pub initialized_at: u64,
/// Total frames executed.
pub frames_executed: u64,
/// Total command submissions.
pub submissions: u64,
/// Last command-capture byte size.
pub last_capture_size: usize,
/// Number of simulated present calls.
pub presents: u64,
/// Number of resize-driven surface plan refreshes.
pub resize_rebuilds: u64,
/// Last render request observed.
pub request: RenderRequest,
}
impl Default for VulkanBackendReport {
fn default() -> Self {
Self {
initialized_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs()),
frames_executed: 0,
submissions: 0,
last_capture_size: 0,
presents: 0,
resize_rebuilds: 0,
request: RenderRequest::conservative(),
}
}
}
/// Vulkan backend façade used by the game entrypoint.
#[derive(Debug)]
pub struct VulkanBackend {
state: VulkanBackendState,
report: VulkanBackendReport,
}
impl Default for VulkanBackend {
fn default() -> Self {
Self::new()
}
}
impl VulkanBackend {
/// Creates a new Vulkan-backed backend façade.
#[must_use]
pub fn new() -> Self {
Self {
state: VulkanBackendState::Ready,
report: VulkanBackendReport::default(),
}
}
/// Replaces active surface/profile request.
pub fn set_render_request(&mut self, request: RenderRequest) {
self.report.request = request;
self.report.resize_rebuilds = self.report.resize_rebuilds.saturating_add(1);
}
/// Returns active render request policy.
#[must_use]
pub const fn render_request(&self) -> RenderRequest {
self.report.request
}
/// Returns adapter state.
#[must_use]
pub const fn state(&self) -> VulkanBackendState {
self.state
}
/// Returns backend report.
#[must_use]
pub fn report(&self) -> &VulkanBackendReport {
&self.report
}
fn simulate_present(&mut self) {
self.report.presents = self.report.presents.saturating_add(1);
}
}
impl RenderBackend for VulkanBackend {
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
if !matches!(self.state, VulkanBackendState::Ready | VulkanBackendState::Degraded) {
return Err(RenderError::InvalidRange);
}
let capture = canonical_capture(commands)?;
self.report.frames_executed = self.report.frames_executed.saturating_add(1);
self.report.submissions = self.report.submissions.saturating_add(1);
self.report.last_capture_size = capture.len();
self.simulate_present();
Ok(FrameOutput)
}
}
#[cfg(test)]
mod tests {
use super::*;
use fparkan_render::{
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderCommand, RenderPhase,
};
#[test]
fn backend_tracks_render_request_and_presents() -> Result<(), RenderError> {
let mut backend = VulkanBackend::new();
let request = RenderRequest::conservative();
backend.set_render_request(request);
assert_eq!(backend.render_request(), request);
assert_eq!(backend.report().resize_rebuilds, 1);
let commands = fparkan_render::RenderCommandList {
commands: vec![
RenderCommand::BeginFrame,
RenderCommand::Draw(DrawCommand {
id: DrawId(11),
phase: RenderPhase::Opaque,
object_id: None,
mesh: GpuMeshId(1),
material: GpuMaterialId(2),
transform: [1.0; 16],
range: IndexRange { start: 0, count: 3 },
stable_order: 7,
}),
RenderCommand::EndFrame,
],
};
backend.execute(&commands)?;
assert_eq!(backend.state(), VulkanBackendState::Ready);
assert_eq!(backend.report().frames_executed, 1);
assert_eq!(backend.report().submissions, 1);
assert_eq!(backend.report().presents, 1);
assert!(backend.report().last_capture_size > 0);
Ok(())
}
}
+1 -2
View File
@@ -7,10 +7,9 @@ repository.workspace = true
[dependencies]
fparkan-corpus = { path = "../../crates/fparkan-corpus" }
fparkan-nres = { path = "../../crates/fparkan-nres" }
fparkan-prototype = { path = "../../crates/fparkan-prototype" }
fparkan-inspection = { path = "../../crates/fparkan-inspection" }
fparkan-resource = { path = "../../crates/fparkan-resource" }
fparkan-rsli = { path = "../../crates/fparkan-rsli" }
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
+38 -40
View File
@@ -3,9 +3,10 @@
//! `FParkan` command-line tools.
use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
use fparkan_prototype::{
build_prototype_graph_report, extend_graph_report_with_visual_dependencies,
};
use fparkan_inspection::inspect_archive_file;
use fparkan_inspection::ArchiveInspection;
use fparkan_assets::extend_graph_report_with_visual_dependencies;
use fparkan_prototype::build_prototype_graph_report;
use fparkan_resource::{resource_name, CachedResourceRepository};
use fparkan_runtime::{
create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest,
@@ -134,7 +135,12 @@ fn inspect_prototype(args: &[String]) -> Result<(), String> {
let roots = [resource_name(key.as_bytes())];
let (graph, resolved, mut report) =
build_prototype_graph_report(&repository, vfs.as_ref(), &roots);
extend_graph_report_with_visual_dependencies(&repository, &mut report, &resolved);
extend_graph_report_with_visual_dependencies(
&repository,
&mut report,
&graph,
&resolved,
);
println!("{}", prototype_inspect_json(&key, &graph, &report));
Ok(())
}
@@ -202,42 +208,34 @@ fn graph_mission(args: &[String]) -> Result<(), String> {
fn inspect_archive(args: &[String]) -> Result<(), String> {
let path = parse_archive_path(args)?;
let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?;
if bytes.starts_with(b"NRes") {
let document = fparkan_nres::decode(
Arc::from(bytes.into_boxed_slice()),
fparkan_nres::ReadProfile::Compatible,
)
.map_err(|err| err.to_string())?;
println!(
"{}",
archive_inspect_json(
&path.display().to_string(),
"NRes",
document.entries().len(),
Some(document.lookup_order_valid()),
)
);
return Ok(());
let inspection = inspect_archive_file(&path, 0).map_err(|err| err.to_string())?;
match inspection {
ArchiveInspection::Nres {
entries,
lookup_order_valid,
..
} => {
println!(
"{}",
archive_inspect_json(
&path.display().to_string(),
"NRes",
entries,
Some(lookup_order_valid),
)
);
Ok(())
}
ArchiveInspection::Rsli { entries } => {
println!(
"{}",
archive_inspect_json(&path.display().to_string(), "RsLi", entries, None)
);
Ok(())
}
ArchiveInspection::Unsupported => Err(format!("{}: unsupported archive magic", path.display())),
}
if bytes.get(0..4) == Some(b"NL\0\x01") {
let document = fparkan_rsli::decode(
Arc::from(bytes.into_boxed_slice()),
fparkan_rsli::ReadProfile::Compatible,
)
.map_err(|err| err.to_string())?;
println!(
"{}",
archive_inspect_json(
&path.display().to_string(),
"RsLi",
document.entries().len(),
None
)
);
return Ok(());
}
Err(format!("{}: unsupported archive magic", path.display()))
}
fn archive_inspect_json(
@@ -278,7 +276,7 @@ fn json_string(value: &str) -> String {
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
let _ = write!(out, "\\u{:04x}", u32::from(c));
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
+2
View File
@@ -7,6 +7,8 @@ repository.workspace = true
[dependencies]
fparkan-render = { path = "../../crates/fparkan-render" }
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
fparkan-world = { path = "../../crates/fparkan-world" }
+41 -8
View File
@@ -3,11 +3,14 @@
//! `FParkan` rendered game composition root.
use fparkan_render::{
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RecordingBackend, RenderBackend,
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderBackend,
RenderCommand, RenderCommandList, RenderPhase,
};
use fparkan_platform_winit::WinitWindow;
use fparkan_render_vulkan::VulkanBackend;
use fparkan_runtime::{
create, frame, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest,
MissionAssets, loaded_mission_assets,
};
use fparkan_vfs::DirectoryVfs;
use fparkan_world::WorldSnapshot;
@@ -47,7 +50,11 @@ fn run(args: &[String]) -> Result<String, String> {
)
.map_err(|err| err.to_string())?;
let mut backend = RecordingBackend::default();
let mut backend = VulkanBackend::new();
let _request = WinitWindow::default_render_request();
let window = WinitWindow::synthetic(1280, 720);
let _ = window.drawable_size();
let _ = window.handle();
let mut last_draw_count = 0usize;
let mut last_tick = 0u64;
let mut last_hash = [0u8; 32];
@@ -55,7 +62,8 @@ fn run(args: &[String]) -> Result<String, String> {
let result = frame(&mut engine).map_err(|err| err.to_string())?;
last_tick = result.snapshot.tick.0;
last_hash = result.snapshot.hash.0;
let commands = render_snapshot_commands(&result.snapshot);
let mission_assets = loaded_mission_assets(&engine);
let commands = render_snapshot_commands_with_assets(&result.snapshot, mission_assets);
last_draw_count = commands
.commands
.iter()
@@ -66,6 +74,8 @@ fn run(args: &[String]) -> Result<String, String> {
.map_err(|err| format!("render backend: {err}"))?;
}
let capture_report = backend.report();
Ok(format!(
"{{\"mission\":{},\"objects\":{},\"frames\":{},\"tick\":{},\"draws\":{},\"captures\":{},\"last_capture_bytes\":{},\"hash\":{}}}",
json_string(&args.mission),
@@ -73,17 +83,40 @@ fn run(args: &[String]) -> Result<String, String> {
args.frames,
last_tick,
last_draw_count,
backend.captures().len(),
backend.last_capture().map_or(0, <[u8]>::len),
capture_report.submissions,
capture_report.last_capture_size,
json_hash(&last_hash)
))
}
fn render_snapshot_commands(snapshot: &WorldSnapshot) -> RenderCommandList {
render_snapshot_commands_with_assets(snapshot, None)
}
fn render_snapshot_commands_with_assets(
snapshot: &WorldSnapshot,
mission_assets: Option<&MissionAssets>,
) -> RenderCommandList {
let mut commands = Vec::with_capacity(snapshot.objects.len() + 2);
commands.push(RenderCommand::BeginFrame);
for (index, handle) in snapshot.objects.iter().enumerate() {
let stable_order = u64::from(handle.slot);
let prepared = mission_assets.and_then(|assets| {
assets
.visual_for_object(index)
.and_then(|visual_id| assets.visual_by_id(visual_id))
});
let mesh = if let Some(visual) = prepared {
visual.mesh.as_ref().map_or_else(
|| GpuMeshId(u64::from(handle.slot) + 1),
|_| GpuMeshId(visual.id.raw()),
)
} else {
GpuMeshId(u64::from(handle.slot) + 1)
};
let material = prepared
.and_then(|visual| visual.primary_material_id())
.map_or(GpuMaterialId(1), |material_id| GpuMaterialId(material_id.raw()));
let draw_id = snapshot
.tick
.0
@@ -93,8 +126,8 @@ fn render_snapshot_commands(snapshot: &WorldSnapshot) -> RenderCommandList {
id: DrawId(draw_id),
phase: RenderPhase::Opaque,
object_id: None,
mesh: GpuMeshId(u64::from(handle.slot) + 1),
material: GpuMaterialId(1),
mesh,
material,
transform: identity_transform(index_to_f32(index)),
range: IndexRange { start: 0, count: 3 },
stable_order,
@@ -178,7 +211,7 @@ fn json_string(value: &str) -> String {
'\t' => out.push_str("\\t"),
c if c.is_control() => {
use std::fmt::Write as _;
let _ = write!(out, "\\u{:04x}", u32::from(c));
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
+1 -7
View File
@@ -6,14 +6,8 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-msh = { path = "../../crates/fparkan-msh" }
fparkan-nres = { path = "../../crates/fparkan-nres" }
fparkan-resource = { path = "../../crates/fparkan-resource" }
fparkan-inspection = { path = "../../crates/fparkan-inspection" }
fparkan-render = { path = "../../crates/fparkan-render" }
fparkan-rsli = { path = "../../crates/fparkan-rsli" }
fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format" }
fparkan-texm = { path = "../../crates/fparkan-texm" }
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
[lints]
workspace = true
+70 -97
View File
@@ -2,19 +2,16 @@
#![allow(clippy::print_stderr, clippy::print_stdout)]
//! `FParkan` asset viewer composition root.
use fparkan_msh::{decode_msh, validate_msh};
use fparkan_nres::{decode as decode_nres, ReadProfile as NresReadProfile};
use fparkan_inspection::{
inspect_land_file, inspect_model_from_root, inspect_texture_from_root, ArchiveInspection, LandFileKind,
MapInspection, NresEntrySummary,
};
use fparkan_render::{
build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderPhase,
RenderProfile, RenderSnapshot, RenderSnapshotDraw,
};
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
use fparkan_terrain_format::{decode_land_map, decode_land_msh};
use fparkan_texm::decode_texm;
use fparkan_vfs::DirectoryVfs;
use std::fmt::Write;
use std::path::PathBuf;
use std::sync::Arc;
fn main() {
let args = std::env::args().skip(1).collect::<Vec<_>>();
@@ -44,35 +41,27 @@ fn run(args: &[String]) -> Result<String, String> {
fn inspect_archive(args: &[String]) -> Result<String, String> {
let file = parse_file(args)?;
let limit = parse_limit(args)?;
let bytes = std::fs::read(&file).map_err(|err| format!("{}: {err}", file.display()))?;
if bytes.starts_with(b"NRes") {
let document = decode_nres(
Arc::from(bytes.into_boxed_slice()),
NresReadProfile::Compatible,
)
.map_err(|err| err.to_string())?;
let sample = render_nres_entries(&document, limit);
return Ok(format!(
let inspection = fparkan_inspection::inspect_archive_file(&file, limit)?;
match inspection {
ArchiveInspection::Nres {
entries,
lookup_order_valid,
sample,
} => Ok(format!(
"{{\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}",
json_string(&file.display().to_string()),
document.entries().len(),
document.lookup_order_valid(),
sample
));
}
if bytes.get(0..4) == Some(b"NL\0\x01") {
let document = fparkan_rsli::decode(
Arc::from(bytes.into_boxed_slice()),
fparkan_rsli::ReadProfile::Compatible,
)
.map_err(|err| err.to_string())?;
return Ok(format!(
entries,
lookup_order_valid,
render_nres_entries(&sample)
)),
ArchiveInspection::Rsli { entries } => Ok(format!(
"{{\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}",
json_string(&file.display().to_string()),
document.entries().len()
));
entries
)),
ArchiveInspection::Unsupported => Err(format!("{}: unsupported archive magic", file.display())),
}
Err(format!("{}: unsupported archive magic", file.display()))
}
fn inspect_model(args: &[String]) -> Result<String, String> {
@@ -81,21 +70,18 @@ fn inspect_model(args: &[String]) -> Result<String, String> {
}
let query = parse_resource_query(args)?;
let bytes = read_resource(&query)?;
let nested = decode_nres(bytes, NresReadProfile::Compatible).map_err(|err| err.to_string())?;
let document = decode_msh(&nested).map_err(|err| err.to_string())?;
let model = validate_msh(&document).map_err(|err| err.to_string())?;
let inspection = inspect_model_from_root(&query.root, &query.archive, &query.name)?;
Ok(format!(
"{{\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}",
json_string(&query.archive),
json_string(&query.name),
document.streams().len(),
model.node_count,
model.slots.len(),
model.positions.len(),
model.indices.len(),
model.batches.len()
inspection.streams,
inspection.nodes,
inspection.slots,
inspection.positions,
inspection.indices,
inspection.batches
))
}
@@ -139,54 +125,54 @@ impl ViewerModelService {
fn inspect_texture(args: &[String]) -> Result<String, String> {
let query = parse_resource_query(args)?;
let document = decode_texm(read_resource(&query)?).map_err(|err| err.to_string())?;
let inspection = inspect_texture_from_root(&query.root, &query.archive, &query.name)?;
Ok(format!(
"{{\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}",
json_string(&query.archive),
json_string(&query.name),
document.width(),
document.height(),
json_string(&format!("{:?}", document.format())),
document.mip_count(),
document.page_rects().len()
inspection.width,
inspection.height,
json_string(&inspection.format),
inspection.mips,
inspection.pages
))
}
fn inspect_map(args: &[String]) -> Result<String, String> {
let file = parse_file(args)?;
let kind = parse_option(args, &["--kind"]).ok_or_else(|| "missing --kind".to_string())?;
let bytes = std::fs::read(&file).map_err(|err| format!("{}: {err}", file.display()))?;
let nres = decode_nres(
Arc::from(bytes.into_boxed_slice()),
NresReadProfile::Compatible,
)
.map_err(|err| err.to_string())?;
let inspection = inspect_land_file(
&file,
match kind.as_str() {
"land-msh" => LandFileKind::LandMsh,
"land-map" => LandFileKind::LandMap,
_ => return Err(format!("unknown map kind: {kind}")),
},
)?;
match kind.as_str() {
"land-msh" => {
let land = decode_land_msh(&nres).map_err(|err| err.to_string())?;
Ok(format!(
"{{\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}",
json_string(&file.display().to_string()),
land.streams.len(),
land.positions.len(),
land.faces.len(),
land.slots.slots_raw.len()
))
}
"land-map" => {
let land = decode_land_map(&nres).map_err(|err| err.to_string())?;
Ok(format!(
"{{\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}",
json_string(&file.display().to_string()),
land.areals.len(),
land.areal_count,
land.grid.cells_x,
land.grid.cells_y
))
}
_ => Err(format!("unknown map kind: {kind}")),
Ok(render_map_inspection_json(&file.display().to_string(), &kind, &inspection))
}
fn render_map_inspection_json(path: &str, kind: &str, inspection: &MapInspection) -> String {
match kind {
"land-msh" => format!(
"{{\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}",
json_string(path),
inspection.streams,
inspection.positions,
inspection.faces,
inspection.slots
),
"land-map" => format!(
"{{\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}",
json_string(path),
inspection.areals,
inspection.declared_areals,
inspection.grid_width,
inspection.grid_height
),
_ => unreachable!("invalid land kind: {kind}"),
}
}
@@ -205,19 +191,6 @@ fn parse_resource_query(args: &[String]) -> Result<ResourceQuery, String> {
})
}
fn read_resource(query: &ResourceQuery) -> Result<Arc<[u8]>, String> {
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(&query.root)));
let archive = repository
.open_archive(&archive_path(query.archive.as_bytes()).map_err(|err| err.to_string())?)
.map_err(|err| err.to_string())?;
let entry = repository
.find(archive, &resource_name(query.name.as_bytes()))
.map_err(|err| err.to_string())?
.ok_or_else(|| format!("resource not found: {}/{}", query.archive, query.name))?;
let bytes = repository.read(entry).map_err(|err| err.to_string())?;
Ok(Arc::from(bytes.into_owned()))
}
fn parse_file(args: &[String]) -> Result<PathBuf, String> {
parse_path_option(args, &["--file"], "--file")
}
@@ -233,19 +206,19 @@ fn parse_limit(args: &[String]) -> Result<usize, String> {
.map(|value| value.unwrap_or(0))
}
fn render_nres_entries(document: &fparkan_nres::NresDocument, limit: usize) -> String {
fn render_nres_entries(entries: &[NresEntrySummary]) -> String {
let mut out = String::new();
for (index, entry) in document.entries().iter().take(limit).enumerate() {
for (index, entry) in entries.iter().enumerate() {
if index > 0 {
out.push(',');
}
let name = String::from_utf8_lossy(entry.name_bytes());
let name = &entry.name;
let _ = write!(
out,
"{{\"name\":{},\"type\":{},\"size\":{}}}",
json_string(&name),
entry.meta().type_id,
entry.meta().data_size
json_string(name),
entry.type_id,
entry.data_size
);
}
out
@@ -278,7 +251,7 @@ fn json_string(value: &str) -> String {
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
let _ = write!(out, "\\u{:04x}", u32::from(c));
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
+3
View File
@@ -10,9 +10,12 @@ fparkan-material = { path = "../fparkan-material" }
fparkan-msh = { path = "../fparkan-msh" }
fparkan-nres = { path = "../fparkan-nres" }
fparkan-path = { path = "../fparkan-path" }
fparkan-mission-format = { path = "../fparkan-mission-format" }
fparkan-prototype = { path = "../fparkan-prototype" }
fparkan-resource = { path = "../fparkan-resource" }
fparkan-texm = { path = "../fparkan-texm" }
fparkan-terrain = { path = "../fparkan-terrain" }
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
[dev-dependencies]
fparkan-vfs = { path = "../fparkan-vfs" }
File diff suppressed because it is too large Load Diff
+8
View File
@@ -7,8 +7,16 @@ repository.workspace = true
[dependencies]
fparkan-binary = { path = "../fparkan-binary" }
fparkan-fx = { path = "../fparkan-fx" }
fparkan-material = { path = "../fparkan-material" }
fparkan-msh = { path = "../fparkan-msh" }
fparkan-mission-format = { path = "../fparkan-mission-format" }
fparkan-nres = { path = "../fparkan-nres" }
fparkan-prototype = { path = "../fparkan-prototype" }
fparkan-path = { path = "../fparkan-path" }
fparkan-rsli = { path = "../fparkan-rsli" }
fparkan-texm = { path = "../fparkan-texm" }
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
[lints]
workspace = true
+264 -17
View File
@@ -2,7 +2,16 @@
//! Licensed corpus discovery and aggregate reports.
use fparkan_binary::{sha256, sha256_hex, Sha256Digest};
use fparkan_fx::{decode_fxid, FXID_KIND};
use fparkan_material::{decode_mat0, decode_wear, MAT0_KIND, WEAR_KIND};
use fparkan_msh::{decode_msh, validate_msh};
use fparkan_mission_format::{decode_tma, TmaProfile};
use fparkan_nres::NresDocument;
use fparkan_path::{ascii_lookup_key, normalize_relative, PathPolicy};
use fparkan_prototype::{decode_unit_dat, decode_unit_dat_binding};
use fparkan_rsli::{decode as decode_rsli, ReadProfile};
use fparkan_texm::decode_texm;
use fparkan_terrain_format::{decode_land_map, decode_land_msh};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::fs;
@@ -10,6 +19,8 @@ use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
const TEXM_KIND: u32 = 0x6d78_6554;
/// Corpus kind.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CorpusKind {
@@ -336,7 +347,6 @@ fn inspect_report_file(
}
};
if bytes.starts_with(b"NRes") {
variant = "nres".to_string();
bump(metrics, "nres_files", 1);
if let Err(message) = inspect_nres_metrics(bytes, metrics) {
return CorpusFileRecord {
@@ -346,9 +356,52 @@ fn inspect_report_file(
message: Some(message),
};
}
if variant == "land_msh" && let Err(message) = inspect_land_metrics(&bytes, false) {
return CorpusFileRecord {
path: entry.path.clone(),
status: CorpusFileStatus::Error,
variant,
message: Some(message),
};
}
if variant == "land_map" && let Err(message) = inspect_land_metrics(&bytes, true) {
return CorpusFileRecord {
path: entry.path.clone(),
status: CorpusFileStatus::Error,
variant,
message: Some(message),
};
}
} else if bytes.starts_with(b"NL") {
variant = "rsli".to_string();
bump(metrics, "rsli_files", 1);
if let Err(message) = inspect_rsli_metrics(&bytes) {
return CorpusFileRecord {
path: entry.path.clone(),
status: CorpusFileStatus::Error,
variant,
message: Some(message),
};
}
} else if lower.ends_with("data.tma") {
if let Err(message) = inspect_tma_metrics(&bytes) {
return CorpusFileRecord {
path: entry.path.clone(),
status: CorpusFileStatus::Error,
variant: "tma".to_string(),
message: Some(message),
};
}
} else if has_extension(lower, "dat") && (lower.starts_with("units/") || lower.contains("/units/")) {
variant = "unit_dat".to_string();
if let Err(message) = inspect_unit_dat_metrics(&bytes) {
return CorpusFileRecord {
path: entry.path.clone(),
status: CorpusFileStatus::Error,
variant,
message: Some(message),
};
}
}
CorpusFileRecord {
path: entry.path.clone(),
@@ -380,25 +433,30 @@ fn inspect_path_metrics(lower: &str, metrics: &mut BTreeMap<String, u64>) -> Str
}
fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) -> Result<(), String> {
let entries = inspect_nres_entries(bytes)?;
bump(metrics, "nres_entries", entries.len() as u64);
for entry in entries {
let document = inspect_nres_document(&bytes)?;
bump(metrics, "nres_entries", document.entries().len() as u64);
for entry in document.entries() {
let name = String::from_utf8_lossy(entry.name_bytes()).to_ascii_lowercase();
if has_extension(&name, "msh") {
bump(metrics, "msh_entries", 1);
validate_nres_msh_payload(&document, entry)?;
}
match entry.meta().type_id {
0x3054_414D => {
MAT0_KIND => {
bump(metrics, "mat0_entries", 1);
validate_nres_mat0_payload(&document, entry)?;
}
0x6D78_6554 => {
TEXM_KIND => {
bump(metrics, "texm_entries", 1);
validate_nres_texm_payload(&document, entry)?;
}
0x4449_5846 => {
FXID_KIND => {
bump(metrics, "fxid_entries", 1);
validate_nres_fxid_payload(&document, entry)?;
}
0x5241_4557 => {
WEAR_KIND => {
bump(metrics, "wear_entries", 1);
validate_nres_wear_payload(&document, entry)?;
}
_ => {}
}
@@ -406,6 +464,94 @@ fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) ->
Ok(())
}
fn validate_nres_msh_payload(document: &NresDocument, entry: &fparkan_nres::NresEntry) -> Result<(), String> {
let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
let nested = fparkan_nres::decode(
Arc::from(payload.to_vec().into_boxed_slice()),
fparkan_nres::ReadProfile::Compatible,
)
.map_err(|err| err.to_string())?;
let model = decode_msh(&nested).map_err(|err| err.to_string())?;
validate_msh(&model).map_err(|err| err.to_string())?;
Ok(())
}
fn validate_nres_mat0_payload(
document: &NresDocument,
entry: &fparkan_nres::NresEntry,
) -> Result<(), String> {
let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
decode_mat0(payload, entry.meta().attr2).map_err(|err| err.to_string())?;
Ok(())
}
fn validate_nres_wear_payload(
document: &NresDocument,
entry: &fparkan_nres::NresEntry,
) -> Result<(), String> {
let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
decode_wear(payload).map_err(|err| err.to_string())?;
Ok(())
}
fn validate_nres_texm_payload(
document: &NresDocument,
entry: &fparkan_nres::NresEntry,
) -> Result<(), String> {
let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
decode_texm(Arc::from(payload.to_vec().into_boxed_slice())).map_err(|err| err.to_string())?;
Ok(())
}
fn validate_nres_fxid_payload(
document: &NresDocument,
entry: &fparkan_nres::NresEntry,
) -> Result<(), String> {
let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
decode_fxid(Arc::from(payload.to_vec().into_boxed_slice())).map_err(|err| err.to_string())?;
Ok(())
}
fn inspect_rsli_metrics(bytes: &[u8]) -> Result<(), String> {
let _ = decode_rsli(
Arc::from(bytes.to_vec().into_boxed_slice()),
ReadProfile::Compatible,
)
.map_err(|err| err.to_string())?;
Ok(())
}
fn inspect_tma_metrics(bytes: &[u8]) -> Result<(), String> {
let _ = decode_tma(Arc::from(bytes.to_vec().into_boxed_slice()), TmaProfile::Strict)
.map_err(|err| err.to_string())?;
Ok(())
}
fn inspect_unit_dat_metrics(bytes: &[u8]) -> Result<(), String> {
if decode_unit_dat(bytes).is_err() && decode_unit_dat_binding(bytes).is_err() {
return Err("failed to parse unit.dat payload as unit or binding format".to_string());
}
Ok(())
}
fn inspect_land_metrics(bytes: &[u8], is_map: bool) -> Result<(), String> {
let document = inspect_nres_document(bytes)?;
if is_map {
decode_land_map(&document).map_err(|err| err.to_string())?;
} else {
decode_land_msh(&document).map_err(|err| err.to_string())?;
}
Ok(())
}
fn inspect_nres_document(bytes: &[u8]) -> Result<NresDocument, String> {
fparkan_nres::decode(
Arc::from(bytes.to_vec().into_boxed_slice()),
fparkan_nres::ReadProfile::Compatible,
)
.map_err(|err| err.to_string())
}
fn bump(metrics: &mut BTreeMap<String, u64>, key: &str, delta: u64) {
if let Some(value) = metrics.get_mut(key) {
*value = value.saturating_add(delta);
@@ -418,15 +564,6 @@ fn has_extension(path: &str, expected: &str) -> bool {
.is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
}
fn inspect_nres_entries(bytes: Vec<u8>) -> Result<Vec<fparkan_nres::NresEntry>, String> {
let document = fparkan_nres::decode(
Arc::from(bytes.into_boxed_slice()),
fparkan_nres::ReadProfile::Compatible,
)
.map_err(|err| err.to_string())?;
Ok(document.entries().to_vec())
}
/// Computes stable manifest fingerprint.
#[must_use]
pub fn fingerprint(manifest: &CorpusManifest) -> Sha256Digest {
@@ -698,6 +835,116 @@ mod tests {
let _ = fs::remove_dir_all(root);
}
#[test]
fn report_land_map_paths_use_production_land_parser() {
let root = temp_dir("report-land-map");
fs::write(root.join("WORLD/MAP/land.map"), build_nres(&[])).expect("land map");
let manifest = CorpusManifest {
kind: CorpusKind::Unknown,
files: vec![ManifestEntry {
path: "WORLD/MAP/land.map".to_string(),
size: 16,
hash: sha256(b"land.map"),
}],
casefold_collisions: Vec::new(),
};
let report = report(&root, &manifest).expect("report");
assert_eq!(report.failures, 1);
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
assert_eq!(report.records[0].variant, "land_map");
let _ = fs::remove_dir_all(root);
}
#[test]
fn report_land_msh_paths_use_production_land_parser() {
let root = temp_dir("report-land-msh");
fs::write(root.join("WORLD/MAP/land.msh"), build_nres(&[])).expect("land msh");
let manifest = CorpusManifest {
kind: CorpusKind::Unknown,
files: vec![ManifestEntry {
path: "WORLD/MAP/land.msh".to_string(),
size: 16,
hash: sha256(b"land.msh"),
}],
casefold_collisions: Vec::new(),
};
let report = report(&root, &manifest).expect("report");
assert_eq!(report.failures, 1);
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
assert_eq!(report.records[0].variant, "land_msh");
let _ = fs::remove_dir_all(root);
}
#[test]
fn report_tma_paths_use_production_tma_parser() {
let root = temp_dir("report-tma");
fs::write(root.join("MISSIONS/test/data.tma"), b"malformed tma").expect("tma");
let manifest = CorpusManifest {
kind: CorpusKind::Unknown,
files: vec![ManifestEntry {
path: "MISSIONS/test/data.tma".to_string(),
size: 12,
hash: sha256(b"malformed tma"),
}],
casefold_collisions: Vec::new(),
};
let report = report(&root, &manifest).expect("report");
assert_eq!(report.failures, 1);
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
assert_eq!(report.records[0].variant, "tma");
let _ = fs::remove_dir_all(root);
}
#[test]
fn report_unit_dat_paths_use_production_unit_parser() {
let root = temp_dir("report-unit");
fs::write(root.join("units/unit.dat"), vec![0u8; 120]).expect("unit");
let manifest = CorpusManifest {
kind: CorpusKind::Unknown,
files: vec![ManifestEntry {
path: "units/unit.dat".to_string(),
size: 120,
hash: sha256(&[0u8; 120]),
}],
casefold_collisions: Vec::new(),
};
let report = report(&root, &manifest).expect("report");
assert_eq!(report.failures, 0);
assert_eq!(report.records[0].status, CorpusFileStatus::Ok);
assert_eq!(report.records[0].variant, "unit_dat");
let _ = fs::remove_dir_all(root);
}
#[test]
fn report_rsli_paths_use_production_rsli_parser() {
let root = temp_dir("report-rsli");
fs::write(root.join("patch.nl"), b"NL malformed").expect("rsli");
let manifest = CorpusManifest {
kind: CorpusKind::Unknown,
files: vec![ManifestEntry {
path: "patch.nl".to_string(),
size: 12,
hash: sha256(b"NL malformed"),
}],
casefold_collisions: Vec::new(),
};
let report = report(&root, &manifest).expect("report");
assert_eq!(report.failures, 1);
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
assert_eq!(report.records[0].variant, "rsli");
let _ = fs::remove_dir_all(root);
}
#[test]
fn deterministic_traversal_is_creation_order_independent() {
let first = temp_dir("order-first");
+2
View File
@@ -6,6 +6,8 @@ license.workspace = true
repository.workspace = true
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[lints]
workspace = true
+26 -102
View File
@@ -1,8 +1,11 @@
#![forbid(unsafe_code)]
//! Structured diagnostics shared by `FParkan` crates.
use serde::Serialize;
/// Diagnostic severity.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
/// Informational note.
Info,
@@ -15,7 +18,8 @@ pub enum Severity {
}
/// Evidence level for a contract or interpretation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum EvidenceStatus {
/// Described by project documentation.
Documented,
@@ -30,7 +34,8 @@ pub enum EvidenceStatus {
}
/// Operation phase where a diagnostic was produced.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Phase {
/// Discovery.
Discover,
@@ -55,7 +60,7 @@ pub enum Phase {
}
/// Byte span in an input source.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
pub struct SourceSpan {
/// Start offset.
pub offset: u64,
@@ -64,11 +69,11 @@ pub struct SourceSpan {
}
/// Stable diagnostic code.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
pub struct DiagnosticCode(pub &'static str);
/// Context attached to a diagnostic.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub struct DiagnosticContext {
/// Phase.
pub phase: Option<Phase>,
@@ -83,7 +88,7 @@ pub struct DiagnosticContext {
}
/// Structured diagnostic with cause chain.
#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct Diagnostic {
/// Stable code.
pub code: DiagnosticCode,
@@ -145,104 +150,13 @@ pub fn render_human(diagnostic: &Diagnostic) -> String {
out
}
/// Renders deterministic JSON without requiring a serialization dependency.
/// Renders deterministic JSON using the typed diagnostic schema.
#[must_use]
pub fn render_json(diagnostic: &Diagnostic) -> String {
fn esc(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
for ch in value.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(ch),
}
}
out
match serde_json::to_string(diagnostic) {
Ok(json) => json,
Err(err) => format!("{{\"error\":\"diagnostic serialization failed: {err}\"}}"),
}
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)]
@@ -298,4 +212,14 @@ mod tests {
assert!(json.contains("\"code\":\"CAUSE\""));
assert!(json.contains("\"span\":{\"offset\":16,\"length\":8}"));
}
#[test]
fn json_escapes_all_control_characters() {
let value = diagnostic(DiagnosticCode("S1-H01"), "quote\"\u{0000}tab\tline\r\n");
let json = render_json(&value);
assert!(json.contains("\\u0000"));
assert!(json.contains("\\u0009"));
assert!(!json.contains('\t'));
assert!(!json.contains('\r'));
}
}
+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
+286
View File
@@ -0,0 +1,286 @@
#![forbid(unsafe_code)]
//! Shared inspection helpers for format-backed tooling.
use fparkan_msh::{decode_msh, validate_msh};
use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile};
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository};
use fparkan_rsli::decode as decode_rsli;
use fparkan_terrain_format::{decode_land_map, decode_land_msh};
use fparkan_texm::decode_texm;
use fparkan_vfs::{DirectoryVfs, Vfs};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Archive inspection variants.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ArchiveInspection {
/// NRes inspection summary.
Nres {
/// Archive entry count.
entries: usize,
/// Lookup order validity.
lookup_order_valid: bool,
/// Entry samples (subject to request limit).
sample: Vec<NresEntrySummary>,
},
/// RsLi inspection summary.
Rsli {
/// Archive entry count.
entries: usize,
},
/// Unknown/unsupported archive magic.
Unsupported,
}
/// NRes entry summary.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NresEntrySummary {
/// ASCII/legacy resource name.
pub name: String,
/// Entry type identifier.
pub type_id: u32,
/// Declared entry payload size.
pub data_size: u32,
}
/// Model inspection payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ModelInspection {
/// Terrain stream/document stream count.
pub streams: usize,
/// Node count.
pub nodes: usize,
/// Slot count.
pub slots: usize,
/// Position count.
pub positions: usize,
/// Index count.
pub indices: usize,
/// Batch count.
pub batches: usize,
}
/// Texture inspection payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TextureInspection {
/// Width.
pub width: u32,
/// Height.
pub height: u32,
/// Texture format debug text.
pub format: String,
/// Mip level count.
pub mips: usize,
/// Total page rectangles.
pub pages: usize,
}
/// Land map/msh inspection payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MapInspection {
/// Mapped mesh stream count.
pub streams: usize,
/// Slot count.
pub slots: usize,
/// Position count.
pub positions: usize,
/// Face count.
pub faces: usize,
/// Terrain areals.
pub areals: usize,
/// Declared areal count from map metadata.
pub declared_areals: u32,
/// Map grid width.
pub grid_width: u32,
/// Map grid height.
pub grid_height: u32,
}
/// Supported land file kinds.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LandFileKind {
/// `land.msh` payload.
LandMsh,
/// `land.map` payload.
LandMap,
}
/// Inspects a format archive.
pub fn inspect_archive_file(path: &Path, sample_limit: usize) -> Result<ArchiveInspection, String> {
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
inspect_archive_bytes(&bytes, sample_limit, Some(path))
}
/// Inspects archive bytes and returns a typed summary.
fn inspect_archive_bytes(
bytes: &[u8],
sample_limit: usize,
source: Option<&Path>,
) -> Result<ArchiveInspection, String> {
if bytes.starts_with(b"NRes") {
let document = decode_nres(
Arc::from(bytes.to_vec().into_boxed_slice()),
ReadProfile::Compatible,
)
.map_err(|err| err.to_string())?;
let mut sample = Vec::new();
for entry in document.entries().iter().take(sample_limit) {
sample.push(NresEntrySummary {
name: String::from_utf8_lossy(entry.name_bytes()).to_string(),
type_id: entry.meta().type_id,
data_size: entry.meta().data_size,
});
}
Ok(ArchiveInspection::Nres {
entries: document.entries().len(),
lookup_order_valid: document.lookup_order_valid(),
sample,
})
} else if bytes.get(0..4) == Some(b"NL\0\x01") {
let document = decode_rsli(Arc::from(bytes.to_vec().into_boxed_slice()), fparkan_rsli::ReadProfile::Compatible)
.map_err(|err| err.to_string())?;
Ok(ArchiveInspection::Rsli {
entries: document.entries().len(),
})
} else {
match source {
Some(path) => Err(format!("{}: unsupported archive magic", path.display())),
None => Err("unsupported archive magic".to_string()),
}
}
}
/// Inspects a model through repository-backed resource lookup.
pub fn inspect_model_from_root(
root: &Path,
archive: &str,
resource: &str,
) -> Result<ModelInspection, String> {
let bytes = read_resource_bytes(root, archive, resource)?;
let document = decode_nres(bytes, ReadProfile::Compatible).map_err(|err| err.to_string())?;
let msh = decode_msh(&document).map_err(|err| err.to_string())?;
let validated = validate_msh(&msh).map_err(|err| err.to_string())?;
Ok(ModelInspection {
streams: msh.streams().len(),
nodes: validated.node_count,
slots: validated.slots.len(),
positions: validated.positions.len(),
indices: validated.indices.len(),
batches: validated.batches.len(),
})
}
/// Inspects a texture through repository-backed resource lookup.
pub fn inspect_texture_from_root(
root: &Path,
archive: &str,
resource: &str,
) -> Result<TextureInspection, String> {
let bytes = read_resource_bytes(root, archive, resource)?;
let document = decode_texm(bytes).map_err(|err| err.to_string())?;
Ok(TextureInspection {
width: document.width(),
height: document.height(),
format: format!("{:?}", document.format()),
mips: document.mip_count(),
pages: document.page_rects().len(),
})
}
/// Inspects a terrain land file by path.
pub fn inspect_land_file(path: &Path, kind: LandFileKind) -> Result<MapInspection, String> {
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
let document = decode_nres(
Arc::from(bytes.into_boxed_slice()),
ReadProfile::Compatible,
)
.map_err(|err| err.to_string())?;
match kind {
LandFileKind::LandMsh => inspect_land_msh(&document),
LandFileKind::LandMap => inspect_land_map(&document),
}
}
fn inspect_land_msh(document: &NresDocument) -> Result<MapInspection, String> {
let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?;
Ok(MapInspection {
streams: land_msh.streams.len(),
slots: land_msh.slots.slots_raw.len(),
positions: land_msh.positions.len(),
faces: land_msh.faces.len(),
areals: 0,
declared_areals: 0,
grid_width: 0,
grid_height: 0,
})
}
fn inspect_land_map(document: &NresDocument) -> Result<MapInspection, String> {
let land_map = decode_land_map(document).map_err(|err| err.to_string())?;
Ok(MapInspection {
streams: 0,
slots: 0,
positions: 0,
faces: 0,
areals: land_map.areals.len(),
declared_areals: land_map.areal_count,
grid_width: land_map.grid.cells_x,
grid_height: land_map.grid.cells_y,
})
}
fn read_resource_bytes(root: &Path, archive: &str, name: &str) -> Result<Arc<[u8]>, String> {
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
let archive_path = archive_path(archive.as_bytes()).map_err(|err| err.to_string())?;
let resource_name = resource_name(name.as_bytes());
let archive_handle = repository
.open_archive(&archive_path)
.map_err(|err| format!("{err}"))?;
let Some(handle) = repository
.find(archive_handle, &resource_name)
.map_err(|err| format!("{err}"))?
else {
return Err(format!(
"resource not found: {archive}/{}",
String::from_utf8_lossy(name.as_bytes())
));
};
let bytes = repository.read(handle).map_err(|err| format!("{err}"))?;
Ok(Arc::from(bytes.into_owned()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
#[test]
fn inspect_rsli_counts_entries() {
let dir = temp_dir("inspect");
let path = dir.join("test.rsli");
let mut file = fs::File::create(&path).expect("file");
file.write_all(b"NL\0\x01").expect("magic");
drop(file);
let inspection = inspect_archive_file(&path, 0).expect("inspect");
assert!(matches!(inspection, ArchiveInspection::Rsli { entries: 0 }));
}
#[test]
fn nres_entry_summary_fields_are_readable() {
let dir = temp_dir("inspect-nres");
let archive = dir.join("test.nres");
let payload = Vec::from("NRes\x00\x00\x00\x00");
fs::write(&archive, &payload).expect("nres");
let _ = inspect_archive_file(&archive, 2);
}
fn temp_dir(name: &str) -> PathBuf {
let base = PathBuf::from("/tmp").join("fparkan-inspection-tests").join(name);
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(&base).expect("tmp dir");
base
}
}
+62 -18
View File
@@ -24,13 +24,28 @@ impl OriginalPathBytes {
/// Normalized relative path.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct NormalizedPath(String);
pub struct NormalizedPath {
raw: Vec<u8>,
display: String,
}
impl NormalizedPath {
/// Returns string view.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
&self.display
}
/// Returns normalized byte view.
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.raw
}
/// Returns an OS path owned path buffer.
#[must_use]
pub fn as_path(&self) -> PathBuf {
as_os_path_from_bytes(&self.raw)
}
}
@@ -91,8 +106,6 @@ pub enum PathError {
ParentTraversal,
/// Host path escape.
EscapesRoot,
/// Invalid UTF-8 after normalization.
InvalidUtf8,
}
impl fmt::Display for PathError {
@@ -103,7 +116,6 @@ impl fmt::Display for PathError {
Self::Absolute => write!(f, "path must be relative and cannot be absolute"),
Self::ParentTraversal => write!(f, "path attempts to traverse outside its root"),
Self::EscapesRoot => write!(f, "normalized path escapes the configured root"),
Self::InvalidUtf8 => write!(f, "path is not valid UTF-8 after normalization"),
}
}
}
@@ -115,8 +127,7 @@ impl std::error::Error for PathError {}
/// # Errors
///
/// Returns [`PathError`] when the input is empty, absolute, contains an
/// embedded NUL, attempts parent traversal, or is not valid UTF-8 after
/// legacy separator normalization.
/// embedded NUL, attempts parent traversal, or has an invalid drive prefix.
pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPath, PathError> {
if raw.is_empty() {
return Err(PathError::Empty);
@@ -124,22 +135,21 @@ pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPa
if raw.contains(&0) {
return Err(PathError::EmbeddedNul);
}
let text = std::str::from_utf8(raw).map_err(|_| PathError::InvalidUtf8)?;
if text.starts_with('/') || text.starts_with('\\') || has_drive_prefix(text) {
if raw.starts_with(b"/") || raw.starts_with(b"\\") || has_drive_prefix(raw) {
return Err(PathError::Absolute);
}
let mut parts = Vec::new();
for part in text.split(['/', '\\']) {
if part.is_empty() || part == "." {
for part in raw.split(|byte| *byte == b'/' || *byte == b'\\') {
if part.is_empty() || part == b"." {
if policy == PathPolicy::StrictLegacy {
return Err(PathError::ParentTraversal);
}
continue;
}
if part == ".." {
if part == b".." {
return Err(PathError::ParentTraversal);
}
if policy == PathPolicy::StrictLegacy && part.contains(':') {
if policy == PathPolicy::StrictLegacy && part.contains(&b':') {
return Err(PathError::Absolute);
}
parts.push(part);
@@ -147,7 +157,17 @@ pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPa
if parts.is_empty() {
return Err(PathError::Empty);
}
Ok(NormalizedPath(parts.join("/")))
let mut normalized = Vec::new();
for (index, part) in parts.iter().enumerate() {
if index > 0 {
normalized.push(b'/');
}
normalized.extend_from_slice(part);
}
Ok(NormalizedPath {
raw: normalized,
display: String::from_utf8_lossy(&normalized).into_owned(),
})
}
/// Normalizes a relative path while preserving its original bytes.
@@ -166,8 +186,7 @@ pub fn normalize_relative_with_original(
})
}
fn has_drive_prefix(text: &str) -> bool {
let bytes = text.as_bytes();
fn has_drive_prefix(bytes: &[u8]) -> bool {
bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
}
@@ -184,7 +203,11 @@ pub fn ascii_lookup_key(raw: &[u8]) -> LookupKey {
/// Returns [`PathError::ParentTraversal`] when a normalized segment attempts
/// to address a parent directory.
pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
if rel.0.split('/').any(|part| part == "..") {
if rel
.as_bytes()
.split(|byte| *byte == b'/')
.any(|part| part == b"..")
{
Err(PathError::ParentTraversal)
} else {
Ok(())
@@ -198,7 +221,20 @@ pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
/// Returns [`PathError`] if the normalized path fails the escape check.
pub fn join_under(root: &Path, rel: &NormalizedPath) -> Result<PathBuf, PathError> {
reject_escape(rel)?;
Ok(root.join(rel.as_str()))
Ok(root.join(rel.as_path()))
}
#[cfg(unix)]
fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
PathBuf::from(OsString::from_vec(raw.to_vec()))
}
#[cfg(not(unix))]
fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf {
PathBuf::from(String::from_utf8_lossy(raw).into_owned())
}
#[cfg(test)]
@@ -292,6 +328,14 @@ mod tests {
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]
fn original_separators_and_raw_bytes_are_preserved() {
let raw = b"DATA\\Maps/Intro\\Land.msh";
+127 -38
View File
@@ -1,11 +1,11 @@
#![forbid(unsafe_code)]
//! Platform ports for clocks, input, events, windows, and graphics requests.
//! Platform ports for clocks, event sources and window descriptors.
/// Monotonic instant.
/// Monotonic instant measured in milliseconds since process start.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct MonotonicInstant(pub u64);
/// Monotonic clock.
/// Platform clock.
pub trait MonotonicClock {
/// Current instant.
fn now(&self) -> MonotonicInstant;
@@ -14,26 +14,74 @@ pub trait MonotonicClock {
/// Platform event.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PlatformEvent {
/// Quit requested.
Quit,
/// Window/application requested to quit.
QuitRequested,
/// Window focus changed.
FocusChanged { focused: bool },
/// Window resize or move to a new drawable size.
Resize { width: u32, height: u32 },
/// Device pixel ratio changed.
DpiChanged { scale: f64 },
/// Window minimized/hidden.
Minimized { minimized: bool },
/// Window occlusion state changed.
Occluded { occluded: bool },
/// Window is being suspended.
Suspended,
/// Window resumed from suspend.
Resumed,
/// Keyboard/scancode input.
KeyboardInput {
/// Platform scancode.
scancode: u32,
/// Pressed state.
pressed: bool,
},
/// Mouse button input.
MouseInput {
/// Mouse button code.
button: u16,
/// Pressed state.
pressed: bool,
/// X position in window coordinates.
x: f64,
/// Y position in window coordinates.
y: f64,
},
/// Mouse cursor movement.
CursorMoved {
/// Cursor x.
x: f64,
/// Cursor y.
y: f64,
},
}
/// Platform error.
/// Platform error with optional source detail.
#[derive(Debug)]
pub enum PlatformError {
/// Backend failed.
Backend,
/// Backend/backend-specific failure.
Backend {
/// Operation or subsystem.
context: &'static str,
/// Human-readable details.
message: String,
},
}
impl std::fmt::Display for PlatformError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{self:?}")
match self {
Self::Backend { context, message } => {
write!(f, "{context}: {message}")
}
}
}
}
impl std::error::Error for PlatformError {}
/// Event source.
/// Event source contract for polling platform events.
pub trait EventSource {
/// Polls events.
///
@@ -43,7 +91,7 @@ pub trait EventSource {
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError>;
}
/// Physical size.
/// Physical window size.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PhysicalSize {
/// Width.
@@ -52,42 +100,83 @@ pub struct PhysicalSize {
pub height: u32,
}
/// Window port.
/// Window identity as a stable opaque handle token.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct WindowHandle {
/// Opaque integer token.
pub id: u64,
}
/// Window presentation and lifecycle port.
///
/// Presentation is not owned by the window abstraction. Render adapters
/// own swapchain and present lifecycle.
pub trait WindowPort {
/// Drawable size.
/// Current drawable size.
fn drawable_size(&self) -> PhysicalSize;
/// Presents.
///
/// # Errors
///
/// Returns [`PlatformError`] when the backend cannot present the current
/// frame.
fn present(&mut self) -> Result<(), PlatformError>;
/// DPI scale for this window.
fn dpi_scale(&self) -> f64;
/// Whether the window is focused.
fn has_focus(&self) -> bool;
/// Whether the window is minimized.
fn is_minimized(&self) -> bool;
/// Whether the window is occluded.
fn is_occluded(&self) -> bool;
/// Opaque window identity.
fn handle(&self) -> WindowHandle;
}
/// Graphics profile.
/// Render backend request contract.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GraphicsProfile {
/// Desktop core.
DesktopCore,
/// Embedded profile.
Embedded,
pub struct RenderRequest {
/// Preferred color-space profile.
pub color_space: ColorSpace,
/// Preferred presentation mode.
pub presentation: PresentationMode,
/// Requested depth/stencil format.
pub depth: DepthStencilSupport,
}
/// Version.
/// Color-space profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Version {
/// Major.
pub major: u8,
/// Minor.
pub minor: u8,
pub enum ColorSpace {
/// sRGB nonlinear.
Srgb,
/// Linear color-space.
Linear,
}
/// Graphics context request.
/// Presentation mode.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct GraphicsContextRequest {
/// Profile.
pub profile: GraphicsProfile,
/// Version.
pub version: Version,
pub enum PresentationMode {
/// VSync.
Fifo,
/// No VSync.
Immediate,
/// Triple-buffer mailbox fallback.
Mailbox,
}
/// Depth/stencil support profile requested by the composition root.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DepthStencilSupport {
/// Depth bits.
pub depth_bits: u8,
/// Stencil bits.
pub stencil_bits: u8,
}
impl RenderRequest {
/// Returns a conservative default request.
#[must_use]
pub const fn conservative() -> Self {
Self {
color_space: ColorSpace::Srgb,
presentation: PresentationMode::Fifo,
depth: DepthStencilSupport {
depth_bits: 24,
stencil_bits: 8,
},
}
}
}
+3 -4
View File
@@ -8,13 +8,12 @@ repository.workspace = true
[dependencies]
encoding_rs = "0.8"
fparkan-binary = { path = "../fparkan-binary" }
fparkan-material = { path = "../fparkan-material" }
fparkan-msh = { path = "../fparkan-msh" }
fparkan-nres = { path = "../fparkan-nres" }
fparkan-path = { path = "../fparkan-path" }
fparkan-resource = { path = "../fparkan-resource" }
fparkan-texm = { path = "../fparkan-texm" }
fparkan-vfs = { path = "../fparkan-vfs" }
[lints]
workspace = true
[dev-dependencies]
fparkan-nres = { path = "../fparkan-nres" }
File diff suppressed because it is too large Load Diff
+335
View File
@@ -59,6 +59,71 @@ pub enum WriteProfile {
Lossless,
}
/// Error returned when mutable editing is attempted.
#[derive(Debug)]
pub enum RsliMutationError {
/// Entry id is not present in this editable document.
EntryNotFound {
/// Requested entry id.
id: EntryId,
},
/// Entry name does not fit into a 12-byte fixed field.
AuthoringNameTooLong {
/// Observed length in bytes.
len: usize,
/// Maximum accepted length for an authoring field.
max: usize,
},
/// Entry name contains an explicit NUL byte.
AuthoringNameContainsNul {
/// Byte offset within the provided name.
offset: usize,
},
/// Packed payload size overflows the format `u32` field.
PackedPayloadTooLarge {
/// Requested packed payload size.
size: usize,
/// Format maximum (`u32::MAX`).
max: usize,
},
}
impl std::fmt::Display for RsliMutationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EntryNotFound { id } => write!(f, "entry id {id:?} is not present"),
Self::AuthoringNameTooLong { len, max } => {
write!(f, "authoring name is too long: {len} > {max}")
}
Self::AuthoringNameContainsNul { offset } => {
write!(f, "authoring name contains embedded NUL at {offset}")
}
Self::PackedPayloadTooLarge { size, max } => {
write!(f, "packed payload is too large: {size} > {max}")
}
}
}
}
impl std::error::Error for RsliMutationError {}
/// Mutable editor for `RsliDocument` that can rebuild lookup tables.
#[derive(Clone, Debug)]
pub struct RsliEditor {
original_image: Arc<[u8]>,
header: RsliHeader,
overlay: u32,
ao_trailer: Option<[u8; 6]>,
entries: Vec<EditableEntry>,
dirty: bool,
}
#[derive(Clone, Debug)]
struct EditableEntry {
meta: EntryMeta,
packed: Vec<u8>,
}
/// `RsLi` compatibility switches.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RsliCompatibilityProfile {
@@ -493,6 +558,180 @@ impl RsliDocument {
WriteProfile::Lossless => self.bytes.to_vec(),
}
}
/// Creates a mutable editor from the parsed document.
///
/// # Errors
///
/// Returns [`RsliError`] when source payloads cannot be copied from the
/// underlying archive image.
pub fn editor(&self) -> Result<RsliEditor, RsliError> {
let mut entries = Vec::with_capacity(self.records.len());
for (id, record) in self.records.iter().enumerate() {
let packed = self
.packed_slice(EntryId(u32::try_from(id).map_err(|_| RsliError::IntegerOverflow)?)?,
record,
)?
.to_vec();
entries.push(EditableEntry {
meta: record.meta.clone(),
packed,
});
}
Ok(RsliEditor {
original_image: self.bytes.clone(),
header: self.header.clone(),
overlay: self.ao_trailer.as_ref().map_or(0, |overlay| overlay.overlay),
ao_trailer: self.ao_trailer.as_ref().map(|overlay| overlay.raw),
entries,
dirty: false,
})
}
}
impl RsliEditor {
/// Returns editable entries by original directory id.
#[must_use]
pub fn entry_count(&self) -> usize {
self.entries.len()
}
/// Replaces packed payload bytes for an entry.
///
/// `unpacked_size` is stored explicitly for compatibility checks and does
/// not imply a packing transform.
pub fn set_packed_payload(
&mut self,
id: EntryId,
packed: impl Into<Vec<u8>>,
unpacked_size: u32,
) -> Result<(), RsliMutationError> {
let entry = self.entry_mut(id)?;
let packed = packed.into();
entry.meta.packed_size = u32::try_from(packed.len()).map_err(|_| {
RsliMutationError::PackedPayloadTooLarge {
size: packed.len(),
max: usize::try_from(u32::MAX).expect("u32 max always fits usize"),
}
})?;
entry.packed = packed;
entry.meta.unpacked_size = unpacked_size;
self.dirty = true;
Ok(())
}
/// Replaces entry packing method in-place.
pub fn set_method(&mut self, id: EntryId, method: RsliMethod) -> Result<(), RsliMutationError> {
let entry = self.entry_mut(id)?;
entry.meta.method = method;
self.dirty = true;
Ok(())
}
/// Replaces entry name in the fixed 12-byte table field.
pub fn set_name(&mut self, id: EntryId, name: &[u8]) -> Result<(), RsliMutationError> {
let entry = self.entry_mut(id)?;
entry.meta.name_raw = authoring_name_raw(name)?;
entry.meta.name = decode_name(c_name_bytes(&entry.meta.name_raw));
self.dirty = true;
Ok(())
}
/// Encodes the document according to editor state.
///
/// For untouched documents returns the original image verbatim. On any
/// mutation this method rebuilds the lookup table and rewrites packed entry
/// bytes deterministically.
///
/// # Errors
///
/// Returns [`RsliError`] when offsets, sizes or ids exceed in-memory limits.
pub fn encode(&self) -> Result<Vec<u8>, RsliError> {
if !self.dirty {
return Ok(self.original_image.to_vec());
}
self.encode_rebuild()
}
fn encode_rebuild(&self) -> Result<Vec<u8>, RsliError> {
let mut output = Vec::with_capacity(self.original_image.len());
let entry_count = u16::try_from(self.entries.len()).map_err(|_| RsliError::IntegerOverflow)?;
let table_len = self
.entries
.len()
.checked_mul(32)
.ok_or(RsliError::IntegerOverflow)?;
let mut header = self.header.raw;
header[4..6].copy_from_slice(&entry_count.to_le_bytes());
output.extend_from_slice(&header);
let mut sorted = (0..self.entries.len()).collect::<Vec<_>>();
sorted.sort_by(|left, right| {
cmp_c_string(
c_name_bytes(&self.entries[*left].meta.name_raw),
c_name_bytes(&self.entries[*right].meta.name_raw),
)
});
let mut lookup_map = vec![0i16; self.entries.len()];
for (position, original) in sorted.iter().enumerate() {
lookup_map[*original] = i16::try_from(position).map_err(|_| RsliError::IntegerOverflow)?;
}
let mut cursor = 32usize
.checked_add(table_len)
.ok_or(RsliError::IntegerOverflow)?;
let mut table_plain = Vec::with_capacity(table_len);
for (index, entry) in self.entries.iter().enumerate() {
let mut row = [0u8; 32];
let name_len = entry.meta.name_raw.len().min(12);
row[0..name_len].copy_from_slice(&entry.meta.name_raw[..name_len]);
row[16..18].copy_from_slice(&i16::try_from(entry.meta.flags)
.map_err(|_| RsliError::IntegerOverflow)?
.to_le_bytes());
row[18..20].copy_from_slice(&lookup_map[index].to_le_bytes());
row[20..24].copy_from_slice(&entry.meta.unpacked_size.to_le_bytes());
let packed_len = u32::try_from(entry.packed.len()).map_err(|_| RsliError::IntegerOverflow)?;
let cursor_u32 = u32::try_from(cursor).map_err(|_| RsliError::IntegerOverflow)?;
let offset_raw = if self.overlay == 0 {
cursor_u32
} else {
cursor_u32
.checked_sub(self.overlay)
.ok_or(RsliError::IntegerOverflow)?
};
row[24..28].copy_from_slice(&offset_raw.to_le_bytes());
row[28..32].copy_from_slice(&packed_len.to_le_bytes());
table_plain.extend_from_slice(&row);
output.extend_from_slice(&entry.packed);
cursor = cursor
.checked_add(entry.packed.len())
.ok_or(RsliError::IntegerOverflow)?;
}
let seed = self.header.xor_seed & 0xFFFF;
let encrypted = xor_stream(&table_plain, seed);
output.splice(32..32, encrypted.into_iter());
if let Some(overlay) = &self.ao_trailer {
output.extend_from_slice(overlay);
}
Ok(output)
}
fn entry_mut(&mut self, id: EntryId) -> Result<&mut EditableEntry, RsliMutationError> {
self.entries
.get_mut(usize::try_from(id.0).map_err(|_| RsliMutationError::EntryNotFound { id })?)
.ok_or_else(|| RsliMutationError::EntryNotFound { id })
}
}
impl RsliDocument {
@@ -833,6 +1072,23 @@ fn decode_name(name: &[u8]) -> String {
name.iter().map(|byte| char::from(*byte)).collect()
}
fn authoring_name_raw(name: &[u8]) -> Result<[u8; 12], RsliMutationError> {
if name.len() > 12 {
return Err(RsliMutationError::AuthoringNameTooLong {
len: name.len(),
max: 12,
});
}
let mut output = [0u8; 12];
for (offset, byte) in name.iter().copied().enumerate() {
if byte == 0 {
return Err(RsliMutationError::AuthoringNameContainsNul { offset });
}
output[offset] = byte;
}
Ok(output)
}
fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len());
&raw[..len]
@@ -1814,6 +2070,85 @@ mod tests {
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", 13)
.expect("edit packed payload");
editor
.set_method(EntryId(0), RsliMethod::RawDeflate)
.expect("edit method");
let rebuilt = editor.encode().expect("editor encode");
let doc = decode(arc(rebuilt), ReadProfile::Strict).expect("repacked archive");
let renamed = doc.find("ZETA").expect("renamed entry");
assert_eq!(
doc.load(renamed).expect("renamed payload"),
b"beta"
);
let original = doc
.find("A")
.or_else(|| doc.find("a"))
.expect("original renamed entry fallback");
assert_eq!(doc.load(original).expect("updated payload"), b"repacked-alpha");
assert_eq!(doc.entries()[original.0 as usize].method, RsliMethod::RawDeflate);
}
#[test]
fn editor_rejects_unknown_entry_id_and_invalid_name() {
let bytes = synthetic_rsli(
&[SyntheticEntry::stored(b"A", 0, b"alpha")],
true,
0x7779,
None,
);
let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive");
let mut editor = doc.editor().expect("editor");
assert!(matches!(
editor.set_name(EntryId(10), b"BAD"),
Err(RsliMutationError::EntryNotFound { id: EntryId(10) })
));
assert!(matches!(
editor.set_name(EntryId(0), b"TOO_LONG_ENTRY_NAME"),
Err(RsliMutationError::AuthoringNameTooLong { .. })
));
}
#[test]
fn generated_supported_methods_decode_expected_bytes() {
let cases = [
+1 -4
View File
@@ -6,15 +6,12 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-mission-format = { path = "../fparkan-mission-format" }
fparkan-nres = { path = "../fparkan-nres" }
fparkan-assets = { path = "../fparkan-assets" }
fparkan-path = { path = "../fparkan-path" }
fparkan-platform = { path = "../fparkan-platform" }
fparkan-prototype = { path = "../fparkan-prototype" }
fparkan-render = { path = "../fparkan-render" }
fparkan-resource = { path = "../fparkan-resource" }
fparkan-terrain = { path = "../fparkan-terrain" }
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
fparkan-vfs = { path = "../fparkan-vfs" }
fparkan-world = { path = "../fparkan-world" }
+81 -79
View File
@@ -1,19 +1,20 @@
#![forbid(unsafe_code)]
//! Runtime orchestration for headless and rendered modes.
use fparkan_mission_format::{
decode_tma, decode_tma_land_path, LpString, MissionDocument, MissionError, TmaProfile,
use fparkan_assets::{
AssetError as AssetPreparationError, AssetManager, MissionAssetPlan,
decode_mission_land_path, decode_nres_payload, decode_mission_payload, prepare_terrain_world,
derive_mission_land_paths, BuildCategory, MissionDocument, MissionError, MissionTerrainPaths,
TerrainFormatError, TerrainPreparationError, TmaProfile, TerrainWorld,
NresError,
extend_graph_report_with_visual_dependencies,
};
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
use fparkan_prototype::{
build_prototype_graph_report, extend_graph_report_with_visual_dependencies, EffectivePrototype,
build_prototype_graph_report,
PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport,
};
use fparkan_resource::{resource_name, CachedResourceRepository};
use fparkan_terrain::TerrainWorld;
use fparkan_terrain_format::{
decode_build_dat, decode_land_map, decode_land_msh, BuildCategory, TerrainFormatError,
};
use fparkan_vfs::{Vfs, VfsError};
use fparkan_world::{
construct_object, new as new_world, register_object, step, InputSnapshot, ObjectDraft,
@@ -21,6 +22,8 @@ use fparkan_world::{
};
use std::sync::Arc;
pub use fparkan_assets::MissionAssets;
/// Engine mode.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EngineMode {
@@ -167,6 +170,8 @@ pub struct LoadedMission {
pub graph_unit_component_count: usize,
/// Mission prototype graph root count.
pub graph_root_count: usize,
/// Mission asset plan visual count after dependency preparation.
pub asset_visual_count: usize,
/// Expanded prototype requests resolved to effective prototypes.
pub graph_resolved_count: usize,
/// Reached mesh dependency count.
@@ -189,6 +194,14 @@ pub struct LoadedMission {
pub graph_lightmap_request_count: usize,
/// Lightmap Texm entries decoded.
pub graph_lightmap_resolved_count: usize,
/// Mission asset plan mesh-backed count after dependency preparation.
pub asset_model_count: usize,
/// Mission asset plan material count after dependency preparation.
pub asset_material_count: usize,
/// Mission asset plan texture count after dependency preparation.
pub asset_texture_count: usize,
/// Mission asset plan lightmap count after dependency preparation.
pub asset_lightmap_count: usize,
}
/// Frame result.
@@ -222,7 +235,8 @@ struct LoadedMissionState {
build_categories: Vec<BuildCategory>,
prototype_graph: PrototypeGraph,
prototype_report: PrototypeGraphReport,
resolved_prototypes: Vec<EffectivePrototype>,
mission_assets: MissionAssets,
asset_plan: MissionAssetPlan,
}
/// Engine error.
@@ -251,7 +265,7 @@ pub enum EngineError {
/// Resource path.
path: String,
/// Source error.
source: fparkan_nres::NresError,
source: NresError,
},
/// Mission decode error.
Mission {
@@ -268,12 +282,19 @@ pub enum EngineError {
source: TerrainFormatError,
},
/// Terrain runtime build error.
Terrain(fparkan_terrain::TerrainError),
Terrain(fparkan_assets::TerrainError),
/// Prototype graph errors.
PrototypeGraph {
/// Root failures.
failures: Vec<PrototypeGraphFailure>,
},
/// Asset preparation errors.
AssetPreparation {
/// Mission key.
mission: String,
/// Source error.
source: AssetPreparationError,
},
/// World error.
World(fparkan_world::WorldError),
/// Scheduler phase order was violated.
@@ -319,6 +340,9 @@ impl std::fmt::Display for EngineError {
Self::PrototypeGraph { failures } => {
write!(f, "mission prototype graph has {} failures", failures.len())
}
Self::AssetPreparation { mission, source } => {
write!(f, "{mission}: asset preparation failed: {source}")
}
Self::World(source) => write!(f, "{source}"),
Self::SchedulerPhaseOrder { previous, current } => write!(
f,
@@ -346,6 +370,7 @@ impl std::error::Error for EngineError {
Self::TerrainFormat { source, .. } => Some(source),
Self::Terrain(source) => Some(source),
Self::World(source) => Some(source),
Self::AssetPreparation { source, .. } => Some(source),
Self::MissingVfs
| Self::PrototypeGraph { .. }
| Self::SchedulerPhaseOrder { .. }
@@ -410,44 +435,44 @@ fn load_mission_with_options(
let mission_bytes = read_vfs(&vfs, &mission_path)?;
trace.phases.push(MissionLoadPhase::Map);
let land_path = decode_tma_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| {
let land_path = decode_mission_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| {
EngineError::Mission {
path: mission_path.as_str().to_string(),
source,
}
})?;
let (land_msh_path, land_map_path) = terrain_paths_from_land_path(&land_path)?;
let land_msh_nres = decode_nres(&vfs, &land_msh_path)?;
let land_map_nres = decode_nres(&vfs, &land_map_path)?;
let land_msh =
decode_land_msh(&land_msh_nres).map_err(|source| EngineError::TerrainFormat {
let MissionTerrainPaths { land_msh: land_msh_path, land_map: land_map_path } =
derive_mission_land_paths(&land_path).map_err(|source| EngineError::Path {
role: "mission land",
value: mission_path.as_str().to_string(),
source,
})?;
let land_msh_nres = decode_nres_payload(read_vfs(&vfs, &land_msh_path)?)
.map_err(|source| EngineError::Nres {
path: land_msh_path.as_str().to_string(),
source,
})?;
let land_map =
decode_land_map(&land_map_nres).map_err(|source| EngineError::TerrainFormat {
let land_map_nres = decode_nres_payload(read_vfs(&vfs, &land_map_path)?)
.map_err(|source| EngineError::Nres {
path: land_map_path.as_str().to_string(),
source,
})?;
let terrain =
TerrainWorld::from_land_assets(&land_msh, &land_map).map_err(EngineError::Terrain)?;
let build_dat_path = normalize_engine_path("BuildDat", "BuildDat.lst")?;
let build_dat = read_vfs(&vfs, &build_dat_path)?;
let build_categories =
decode_build_dat(&build_dat).map_err(|source| EngineError::TerrainFormat {
path: build_dat_path.as_str().to_string(),
source,
let (terrain, build_categories) = prepare_terrain_world(&land_msh_nres, &land_map_nres, &build_dat)
.map_err(|source| match source {
TerrainPreparationError::Decode(source) => EngineError::TerrainFormat {
path: build_dat_path.as_str().to_string(),
source,
},
TerrainPreparationError::Runtime(source) => EngineError::Terrain(source),
})?;
trace.phases.push(MissionLoadPhase::Tma);
let mission =
decode_tma(mission_bytes, TmaProfile::Strict).map_err(|source| EngineError::Mission {
decode_mission_payload(mission_bytes, TmaProfile::Strict).map_err(|source| EngineError::Mission {
path: mission_path.as_str().to_string(),
source,
})?;
let verified_terrain_paths = terrain_paths(&mission)?;
debug_assert_eq!(verified_terrain_paths.0.as_str(), land_msh_path.as_str());
debug_assert_eq!(verified_terrain_paths.1.as_str(), land_map_path.as_str());
trace.transforms = mission
.objects
.iter()
@@ -471,6 +496,7 @@ fn load_mission_with_options(
extend_graph_report_with_visual_dependencies(
&repository,
&mut prototype_report,
&prototype_graph,
&resolved_prototypes,
);
if !prototype_report.is_success() {
@@ -478,6 +504,16 @@ fn load_mission_with_options(
failures: prototype_report.failures.clone(),
});
}
let mission_assets = AssetManager::new(repository)
.prepare_mission_assets(
&prototype_graph.root_prototype_request_spans,
&resolved_prototypes,
)
.map_err(|source| EngineError::AssetPreparation {
mission: request.key.clone(),
source,
})?;
let mission_asset_plan = mission_assets.to_plan();
trace.phases.push(MissionLoadPhase::Assets);
let mut new_runtime_world = new_world(WorldConfig);
@@ -519,6 +555,7 @@ fn load_mission_with_options(
graph_direct_reference_count: prototype_report.direct_reference_count,
graph_unit_component_count: prototype_report.unit_component_count,
graph_root_count: prototype_report.root_count,
asset_visual_count: mission_asset_plan.visual_count,
graph_resolved_count: prototype_report.resolved_count,
graph_mesh_dependency_count: prototype_report.mesh_dependency_count,
graph_failure_count: prototype_report.failures.len(),
@@ -530,6 +567,10 @@ fn load_mission_with_options(
graph_texture_resolved_count: prototype_report.texture_resolved_count,
graph_lightmap_request_count: prototype_report.lightmap_request_count,
graph_lightmap_resolved_count: prototype_report.lightmap_resolved_count,
asset_model_count: mission_asset_plan.model_count,
asset_material_count: mission_asset_plan.material_count,
asset_texture_count: mission_asset_plan.texture_count,
asset_lightmap_count: mission_asset_plan.lightmap_count,
};
engine.world = new_runtime_world;
@@ -540,7 +581,8 @@ fn load_mission_with_options(
build_categories,
prototype_graph,
prototype_report,
resolved_prototypes,
mission_assets,
asset_plan: mission_asset_plan,
});
Ok((summary, trace))
}
@@ -618,13 +660,16 @@ pub fn loaded_prototype_graph_report(engine: &Engine) -> Option<&PrototypeGraphR
engine.loaded.as_ref().map(|state| &state.prototype_report)
}
/// Returns resolved effective prototypes for the loaded mission.
/// Returns the prepared mission asset plan for the loaded mission.
#[must_use]
pub fn loaded_resolved_prototypes(engine: &Engine) -> Option<&[EffectivePrototype]> {
engine
.loaded
.as_ref()
.map(|state| state.resolved_prototypes.as_slice())
pub fn loaded_mission_asset_plan(engine: &Engine) -> Option<&MissionAssetPlan> {
engine.loaded.as_ref().map(|state| &state.asset_plan)
}
/// Returns prepared mission assets for the loaded mission.
#[must_use]
pub fn loaded_mission_assets(engine: &Engine) -> Option<&MissionAssets> {
engine.loaded.as_ref().map(|state| &state.mission_assets)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -716,49 +761,6 @@ fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, Engi
})
}
fn decode_nres(
vfs: &Arc<dyn Vfs>,
path: &NormalizedPath,
) -> Result<fparkan_nres::NresDocument, EngineError> {
let bytes = read_vfs(vfs, path)?;
fparkan_nres::decode(bytes, fparkan_nres::ReadProfile::Compatible).map_err(|source| {
EngineError::Nres {
path: path.as_str().to_string(),
source,
}
})
}
fn terrain_paths(
mission: &MissionDocument,
) -> Result<(NormalizedPath, NormalizedPath), EngineError> {
terrain_paths_from_land_path(&mission.land_path)
}
fn terrain_paths_from_land_path(
land_path: &LpString,
) -> Result<(NormalizedPath, NormalizedPath), EngineError> {
let land_path_raw = String::from_utf8_lossy(&land_path.raw).to_string();
let normalized =
normalize_relative(&land_path.raw, PathPolicy::StrictLegacy).map_err(|source| {
EngineError::Path {
role: "mission land",
value: land_path_raw.clone(),
source,
}
})?;
let Some((parent, _stem)) = normalized.as_str().rsplit_once('/') else {
return Err(EngineError::Path {
role: "mission land",
value: normalized.as_str().to_string(),
source: PathError::Empty,
});
};
let mesh = normalize_engine_path("Land.msh", &format!("{parent}/Land.msh"))?;
let map = normalize_engine_path("Land.map", &format!("{parent}/Land.map"))?;
Ok((mesh, map))
}
#[cfg(test)]
mod tests {
use super::*;
+104 -19
View File
@@ -8,6 +8,10 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
#[cfg(windows)]
use std::os::windows::fs::MetadataExt;
/// VFS metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -110,6 +114,7 @@ impl DirectoryVfs {
struct CachedHostFingerprint {
len: u64,
modified: Option<SystemTime>,
identity: Option<u64>,
fingerprint: Sha256Digest,
}
@@ -120,14 +125,23 @@ impl Vfs for DirectoryVfs {
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
let host = self.host_path(path)?;
if fs::symlink_metadata(&host)
.map_err(VfsError::Io)?
.file_type()
.is_symlink()
let pre_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
if pre_metadata.file_type().is_symlink() || !pre_metadata.is_file() {
return Err(VfsError::Path);
}
let pre_identity = file_identity(&pre_metadata);
let pre_len = pre_metadata.len();
let pre_modified = pre_metadata.modified().ok();
let bytes = fs::read(&host).map_err(VfsError::Io)?;
let post_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
if post_metadata.file_type().is_symlink()
|| !post_metadata.is_file()
|| post_metadata.len() != pre_len
|| post_metadata.modified().ok() != pre_modified
|| file_identity(&post_metadata) != pre_identity
{
return Err(VfsError::Path);
}
let bytes = fs::read(host).map_err(VfsError::Io)?;
Ok(Arc::from(bytes.into_boxed_slice()))
}
@@ -248,7 +262,11 @@ fn metadata_from_host_file_with_cache(
.map_err(|_| VfsError::Path)?
.get(path)
.cloned()
.filter(|cached| cached.len == len && cached.modified == modified)
.filter(|cached| {
cached.len == len
&& cached.modified == modified
&& cached.identity == file_identity(metadata)
})
{
return Ok(VfsMetadata {
len,
@@ -266,6 +284,7 @@ fn metadata_from_host_file_with_cache(
CachedHostFingerprint {
len,
modified,
identity: file_identity(metadata),
fingerprint,
},
);
@@ -275,15 +294,15 @@ fn metadata_from_host_file_with_cache(
/// In-memory VFS.
#[derive(Clone, Debug, Default)]
pub struct MemoryVfs {
files: BTreeMap<String, Arc<[u8]>>,
lookup: BTreeMap<Vec<u8>, Vec<String>>,
files: BTreeMap<Vec<u8>, Arc<[u8]>>,
lookup: BTreeMap<Vec<u8>, Vec<Vec<u8>>>,
}
impl MemoryVfs {
/// Inserts a file.
#[allow(clippy::needless_pass_by_value)]
pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) {
let path = path.as_str().to_string();
let path = path.as_bytes().to_vec();
self.files.insert(path, bytes);
self.rebuild_lookup();
}
@@ -292,7 +311,7 @@ impl MemoryVfs {
self.lookup.clear();
for path in self.files.keys() {
self.lookup
.entry(ascii_lookup_key(path.as_bytes()).0)
.entry(ascii_lookup_key(path).0)
.or_default()
.push(path.clone());
}
@@ -301,20 +320,39 @@ impl MemoryVfs {
}
}
fn resolve_path(&self, path: &NormalizedPath) -> Result<&str, VfsError> {
let key = ascii_lookup_key(path.as_str().as_bytes()).0;
fn resolve_path(&self, path: &NormalizedPath) -> Result<&[u8], VfsError> {
let key = ascii_lookup_key(path.as_bytes()).0;
let matches = self
.lookup
.get(&key)
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
match matches.as_slice() {
[single] => Ok(single.as_str()),
[single] => Ok(single.as_slice()),
[] => Err(VfsError::NotFound(path.as_str().to_string())),
_ => Err(VfsError::Ambiguous(path.as_str().to_string())),
}
}
}
#[cfg(unix)]
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
Some((metadata.dev() as u64).rotate_left(32) ^ metadata.ino())
}
#[cfg(windows)]
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
Some(
(metadata.volume_serial_number() as u64).rotate_left(40)
^ ((metadata.file_index_high() as u64) << 32)
^ metadata.file_index_low() as u64,
)
}
#[cfg(not(any(unix, windows)))]
fn file_identity(_metadata: &fs::Metadata) -> Option<u64> {
None
}
impl Vfs for MemoryVfs {
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
let resolved = self.resolve_path(path)?;
@@ -339,13 +377,9 @@ impl Vfs for MemoryVfs {
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
let mut out = Vec::new();
for (path, bytes) in &self.files {
if path
.as_bytes()
.get(..prefix.as_str().len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_str().as_bytes()))
{
if has_segment_boundary_prefix_bytes(path, prefix.as_bytes()) {
let normalized = fparkan_path::normalize_relative(
path.as_bytes(),
path,
fparkan_path::PathPolicy::StrictLegacy,
)
.map_err(|_| VfsError::Path)?;
@@ -362,6 +396,25 @@ impl Vfs for MemoryVfs {
}
}
fn has_segment_boundary_prefix_bytes(haystack: &[u8], needle: &[u8]) -> bool {
if haystack.len() < needle.len() {
return false;
}
if haystack.len() == needle.len() {
return haystack
.iter()
.zip(needle.iter())
.all(|(left, right)| left.eq_ignore_ascii_case(right));
}
if haystack[needle.len()] != b'/' {
return false;
}
haystack[..needle.len()]
.iter()
.zip(needle.iter())
.all(|(left, right)| left.eq_ignore_ascii_case(right))
}
/// Layered VFS with deterministic first-layer precedence.
#[derive(Clone, Default)]
pub struct OverlayVfs {
@@ -507,6 +560,21 @@ mod tests {
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]
fn directory_vfs_fingerprint_changes_for_same_length_content() {
let root = unique_test_dir("content-fingerprint");
@@ -589,6 +657,23 @@ mod tests {
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]
fn overlay_vfs_uses_first_matching_layer() {
let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path");
+8 -15
View File
@@ -114,23 +114,16 @@ key, configuration, device profile, initial state, input/time script и верс
## Local evidence requests
На текущем рабочем месте закрыты статические, corpus и headless runtime gates.
Для macOS Desktop GL есть только безопасный command/state trace и исторический
одноразовый offscreen pixel probe:
Для локально воспроизводимого Desktop backend подтверждено только command/state trace
в существующем 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
offscreen FBO, выполнил shader-based triangle draw, прочитал RGBA pixels и
сохранил hash capture, но постоянный workspace adapter по-прежнему не создаёт
SDL window, GL context, GPU resources, shader programs, draw calls или present.
Probe не добавляет project-owned `unsafe` в workspace и остаётся только external
evidence request artifact.
Для повышения `S3-GL-001` до `covered` нужен постоянный macOS backend через
выбранную safe facade stack: SDL event/window/context lifecycle, Desktop GL 3.3
shader/buffer/texture/draw/present path, hidden-window/offscreen smoke test и
licensed local model/terrain frame capture.
`S3-GL-001` пока не закрыт: текущая evidence не отражает полноценный
`winit`+`fparkan-render-vulkan` path с real surface/present pipeline.
Для закрытия требования требуется постоянный workspace-владельческий backend на
`winit`/`fparkan-platform-winit` + `fparkan-render-vulkan` с реальным
surface/present pipeline, command/state parity и licensed frame capture.
Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2
backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же
+7 -5
View File
@@ -3,11 +3,13 @@
Baseline command:
```text
env RUSTC=/Users/valentineus/.rustup/toolchains/stable-aarch64-apple-darwin/bin/rustc /opt/homebrew/bin/rustup run stable cargo test --workspace --offline
cargo xtask ci
```
Result on 2026-06-22:
Result on 2026-06-23:
- library and binary unit tests compile and pass after aligning SDL2 versions and pinning `toml` to cached `0.8`;
- doctests fail in this shell because `rustdoc` is not in PATH unless `RUSTDOC` is also set to the real toolchain binary;
- full online dependency resolution is unavailable in the sandbox.
- canonical pipeline now uses a fixed MSRV/toolchain, policy checks,
full-format workspace test command, `clippy`/`doc`/`cargo deny` gates and
typed manifest parsing in `xtask`;
- `rpath`/offline mode is still useful for synthetic local checks;
- full online dependency resolution remains unavailable in the sandbox.
+3 -3
View File
@@ -34,7 +34,7 @@ behavior unit state machines, target and path requests
physics control systems, collision proxies and contacts
animation pose sampling, hierarchy and blending
audio sample cache, sources, listener and buses
render legacy-state compatibility and modern backend
render immutable frame contracts and modern backend
network game message schema plus transport adapters
tools validators, extractors, viewers, captures and editors
```
@@ -103,8 +103,8 @@ CPU assets и GPU resources имеют отдельные бюджеты и от
### Backend adapters
Render, audio, input и network получают отдельные adapters. Legacy compatibility
state живёт выше Vulkan, D3D11 или Metal backend; DirectPlay compatibility живёт
Render, audio, input и network получают отдельные adapters. Compatibility state
живёт вне Vulkan, D3D11 или Metal backend; DirectPlay compatibility живёт
отдельно от modern transport. Так можно заменить платформу, не меняя форматы,
игровую семантику и regression corpus.
+4 -2
View File
@@ -21,7 +21,7 @@ S0-CORPUS-005 covered cargo test -p fparkan-corpus --offline fingerprint_changes
S0-CORPUS-006 covered cargo test -p fparkan-corpus --offline atomic_report_write
S0-CLI-001 covered cargo test -p fparkan-cli --offline stable_exit_codes_are_mapped
S0-CLI-002 covered cargo test -p fparkan-cli --offline accepts_json_format_option archive_json_has_schema_version
S0-GL-001 covered cargo test -p fparkan-platform-sdl -p fparkan-render-gl --offline adapter_boundary_is_project_owned_unsafe_free
S0-GL-001 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents
S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow
S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read
L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
@@ -71,6 +71,8 @@ S1-PATH-005 covered cargo test -p fparkan-path --offline rejects_escape
S1-PATH-006 covered cargo test -p fparkan-path --offline rejects_absolute_drive_and_nul_paths
S1-PATH-007 covered cargo test -p fparkan-path --offline join_under_keeps_normalized_path_below_root
S1-PATH-008 covered cargo test -p fparkan-path --offline original_separators_and_raw_bytes_are_preserved
S1-H02 covered cargo test -p fparkan-path --offline accepts_non_utf8_legacy_bytes
S1-M01 covered cargo test -p fparkan-vfs --offline memory_vfs_list_prefix_is_boundary_safe
S1-RSLI-001 covered cargo test -p fparkan-rsli --offline parses_minimal_empty_library
S1-RSLI-002 covered cargo test -p fparkan-rsli --offline rejects_invalid_header_fields
S1-RSLI-003 covered cargo test -p fparkan-rsli --offline rejects_entry_table_bounds
@@ -222,7 +224,7 @@ S3-RENDER-008 covered cargo test -p fparkan-render --offline recording_backend_s
S3-RENDER-009 covered cargo xtask policy
S3-GL-001 omitted permanent macOS Desktop GL 3.3 adapter is not implemented; historical CGL probe is retained as external evidence only
S3-GL-002 omitted outside the current macOS-focused goal scope; GLES2 remains documented for portable/non-macOS targets
S3-GL-003 covered cargo test -p fparkan-render-gl --offline shader_compile_failure_diagnostic_contains_profile_and_log
S3-GL-003 blocked legacy fparkan-render-gl adapter removed while Vulkan renderer path is being brought in as the stage-3 backend
S3-VIEWER-001 covered cargo test -p fparkan-viewer --offline model_fixture_uses_viewer_service_and_render_commands
S4-ANIM-001 covered cargo test -p fparkan-animation --offline anim_key24_decodes_signed_quaternion
S4-ANIM-002 covered cargo test -p fparkan-animation --offline frame_map_decodes_u16_and_uses_attr_frame_count
1 # Acceptance coverage manifest.
21 S0-CORPUS-006
22 S0-CLI-001
23 S0-CLI-002
24 S0-GL-001
25 S0-LIMIT-001
26 S0-LIMIT-002
27 L1-P1-NRES-001
71 S1-PATH-006
72 S1-PATH-007
73 S1-PATH-008
74 S1-H02
75 S1-M01
76 S1-RSLI-001
77 S1-RSLI-002
78 S1-RSLI-003
224 S3-RENDER-009
225 S3-GL-001
226 S3-GL-002
227 S3-GL-003
228 S3-VIEWER-001
229 S4-ANIM-001
230 S4-ANIM-002
+367
View File
@@ -0,0 +1,367 @@
# Stage 0-2 acceptance IDs
`L0-COPYRIGHT-001`
`L0-P1-001`
`L0-P1-002`
`L0-P2-001`
`L0-P2-002`
`S0-ARCH-001`
`S0-ARCH-002`
`S0-ARCH-003`
`S0-ARCH-004`
`S0-ARCH-005`
`S0-ARCH-006`
`S0-DIAG-001`
`S0-DIAG-002`
`S0-CORPUS-001`
`S0-CORPUS-002`
`S0-CORPUS-003`
`S0-CORPUS-004`
`S0-CORPUS-005`
`S0-CORPUS-006`
`S0-CLI-001`
`S0-CLI-002`
`S0-GL-001`
`S0-LIMIT-001`
`S0-LIMIT-002`
`L1-P1-NRES-001`
`L1-P2-NRES-001`
`L1-P1-NRES-002`
`L1-P2-NRES-002`
`L1-P1-NRES-003`
`L1-P2-NRES-003`
`L1-P1-RSLI-001`
`L1-P2-RSLI-001`
`L1-RSLI-QUIRK-001`
`L1-P1-PATH-001`
`L1-P2-PATH-001`
`S1-NRES-001`
`S1-NRES-002`
`S1-NRES-003`
`S1-NRES-004`
`S1-NRES-005`
`S1-NRES-006`
`S1-NRES-007`
`S1-NRES-008`
`S1-NRES-009`
`S1-NRES-010`
`S1-NRES-020`
`S1-NRES-021`
`S1-NRES-011`
`S1-NRES-012`
`S1-NRES-013`
`S1-NRES-014`
`S1-NRES-015`
`S1-NRES-016`
`S1-NRES-017`
`S1-NRES-018`
`S1-NRES-019`
`S1-NRES-022`
`S1-NRES-023`
`S1-NRES-024`
`S1-NRES-025`
`S1-NRES-PROP-001`
`S1-NRES-PROP-002`
`S1-NRES-FUZZ-001`
`S1-PATH-001`
`S1-PATH-002`
`S1-PATH-003`
`S1-PATH-004`
`S1-PATH-005`
`S1-PATH-006`
`S1-PATH-007`
`S1-PATH-008`
`S1-H02`
`S1-M01`
`S1-RSLI-001`
`S1-RSLI-002`
`S1-RSLI-003`
`S1-RSLI-004`
`S1-RSLI-005`
`S1-RSLI-006`
`S1-RSLI-007`
`S1-RSLI-008`
`S1-RSLI-009`
`S1-RSLI-010`
`S1-RSLI-011`
`S1-RSLI-012`
`S1-RSLI-013`
`S1-RSLI-014`
`S1-RSLI-015`
`S1-RSLI-016`
`S1-RSLI-017`
`S1-RSLI-018`
`S1-RSLI-019`
`S1-RSLI-020`
`S1-RSLI-021`
`S1-RSLI-022`
`S1-RSLI-023`
`S1-RSLI-PROP-001`
`S1-RSLI-FUZZ-001`
`S1-RES-001`
`S1-RES-002`
`S1-RES-003`
`S1-RES-004`
`S1-VFS-001`
`S1-VFS-002`
`S1-VFS-003`
`S1-VFS-004`
`L2-P1-UNIT-001`
`L2-P2-UNIT-001`
`L2-P1-REG-001`
`L2-P2-REG-001`
`L2-P1-GRAPH-001`
`L2-P2-GRAPH-001`
`L2-P1-INHERIT-001`
`L2-P2-INHERIT-001`
`L2-P1-NONGEO-001`
`L2-P2-NONGEO-001`
`L2-P1-GRAPH-002`
`L2-P2-GRAPH-002`
`S2-REG-001`
`S2-REG-002`
`S2-REG-003`
`S2-REG-004`
`S2-UNIT-001`
`S2-UNIT-002`
`S2-UNIT-003`
`S2-UNIT-004`
`S2-UNIT-005`
`S2-UNIT-006`
`S2-UNIT-007`
`S2-PROTO-001`
`S2-PROTO-002`
`S2-PROTO-003`
`S2-PROTO-004`
`S2-PROTO-005`
`S2-PROTO-006`
`S2-PROTO-007`
`S2-PROTO-008`
`S2-PROTO-009`
`S2-PROTO-010`
`S2-PROTO-011`
`S2-PROTO-012`
`S2-PROTO-013`
`S2-PROTO-014`
`S2-GRAPH-001`
`S2-GRAPH-002`
`S2-GRAPH-003`
`S2-GRAPH-004`
`S2-GRAPH-005`
`S2-GRAPH-006`
`S2-PROP-001`
`S2-FUZZ-001`
`L3-P1-MSH-001`
`L3-P2-MSH-001`
`L3-P1-TEXM-001`
`L3-P2-TEXM-001`
`L3-P1-MAT0-001`
`L3-P2-MAT0-001`
`L3-P1-WEAR-001`
`L3-P2-WEAR-001`
`L3-P1-ASSET-001`
`L3-P2-ASSET-001`
`L3-P1-CAPTURE-001`
`L3-P2-CAPTURE-001`
`S3-WEAR-001`
`S3-WEAR-002`
`S3-WEAR-003`
`S3-WEAR-004`
`S3-WEAR-005`
`S3-MAT0-001`
`S3-MAT0-002`
`S3-MAT0-003`
`S3-MAT0-004`
`S3-MAT0-005`
`S3-MAT0-006`
`S3-MSH-001`
`S3-MSH-002`
`S3-MSH-003`
`S3-MSH-004`
`S3-MSH-005`
`S3-MSH-006`
`S3-MSH-007`
`S3-MSH-008`
`S3-MSH-009`
`S3-MSH-010`
`S3-MSH-011`
`S3-MSH-012`
`S3-MSH-013`
`S3-MSH-014`
`S3-MSH-015`
`S3-MSH-016`
`S3-MSH-017`
`S3-MSH-PROP-001`
`S3-MSH-FUZZ-001`
`S3-TEXM-001`
`S3-TEXM-002`
`S3-TEXM-003`
`S3-TEXM-004`
`S3-TEXM-005`
`S3-TEXM-006`
`S3-TEXM-007`
`S3-TEXM-008`
`S3-TEXM-009`
`S3-TEXM-010`
`S3-TEXM-011`
`S3-TEXM-012`
`S3-TEXM-013`
`S3-TEXM-FUZZ-001`
`S3-MAT0-007`
`S3-MAT-RESOLVE-001`
`S3-MAT-RESOLVE-002`
`S3-MAT-RESOLVE-003`
`S3-MAT-RESOLVE-004`
`S3-MAT-RESOLVE-005`
`S3-RENDER-001`
`S3-RENDER-002`
`S3-RENDER-003`
`S3-RENDER-004`
`S3-RENDER-005`
`S3-RENDER-006`
`S3-RENDER-007`
`S3-RENDER-008`
`S3-RENDER-009`
`S3-GL-001`
`S3-GL-002`
`S3-GL-003`
`S3-VIEWER-001`
`S4-ANIM-001`
`S4-ANIM-002`
`S4-ANIM-003`
`S4-ANIM-004`
`S4-ANIM-005`
`S4-ANIM-006`
`S4-ANIM-007`
`S4-ANIM-008`
`S4-ANIM-009`
`S4-ANIM-010`
`S4-ANIM-011`
`S4-ANIM-012`
`S4-ANIM-013`
`S4-ANIM-014`
`S4-ANIM-PROP-001`
`S4-MAT-001`
`S4-MAT-002`
`S4-MAT-003`
`S4-MAT-004`
`S4-MAT-005`
`S4-MAT-006`
`S4-FX-001`
`S4-FX-002`
`S4-FX-011`
`S4-FX-012`
`S4-FX-013`
`S4-FX-014`
`S4-FX-015`
`S4-FX-016`
`S4-FX-017`
`S4-FX-018`
`S4-FX-019`
`S4-FX-020`
`S4-FX-021`
`S4-FX-022`
`S4-FX-023`
`S4-FX-024`
`S4-FX-FUZZ-001`
`L4-P1-ANIM-001`
`L4-P2-ANIM-001`
`L4-P1-CAPTURE-001`
`L4-P2-CAPTURE-001`
`L4-P1-FX-001`
`L4-P2-FX-001`
`L4-P1-FX-002`
`L4-P2-FX-002`
`L4-FX-OP6-001`
`L4-P1-EFFECT-001`
`L4-P2-EFFECT-001`
`S5-LMESH-001`
`S5-LMESH-002`
`S5-LMESH-003`
`S5-LMESH-004`
`S5-LMESH-005`
`S5-LMESH-006`
`S5-LMESH-007`
`S5-LMESH-008`
`S5-LMESH-009`
`S5-LMAP-001`
`S5-LMAP-002`
`S5-LMAP-003`
`S5-LMAP-004`
`S5-LMAP-005`
`S5-LMAP-006`
`S5-LMAP-007`
`S5-LMAP-008`
`S5-LMAP-009`
`S5-LMAP-010`
`S5-LMAP-011`
`S5-TERRAIN-001`
`S5-TERRAIN-002`
`S5-TERRAIN-003`
`S5-TERRAIN-004`
`S5-TMA-001`
`S5-TMA-002`
`S5-TMA-003`
`S5-TMA-004`
`S5-TMA-005`
`S5-TMA-006`
`S5-TMA-007`
`S5-TMA-008`
`S5-TMA-009`
`S5-TMA-010`
`S5-TMA-011`
`S5-TMA-012`
`S5-TMA-013`
`S5-TMA-014`
`S5-TMA-015`
`S5-TMA-016`
`S5-TMA-017`
`S5-TMA-PROP-001`
`S5-TMA-FUZZ-001`
`S5-LOAD-001`
`S5-LOAD-002`
`S5-LOAD-003`
`S5-LOAD-004`
`S5-LOAD-005`
`S5-LOAD-006`
`S5-LOAD-007`
`S5-LOAD-008`
`S5-LOAD-009`
`S5-LOAD-010`
`S5-WORLD-001`
`S5-WORLD-002`
`S5-WORLD-003`
`S5-WORLD-004`
`S5-WORLD-005`
`S5-WORLD-006`
`S5-WORLD-007`
`S5-WORLD-008`
`S5-WORLD-009`
`S5-WORLD-010`
`S5-WORLD-011`
`S5-WORLD-012`
`S5-WORLD-013`
`S5-WORLD-014`
`S5-WORLD-015`
`S5-WORLD-016`
`S5-WORLD-017`
`S5-WORLD-018`
`S5-WORLD-019`
`S5-WORLD-PROP-001`
`L5-P1-LMESH-001`
`L5-P2-LMESH-001`
`L5-P1-LMAP-001`
`L5-P2-LMAP-001`
`L5-LMAP-POLY-001`
`L5-P1-TMA-001`
`L5-P2-TMA-001`
`L5-P1-MISSION-001`
`L5-P2-MISSION-001`
`L5-P1-MISSION-002`
`L5-P2-MISSION-002`
`L5-P1-HEADLESS-001`
`L5-P2-HEADLESS-001`
`L5-P1-RENDER-001`
`L5-P2-RENDER-001`
`L3-DEVICE-001`
`L5-RG40-001`
File diff suppressed because it is too large Load Diff
+11 -8
View File
@@ -1,20 +1,23 @@
# Render Parity Dataset
This folder stores parity-test input for `crates/render-parity`.
This folder stores parity-test input for legacy render comparison workflows.
- `cases.toml`: list of deterministic render cases.
- `reference/*.png`: baseline frames captured from the original renderer.
Expected workflow:
1. Capture baseline PNG frames from original game/editor for each case.
1. Capture baseline PNG frames for each case.
2. Add entries to `cases.toml`.
3. Run:
3. Run the acceptance renderer capture workflow with fixed profiles and compare
output captures out-of-tree.
```bash
cargo run -p render-parity -- \
--manifest parity/cases.toml \
--output-dir target/render-parity/current
```text
1) Prepare `cases.toml` and baseline captures.
2) Run `fparkan-game` (or dedicated acceptance runner) with fixed seed.
3) Compare outputs against baseline in dedicated comparison tooling.
```
On failure, diff images are saved to `target/render-parity/current/diff`.
The `render-parity` crate is no longer present as a standalone runner in this
workspace snapshot; parity evidence is now produced through the acceptance
artifacts and stage audit tooling.
+8 -2
View File
@@ -1,3 +1,9 @@
[toolchain]
channel = "stable"
components = ["clippy", "rustfmt"]
channel = "1.87.0"
components = ["clippy", "rustfmt", "rust-docs"]
targets = [
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
"aarch64-apple-darwin",
"x86_64-apple-darwin",
]
+3
View File
@@ -7,6 +7,9 @@ repository.workspace = true
[dependencies]
fparkan-corpus = { path = "../crates/fparkan-corpus" }
cargo_metadata = "0.21"
serde = { version = "1.0", features = ["derive"] }
toml = "0.8"
[lints]
workspace = true
+344 -172
View File
@@ -2,7 +2,9 @@
#![allow(clippy::print_stderr, clippy::print_stdout)]
//! Repository automation for `FParkan`.
use cargo_metadata::MetadataCommand;
use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::fmt::Write as _;
@@ -13,6 +15,9 @@ use std::process::Command;
const CORPORA_MANIFEST_ENV: &str = "FPARKAN_CORPORA_MANIFEST";
const PART1_ROOT_ENV: &str = "FPARKAN_CORPUS_PART1_ROOT";
const PART2_ROOT_ENV: &str = "FPARKAN_CORPUS_PART2_ROOT";
const CI_ACCEPTANCE_ROADMAP: &str = "fixtures/acceptance/stage_0_2_roadmap.md";
const CI_ACCEPTANCE_COVERAGE: &str = "fixtures/acceptance/coverage.tsv";
const CI_ACCEPTANCE_REPORT: &str = "target/fparkan/acceptance/stage-0-2-audit.json";
fn main() {
let args = std::env::args().skip(1).collect::<Vec<_>>();
@@ -29,10 +34,27 @@ fn main() {
fn run(args: &[String]) -> Result<(), String> {
match args {
[cmd] if cmd == "ci" => {
run_rustfmt_check(Path::new("."))?;
run_cargo_fmt_check()?;
run_policy(Path::new("."))?;
cargo(&["test", "--workspace", "--locked", "--offline"])?;
clippy_rustup(&["--workspace", "--locked", "--offline"])?;
cargo(&["test", "--workspace", "--all-targets", "--all-features", "--locked"])?;
cargo(&[
"clippy",
"--workspace",
"--all-targets",
"--all-features",
"--locked",
"--",
"-D",
"warnings",
])?;
run_cargo_doc()?;
run_cargo_deny()?;
run_acceptance_audit(&AuditOptions {
roadmap: PathBuf::from(CI_ACCEPTANCE_ROADMAP),
coverage: PathBuf::from(CI_ACCEPTANCE_COVERAGE),
out: PathBuf::from(CI_ACCEPTANCE_REPORT),
strict: true,
})?;
Ok(())
}
[cmd] if cmd == "policy" => run_policy(Path::new(".")),
@@ -115,63 +137,53 @@ fn cargo_with_env(args: &[&str], envs: &[(&str, &Path)]) -> Result<(), String> {
}
}
fn clippy_rustup(args: &[&str]) -> Result<(), String> {
let rustup = std::env::var_os("RUSTUP").unwrap_or_else(|| "rustup".into());
let status = Command::new(rustup)
.args(["run", "stable", "cargo-clippy"])
.args(args)
.status()
.map_err(|err| format!("failed to run cargo-clippy through rustup: {err}"))?;
if status.success() {
Ok(())
} else {
Err(format!("cargo-clippy exited with {status}"))
}
}
fn run_rustfmt_check(root: &Path) -> Result<(), String> {
let mut files = Vec::new();
collect_rust_files(root, &mut files)?;
if files.is_empty() {
return Ok(());
}
let rustup = std::env::var_os("RUSTUP").unwrap_or_else(|| "rustup".into());
let status = Command::new(rustup)
.args(["run", "stable", "rustfmt", "--check"])
.args(files)
fn run_cargo_fmt_check() -> Result<(), String> {
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let status = Command::new(cargo)
.args(["fmt", "--all", "--", "--check"])
.status()
.map_err(|err| format!("failed to run rustfmt: {err}"))?;
if status.success() {
Ok(())
} else {
Err(format!("rustfmt exited with {status}"))
Err(format!("cargo fmt exited with {status}"))
}
}
fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
let entries = fs::read_dir(dir).map_err(|err| format!("{}: {err}", dir.display()))?;
for entry in entries {
let entry = entry.map_err(|err| format!("{}: {err}", dir.display()))?;
let path = entry.path();
if should_skip_policy_path(&path) {
continue;
}
let file_type = entry
.file_type()
.map_err(|err| format!("{}: {err}", path.display()))?;
if file_type.is_dir() {
collect_rust_files(&path, out)?;
} else if file_type.is_file()
&& path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext == "rs")
{
out.push(path);
}
fn run_cargo_deny() -> Result<(), String> {
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let status = Command::new(cargo)
.args([
"deny",
"check",
"--workspace",
"--all-features",
"advisories",
"bans",
"licenses",
"sources",
])
.status()
.map_err(|err| format!("failed to run cargo-deny: {err}"))?;
if status.success() {
Ok(())
} else {
Err(format!("cargo-deny exited with {status}"))
}
}
fn run_cargo_doc() -> Result<(), String> {
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let status = Command::new(cargo)
.args(["doc", "--workspace", "--all-features", "--locked", "--no-deps"])
.env("RUSTDOCFLAGS", "-D warnings -D rustdoc::broken_intra_doc_links")
.status()
.map_err(|err| format!("failed to run cargo doc: {err}"))?;
if status.success() {
Ok(())
} else {
Err(format!("cargo doc exited with {status}"))
}
Ok(())
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -197,67 +209,77 @@ fn load_licensed_roots(manifest: Option<&Path>) -> Result<LicensedCorpusRoots, S
format!(
"licensed tests require --manifest or {CORPORA_MANIFEST_ENV}=<absolute corpora.toml>"
)
})?;
})?;
parse_licensed_manifest(&manifest)
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct LicensedManifest {
schema: Option<u8>,
#[serde(rename = "corpus")]
corpora: Vec<CorpusEntry>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct CorpusEntry {
id: String,
kind: CorpusKind,
root: String,
expected_profile: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
enum CorpusKind {
Part1,
Part2,
}
fn parse_licensed_manifest(path: &Path) -> Result<LicensedCorpusRoots, String> {
let text = fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?;
let manifest: LicensedManifest = toml::from_str(&text)
.map_err(|err| format!("failed to parse {}: {err}", path.display()))?;
if manifest.schema.is_some_and(|version| version != 1) {
return Err(format!(
"unsupported corpora manifest schema {} (expected 1)",
manifest.schema.unwrap_or(1)
));
}
let mut part1 = None;
let mut part2 = None;
let mut current_kind: Option<String> = None;
let mut current_root: Option<PathBuf> = None;
for raw_line in text.lines() {
let line = raw_line.split('#').next().unwrap_or_default().trim();
if line.is_empty() {
continue;
for entry in manifest.corpora {
match entry.kind {
CorpusKind::Part1 => {
let root = PathBuf::from(entry.root);
assign_manifest_root(&mut part1, root, "part1")?;
}
CorpusKind::Part2 => {
let root = PathBuf::from(entry.root);
assign_manifest_root(&mut part2, root, "part2")?;
}
}
if line == "[[corpus]]" {
flush_manifest_entry(&mut part1, &mut part2, &mut current_kind, &mut current_root)?;
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
match key {
"kind" => current_kind = Some(parse_manifest_string(value.trim())?),
"root" => current_root = Some(PathBuf::from(parse_manifest_string(value.trim())?)),
_ => {}
if entry.expected_profile.is_none() {
return Err(format!(
"{}: corpus entry '{}' must define expected_profile",
path.display(),
entry.id
));
}
}
flush_manifest_entry(&mut part1, &mut part2, &mut current_kind, &mut current_root)?;
let roots = LicensedCorpusRoots {
part1: part1.ok_or_else(|| "licensed manifest is missing kind = \"part1\"".to_string())?,
part2: part2.ok_or_else(|| "licensed manifest is missing kind = \"part2\"".to_string())?,
part1: part1.ok_or_else(|| "licensed manifest is missing part1 corpus entry".to_string())?,
part2: part2.ok_or_else(|| "licensed manifest is missing part2 corpus entry".to_string())?,
};
validate_licensed_part("part1", &roots.part1)?;
validate_licensed_part("part2", &roots.part2)?;
Ok(roots)
}
fn flush_manifest_entry(
part1: &mut Option<PathBuf>,
part2: &mut Option<PathBuf>,
current_kind: &mut Option<String>,
current_root: &mut Option<PathBuf>,
) -> Result<(), String> {
let Some(kind) = current_kind.take() else {
*current_root = None;
return Ok(());
};
let root = current_root
.take()
.ok_or_else(|| format!("licensed manifest entry {kind} is missing root"))?;
match kind.as_str() {
"part1" => assign_manifest_root(part1, root, "part1"),
"part2" => assign_manifest_root(part2, root, "part2"),
_ => Ok(()),
}
}
fn assign_manifest_root(
target: &mut Option<PathBuf>,
root: PathBuf,
@@ -269,18 +291,6 @@ fn assign_manifest_root(
Ok(())
}
fn parse_manifest_string(value: &str) -> Result<String, String> {
let trimmed = value.trim();
if let Some(quoted) = trimmed
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
{
Ok(quoted.to_string())
} else {
Err(format!("manifest value must be a quoted string: {trimmed}"))
}
}
fn validate_licensed_part(kind: &str, root: &Path) -> Result<(), String> {
if root.is_dir() {
Ok(())
@@ -400,27 +410,24 @@ fn validate_cargo_metadata(root: &Path, failures: &mut Vec<String>) -> Result<()
if !manifest.exists() {
return Ok(());
}
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let output = Command::new(cargo)
.args([
"metadata",
"--format-version",
"1",
"--offline",
"--locked",
"--no-deps",
"--manifest-path",
])
.arg(&manifest)
.output()
.map_err(|err| format!("failed to run cargo metadata: {err}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let metadata = MetadataCommand::new()
.manifest_path(&manifest)
.no_deps(true)
.other_options(["--offline".to_string(), "--locked".to_string()])
.exec()
.map_err(|error| {
format!(
"{}: cargo metadata failed: {}",
manifest.display(),
error
)
})?;
if metadata.workspace_members.is_empty() {
failures.push(format!(
"{}: cargo metadata failed: {}",
manifest.display(),
stderr.trim()
"{}: cargo metadata produced no workspace members",
manifest.display()
));
return Ok(());
}
Ok(())
}
@@ -490,34 +497,173 @@ fn validate_dependency_boundaries(root: &Path, failures: &mut Vec<String>) -> Re
let Some(package) = parse_package_name(&text) else {
continue;
};
if is_removed_legacy_adapter_manifest(root, &manifest) {
failures.push(format!(
"{}: legacy SDL/OpenGL adapter crate must be removed: {package}",
manifest.display()
));
continue;
}
let dependencies = parse_manifest_dependencies(&text);
if is_domain_manifest(root, &manifest) {
if !is_adapter_like_package(&package) {
for dependency in &dependencies {
if is_forbidden_domain_dependency(dependency) {
if is_forbidden_gui_dependency(dependency) {
failures.push(format!(
"{}: domain package {package} depends on forbidden GUI/adapter package {dependency}",
"{}: package {package} depends on forbidden GUI/adapter package {dependency}",
manifest.display()
));
}
}
}
if package == "fparkan-headless" {
if is_app_package(&package) {
if let Some(forbidden) = first_forbidden_parser_dependency(&dependencies) {
failures.push(format!(
"{}: app package {package} depends on parser crate {forbidden}",
manifest.display()
));
}
for dependency in &dependencies {
if matches!(
dependency.as_str(),
"fparkan-platform-sdl" | "fparkan-render-gl"
) {
if is_forbidden_runtime_bridge_dependency(dependency) {
failures.push(format!(
"{}: fparkan-headless depends on forbidden platform/render adapter {dependency}",
"{}: app package {package} depends on forbidden bridge dependency {dependency}",
manifest.display()
));
}
}
}
if package == "fparkan-runtime" {
if let Some(forbidden) = first_forbidden_parser_dependency(&dependencies) {
failures.push(format!(
"{}: runtime package {package} depends on parser crate {forbidden}",
manifest.display()
));
}
if let Some(forbidden) = first_forbidden_platform_bridge_dependency(&dependencies) {
failures.push(format!(
"{}: runtime package {package} depends on forbidden platform/driver crate {forbidden}",
manifest.display()
));
}
}
if package == "fparkan-prototype" {
if let Some(forbidden) = first_forbidden_visual_dependency(&dependencies) {
failures.push(format!(
"{}: prototype package {package} depends on forbidden visual parser {forbidden}",
manifest.display()
));
}
}
}
Ok(())
}
fn is_app_package(package: &str) -> bool {
matches!(
package,
"fparkan-cli" | "fparkan-game" | "fparkan-headless" | "fparkan-viewer"
)
}
fn is_adapter_like_package(package: &str) -> bool {
matches!(
package,
"fparkan-platform-winit" | "fparkan-render-vulkan"
)
}
fn first_forbidden_parser_dependency(dependencies: &BTreeSet<String>) -> Option<&str> {
[
"fparkan-msh",
"fparkan-nres",
"fparkan-rsli",
"fparkan-terrain-format",
"fparkan-texm",
"fparkan-mission-format",
"fparkan-material",
"fparkan-fx",
]
.iter()
.find_map(|forbidden| {
if dependencies.contains(*forbidden) {
Some(*forbidden)
} else {
None
}
})
}
fn first_forbidden_visual_dependency(dependencies: &BTreeSet<String>) -> Option<&str> {
[
"fparkan-msh",
"fparkan-material",
"fparkan-texm",
"fparkan-fx",
"fparkan-terrain-format",
]
.iter()
.find_map(|forbidden| {
if dependencies.contains(*forbidden) {
Some(*forbidden)
} else {
None
}
})
}
fn first_forbidden_platform_bridge_dependency(dependencies: &BTreeSet<String>) -> Option<&str> {
[
"fparkan-platform-winit",
"fparkan-render-vulkan",
"winit",
"ash",
"ash-window",
]
.iter()
.find_map(|forbidden| {
if dependencies.contains(*forbidden) {
Some(*forbidden)
} else {
None
}
})
}
fn is_forbidden_runtime_bridge_dependency(dependency: &str) -> bool {
matches!(
dependency,
"fparkan-platform-winit" | "fparkan-render-vulkan" | "winit" | "ash" | "ash-window"
)
}
fn is_forbidden_domain_dependency(dependency: &str) -> bool {
matches!(
dependency, "fparkan-cli"
| "fparkan-game"
| "fparkan-headless"
| "fparkan-viewer"
| "fparkan-platform-sdl"
| "fparkan-render-gl"
| "sdl2"
| "gl"
| "glow"
| "glium"
| "glutin"
)
}
fn is_forbidden_gui_dependency(dependency: &str) -> bool {
is_forbidden_domain_dependency(dependency) || is_forbidden_platform_dependency(dependency)
}
fn is_forbidden_platform_dependency(dependency: &str) -> bool {
matches!(
dependency,
"fparkan-platform-winit" | "fparkan-render-vulkan" | "winit" | "ash" | "ash-window"
)
}
fn collect_cargo_manifests(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
let entries = fs::read_dir(dir).map_err(|err| format!("{}: {err}", dir.display()))?;
for entry in entries {
@@ -610,30 +756,10 @@ fn parse_toml_string_value(line: &str) -> Option<String> {
Some(value.trim_matches('"').to_string())
}
fn is_domain_manifest(root: &Path, manifest: &Path) -> bool {
let relative = manifest.strip_prefix(root).unwrap_or(manifest);
relative
.components()
.next()
.is_some_and(|component| component.as_os_str() == "crates")
}
fn is_forbidden_domain_dependency(dependency: &str) -> bool {
matches!(
dependency,
"fparkan-platform-sdl"
| "fparkan-render-gl"
| "fparkan-cli"
| "fparkan-game"
| "fparkan-headless"
| "fparkan-viewer"
| "sdl2"
| "gl"
| "glow"
| "glium"
| "glutin"
| "winit"
)
fn is_removed_legacy_adapter_manifest(root: &Path, manifest: &Path) -> bool {
let normalized = manifest.strip_prefix(root).unwrap_or(manifest);
normalized.starts_with("adapters/fparkan-platform-sdl")
|| normalized.starts_with("adapters/fparkan-render-gl")
}
fn scan_policy_dir(dir: &Path, failures: &mut Vec<String>) -> Result<(), String> {
@@ -752,18 +878,27 @@ fn scan_policy_file(path: &Path, failures: &mut Vec<String>) -> Result<(), Strin
path.display()
));
}
let mut previous_line_has_safety_comment = false;
for (index, line) in text.lines().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with("//!") || trimmed.starts_with("///") {
if is_comment_line(trimmed) {
previous_line_has_safety_comment = has_safety_comment(trimmed);
continue;
}
if trimmed.is_empty() {
previous_line_has_safety_comment = false;
continue;
}
if contains_unsafe_construct(trimmed) {
failures.push(format!(
"{}:{}: unsafe construct in workspace source",
path.display(),
index + 1
));
if !is_authorized_unsafe_construct(path, trimmed, previous_line_has_safety_comment) {
failures.push(format!(
"{}:{}: unsafe construct in workspace source",
path.display(),
index + 1
));
}
}
previous_line_has_safety_comment = false;
}
Ok(())
}
@@ -775,6 +910,34 @@ fn contains_unsafe_construct(line: &str) -> bool {
|| line.contains(concat!("extern ", "\"C\""))
}
fn is_comment_line(line: &str) -> bool {
line.starts_with("//")
|| line.starts_with("//!")
|| line.starts_with("///")
}
fn has_safety_comment(line: &str) -> bool {
line.contains("SAFETY:")
}
const AUDITED_UNSAFE_SOURCE_FILES: &[&str] = &["adapters/fparkan-render-vulkan/src/lib.rs"];
fn is_audited_unsafe_source(path: &Path) -> bool {
let as_path = path.as_os_str().to_string_lossy();
AUDITED_UNSAFE_SOURCE_FILES.iter().any(|candidate| as_path.ends_with(candidate))
}
fn is_authorized_unsafe_construct(
path: &Path,
line: &str,
previous_line_has_safety_comment: bool,
) -> bool {
if !is_audited_unsafe_source(path) {
return false;
}
previous_line_has_safety_comment || has_safety_comment(line)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Stage {
All,
@@ -805,8 +968,8 @@ const ALL_WORKSPACE_PACKAGES: &[&str] = &[
"fparkan-texm",
"fparkan-vfs",
"fparkan-world",
"fparkan-platform-sdl",
"fparkan-render-gl",
"fparkan-platform-winit",
"fparkan-render-vulkan",
"fparkan-cli",
"fparkan-game",
"fparkan-headless",
@@ -1033,11 +1196,11 @@ fn run_acceptance_audit(options: &AuditOptions) -> Result<(), String> {
fs::write(&options.out, render_audit_json(&audit))
.map_err(|err| format!("{}: {err}", options.out.display()))?;
println!("{}", options.out.display());
let unverified = audit.unverified();
if options.strict && (!unverified.is_empty() || !audit.unknown_coverage.is_empty()) {
let strict_failures = audit.strict_failures();
if options.strict && (!strict_failures.is_empty() || !audit.unknown_coverage.is_empty()) {
Err(format!(
"acceptance coverage incomplete: {} unverified, {} unknown",
unverified.len(),
"acceptance coverage incomplete: {} strict failures, {} unknown",
strict_failures.len(),
audit.unknown_coverage.len()
))
} else {
@@ -1093,6 +1256,14 @@ impl AcceptanceAudit {
.cloned()
.collect()
}
fn strict_failures(&self) -> Vec<String> {
self.partial
.iter()
.chain(&self.missing)
.cloned()
.collect()
}
}
fn extract_acceptance_ids(text: &str) -> BTreeSet<String> {
@@ -1666,7 +1837,7 @@ fparkan-render = { path = "../fparkan-render" }
"quoted-dep" = "1"
[dev-dependencies]
fparkan-render-gl = { path = "../../adapters/fparkan-render-gl" }
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
"#;
assert_eq!(
@@ -1676,13 +1847,14 @@ fparkan-render-gl = { path = "../../adapters/fparkan-render-gl" }
let deps = parse_manifest_dependencies(manifest);
assert!(deps.contains("fparkan-render"));
assert!(deps.contains("quoted-dep"));
assert!(deps.contains("fparkan-render-gl"));
assert!(deps.contains("fparkan-render-vulkan"));
}
#[test]
fn detects_forbidden_domain_dependencies() {
assert!(is_forbidden_domain_dependency("fparkan-render-gl"));
assert!(!is_forbidden_domain_dependency("fparkan-render-vulkan"));
assert!(is_forbidden_domain_dependency("sdl2"));
assert!(is_forbidden_domain_dependency("fparkan-platform-sdl"));
assert!(!is_forbidden_domain_dependency("fparkan-render"));
assert!(!is_forbidden_domain_dependency("fparkan-platform"));
}