Compare commits
29
Commits
83d763dd70
...
d41add32c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d41add32c4
|
||
|
|
159731664f
|
||
|
|
e6b7fa1896
|
||
|
|
0e127117e9
|
||
|
|
4d19728c39
|
||
|
|
54f07ee3be
|
||
|
|
ed2b540abf
|
||
|
|
00ae9067d8
|
||
|
|
c71e706d69
|
||
|
|
aa2133d82b
|
||
|
|
71ead678c0
|
||
|
|
f15ea95bf2
|
||
|
|
99bcbf388f
|
||
|
|
227d95fc49
|
||
|
|
dceea70122
|
||
|
|
fd452f6016
|
||
|
|
1d0244c3e4
|
||
|
|
5d9e1cbe38
|
||
|
|
0e76c2ed7c
|
||
|
|
4c1edef21b
|
||
|
|
e6778d43af
|
||
|
|
ec8f6599fc
|
||
|
|
f5fae8e84a
|
||
|
|
a0a4089e4b
|
||
|
|
dc7e72961a
|
||
|
|
8ea1fd5c18
|
||
|
|
69c032acca
|
||
|
|
9cc24e715d
|
||
|
|
f8e447ffee
|
@@ -0,0 +1,90 @@
|
|||||||
|
name: fparkan-ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
msrv-backend-neutral:
|
||||||
|
name: MSRV backend-neutral crates
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: 1.87.0
|
||||||
|
- name: Test backend-neutral crates
|
||||||
|
run: >
|
||||||
|
cargo test
|
||||||
|
-p fparkan-animation
|
||||||
|
-p fparkan-binary
|
||||||
|
-p fparkan-corpus
|
||||||
|
-p fparkan-diagnostics
|
||||||
|
-p fparkan-fx
|
||||||
|
-p fparkan-inspection
|
||||||
|
-p fparkan-material
|
||||||
|
-p fparkan-mission-format
|
||||||
|
-p fparkan-msh
|
||||||
|
-p fparkan-nres
|
||||||
|
-p fparkan-path
|
||||||
|
-p fparkan-platform
|
||||||
|
-p fparkan-prototype
|
||||||
|
-p fparkan-render
|
||||||
|
-p fparkan-resource
|
||||||
|
-p fparkan-rsli
|
||||||
|
-p fparkan-runtime
|
||||||
|
-p fparkan-terrain
|
||||||
|
-p fparkan-terrain-format
|
||||||
|
-p fparkan-texm
|
||||||
|
-p fparkan-vfs
|
||||||
|
-p fparkan-world
|
||||||
|
--all-targets
|
||||||
|
--locked
|
||||||
|
|
||||||
|
stage0-matrix:
|
||||||
|
name: Stage 0-2 CI (${{ matrix.os }})
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
smoke_platform: linux
|
||||||
|
- os: windows-latest
|
||||||
|
smoke_platform: windows
|
||||||
|
- os: macos-latest
|
||||||
|
smoke_platform: macos
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain-file: rust-toolchain.toml
|
||||||
|
- name: Install cargo-deny
|
||||||
|
run: cargo install cargo-deny --locked
|
||||||
|
- name: Run canonical CI gate
|
||||||
|
run: cargo xtask ci
|
||||||
|
- name: Record native Vulkan smoke status
|
||||||
|
if: always()
|
||||||
|
shell: bash
|
||||||
|
run: >
|
||||||
|
cargo run -p fparkan-vulkan-smoke --locked --
|
||||||
|
--platform "${{ matrix.smoke_platform }}"
|
||||||
|
--out "target/fparkan/native-smoke/${{ runner.os }}.json"
|
||||||
|
--status blocked
|
||||||
|
--probe-surface
|
||||||
|
--reason "native Vulkan smoke runner is not enabled on this CI lane yet"
|
||||||
|
- name: Upload acceptance evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: stage-0-2-acceptance-${{ matrix.os }}
|
||||||
|
path: |
|
||||||
|
target/fparkan/acceptance/stage-0-2-audit.json
|
||||||
|
target/fparkan/native-smoke/*.json
|
||||||
|
if-no-files-found: ignore
|
||||||
Generated
+2316
-18
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -7,6 +7,7 @@ members = [
|
|||||||
"crates/fparkan-corpus",
|
"crates/fparkan-corpus",
|
||||||
"crates/fparkan-diagnostics",
|
"crates/fparkan-diagnostics",
|
||||||
"crates/fparkan-fx",
|
"crates/fparkan-fx",
|
||||||
|
"crates/fparkan-inspection",
|
||||||
"crates/fparkan-material",
|
"crates/fparkan-material",
|
||||||
"crates/fparkan-mission-format",
|
"crates/fparkan-mission-format",
|
||||||
"crates/fparkan-msh",
|
"crates/fparkan-msh",
|
||||||
@@ -24,11 +25,12 @@ members = [
|
|||||||
"crates/fparkan-texm",
|
"crates/fparkan-texm",
|
||||||
"crates/fparkan-vfs",
|
"crates/fparkan-vfs",
|
||||||
"crates/fparkan-world",
|
"crates/fparkan-world",
|
||||||
"adapters/fparkan-platform-sdl",
|
"adapters/fparkan-platform-winit",
|
||||||
"adapters/fparkan-render-gl",
|
"adapters/fparkan-render-vulkan",
|
||||||
"apps/fparkan-cli",
|
"apps/fparkan-cli",
|
||||||
"apps/fparkan-game",
|
"apps/fparkan-game",
|
||||||
"apps/fparkan-headless",
|
"apps/fparkan-headless",
|
||||||
|
"apps/fparkan-vulkan-smoke",
|
||||||
"apps/fparkan-viewer",
|
"apps/fparkan-viewer",
|
||||||
"xtask",
|
"xtask",
|
||||||
]
|
]
|
||||||
@@ -36,6 +38,7 @@ members = [
|
|||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
rust-version = "1.87"
|
||||||
license = "GPL-2.0-only"
|
license = "GPL-2.0-only"
|
||||||
repository = "https://github.com/valentineus/fparkan"
|
repository = "https://github.com/valentineus/fparkan"
|
||||||
|
|
||||||
|
|||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-1
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "fparkan-platform-sdl"
|
name = "fparkan-platform-winit"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
@@ -7,6 +7,8 @@ repository.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||||
|
raw-window-handle = "0.6"
|
||||||
|
winit = "0.30"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
//! Minimal `winit`-backed platform adapter shim.
|
||||||
|
|
||||||
|
use fparkan_platform::{
|
||||||
|
EventSource, MonotonicClock, MonotonicInstant, NativeWindowHandles, PhysicalSize,
|
||||||
|
PlatformError, PlatformEvent, RenderRequest, WindowHandle, WindowPort,
|
||||||
|
};
|
||||||
|
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use winit::application::ApplicationHandler;
|
||||||
|
use winit::dpi::PhysicalSize as WinitPhysicalSize;
|
||||||
|
use winit::event::{Event, MouseButton, WindowEvent};
|
||||||
|
use winit::event_loop::{ActiveEventLoop, EventLoop};
|
||||||
|
use winit::platform::scancode::PhysicalKeyExtScancode;
|
||||||
|
use winit::window::{Window, WindowId};
|
||||||
|
|
||||||
|
static NEXT_WINDOW_HANDLE_ID: AtomicU64 = AtomicU64::new(1);
|
||||||
|
const DEFAULT_SMOKE_WIDTH: u32 = 1280;
|
||||||
|
const DEFAULT_SMOKE_HEIGHT: u32 = 720;
|
||||||
|
|
||||||
|
fn next_window_id() -> u64 {
|
||||||
|
NEXT_WINDOW_HANDLE_ID.fetch_add(1, Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple monotonic clock for windowing abstractions.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct WinitClock;
|
||||||
|
|
||||||
|
impl MonotonicClock for WinitClock {
|
||||||
|
fn now(&self) -> MonotonicInstant {
|
||||||
|
let duration = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
MonotonicInstant(duration.as_millis().try_into().unwrap_or(u64::MAX))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event source backed by pre-buffered platform events.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct WinitEventSource {
|
||||||
|
queue: VecDeque<PlatformEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WinitEventSource {
|
||||||
|
/// Creates an empty source.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
queue: VecDeque::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes a synthetic event (used by tests and smoke stubs).
|
||||||
|
pub fn push(&mut self, event: PlatformEvent) {
|
||||||
|
self.queue.push_back(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes a mapped native window event.
|
||||||
|
pub fn push_window_event(&mut self, event: &WindowEvent) {
|
||||||
|
match event {
|
||||||
|
WindowEvent::KeyboardInput { event, .. } => {
|
||||||
|
self.queue.push_back(PlatformEvent::KeyboardInput {
|
||||||
|
scancode: event.physical_key.to_scancode().unwrap_or(0),
|
||||||
|
pressed: event.state.is_pressed(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
WindowEvent::MouseInput { state, button, .. } => {
|
||||||
|
self.queue.push_back(PlatformEvent::MouseInput {
|
||||||
|
button: mouse_button_code(*button),
|
||||||
|
pressed: state.is_pressed(),
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
WindowEvent::CursorMoved { position, .. } => {
|
||||||
|
self.queue.push_back(PlatformEvent::CursorMoved {
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
WindowEvent::Resized(size) => {
|
||||||
|
self.queue.push_back(PlatformEvent::Resize {
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
WindowEvent::Focused(focused) => {
|
||||||
|
self.queue
|
||||||
|
.push_back(PlatformEvent::FocusChanged { focused: *focused });
|
||||||
|
}
|
||||||
|
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
|
||||||
|
self.queue.push_back(PlatformEvent::DpiChanged {
|
||||||
|
scale: *scale_factor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
WindowEvent::CloseRequested => {
|
||||||
|
self.queue.push_back(PlatformEvent::QuitRequested);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes events from an event loop event.
|
||||||
|
pub fn push_event<T>(&mut self, event: &Event<T>) {
|
||||||
|
if let Event::WindowEvent { event, .. } = event {
|
||||||
|
self.push_window_event(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mouse_button_code(button: MouseButton) -> u16 {
|
||||||
|
match button {
|
||||||
|
MouseButton::Left => 0,
|
||||||
|
MouseButton::Right => 1,
|
||||||
|
MouseButton::Middle => 2,
|
||||||
|
MouseButton::Back => 3,
|
||||||
|
MouseButton::Forward => 4,
|
||||||
|
MouseButton::Other(index) => 100 + index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventSource for WinitEventSource {
|
||||||
|
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError> {
|
||||||
|
while let Some(event) = self.queue.pop_front() {
|
||||||
|
out.push(event);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Window creation plan for native smoke entrypoints.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct WinitWindowPlan {
|
||||||
|
/// Requested drawable width in physical pixels.
|
||||||
|
pub width: u32,
|
||||||
|
/// Requested drawable height in physical pixels.
|
||||||
|
pub height: u32,
|
||||||
|
/// Whether native window/display handles are required by the caller.
|
||||||
|
pub requires_native_handles: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WinitWindowPlan {
|
||||||
|
/// Returns the Stage 0 native smoke window plan.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn smoke() -> Self {
|
||||||
|
Self {
|
||||||
|
width: DEFAULT_SMOKE_WIDTH,
|
||||||
|
height: DEFAULT_SMOKE_HEIGHT,
|
||||||
|
requires_native_handles: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates the window plan before a native event loop is entered.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PlatformError`] when the drawable extent is zero.
|
||||||
|
pub fn validate(self) -> Result<Self, PlatformError> {
|
||||||
|
if self.width == 0 || self.height == 0 {
|
||||||
|
return Err(PlatformError::Backend {
|
||||||
|
context: "winit window plan",
|
||||||
|
message: "drawable extent must be non-zero".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native smoke window creation result.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct WinitSmokeWindowProbe {
|
||||||
|
/// Validated creation plan.
|
||||||
|
pub plan: WinitWindowPlan,
|
||||||
|
/// Captured window descriptor.
|
||||||
|
pub window: WinitWindow,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WinitSmokeWindowProbe {
|
||||||
|
/// Returns raw native handles captured from the native window.
|
||||||
|
#[must_use]
|
||||||
|
pub fn native_handles(&self) -> Option<NativeWindowHandles> {
|
||||||
|
self.window.native_handles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a native smoke window, captures raw handles, then exits the event loop.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PlatformError`] when the plan is invalid, the event loop/window
|
||||||
|
/// cannot be created, or raw native handles are unavailable.
|
||||||
|
pub fn probe_smoke_window() -> Result<WinitSmokeWindowProbe, PlatformError> {
|
||||||
|
let plan = WinitWindowPlan::smoke().validate()?;
|
||||||
|
let event_loop = EventLoop::new().map_err(|err| PlatformError::Backend {
|
||||||
|
context: "winit event loop",
|
||||||
|
message: err.to_string(),
|
||||||
|
})?;
|
||||||
|
let mut app = SmokeWindowApp::new(plan);
|
||||||
|
event_loop
|
||||||
|
.run_app(&mut app)
|
||||||
|
.map_err(|err| PlatformError::Backend {
|
||||||
|
context: "winit event loop",
|
||||||
|
message: err.to_string(),
|
||||||
|
})?;
|
||||||
|
app.into_probe()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SmokeWindowApp {
|
||||||
|
plan: WinitWindowPlan,
|
||||||
|
window: Option<WinitWindow>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmokeWindowApp {
|
||||||
|
const fn new(plan: WinitWindowPlan) -> Self {
|
||||||
|
Self {
|
||||||
|
plan,
|
||||||
|
window: None,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_probe(self) -> Result<WinitSmokeWindowProbe, PlatformError> {
|
||||||
|
if let Some(message) = self.error {
|
||||||
|
return Err(PlatformError::Backend {
|
||||||
|
context: "winit smoke window",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let window = self.window.ok_or_else(|| PlatformError::Backend {
|
||||||
|
context: "winit smoke window",
|
||||||
|
message: "event loop exited before creating a window".to_string(),
|
||||||
|
})?;
|
||||||
|
if self.plan.requires_native_handles && window.native_handles().is_none() {
|
||||||
|
return Err(PlatformError::Backend {
|
||||||
|
context: "winit smoke window",
|
||||||
|
message: "native window/display handles are unavailable".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(WinitSmokeWindowProbe {
|
||||||
|
plan: self.plan,
|
||||||
|
window,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApplicationHandler for SmokeWindowApp {
|
||||||
|
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||||
|
if self.window.is_some() || self.error.is_some() {
|
||||||
|
event_loop.exit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let attributes = Window::default_attributes()
|
||||||
|
.with_title("FParkan Vulkan smoke")
|
||||||
|
.with_inner_size(WinitPhysicalSize::new(self.plan.width, self.plan.height));
|
||||||
|
match event_loop.create_window(attributes) {
|
||||||
|
Ok(window) => {
|
||||||
|
self.window = Some(WinitWindow::from_window(&window));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
self.error = Some(err.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event_loop.exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn window_event(
|
||||||
|
&mut self,
|
||||||
|
_event_loop: &ActiveEventLoop,
|
||||||
|
_window_id: WindowId,
|
||||||
|
_event: WindowEvent,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal window view over a `winit` window.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct WinitWindow {
|
||||||
|
handle: WindowHandle,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
scale: f64,
|
||||||
|
focused: bool,
|
||||||
|
minimized: bool,
|
||||||
|
occluded: bool,
|
||||||
|
native_handles: Option<NativeWindowHandles>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WinitWindow {
|
||||||
|
/// Builds a stable descriptor from a `winit` window.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_window(window: &Window) -> Self {
|
||||||
|
let scale = window.scale_factor();
|
||||||
|
let size = window.inner_size();
|
||||||
|
Self {
|
||||||
|
handle: WindowHandle {
|
||||||
|
id: next_window_id(),
|
||||||
|
},
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
scale,
|
||||||
|
focused: true,
|
||||||
|
minimized: false,
|
||||||
|
occluded: false,
|
||||||
|
native_handles: native_handles(window),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns conservative defaults if a native window is not available yet.
|
||||||
|
#[must_use]
|
||||||
|
pub fn synthetic(width: u32, height: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
handle: WindowHandle {
|
||||||
|
id: next_window_id(),
|
||||||
|
},
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
scale: 1.0,
|
||||||
|
focused: true,
|
||||||
|
minimized: false,
|
||||||
|
occluded: false,
|
||||||
|
native_handles: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns requested default render profile for integration points.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn default_render_request() -> RenderRequest {
|
||||||
|
RenderRequest::conservative()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowPort for WinitWindow {
|
||||||
|
fn drawable_size(&self) -> PhysicalSize {
|
||||||
|
PhysicalSize {
|
||||||
|
width: self.width,
|
||||||
|
height: self.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dpi_scale(&self) -> f64 {
|
||||||
|
self.scale
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_focus(&self) -> bool {
|
||||||
|
self.focused
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_minimized(&self) -> bool {
|
||||||
|
self.minimized
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_occluded(&self) -> bool {
|
||||||
|
self.occluded
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle(&self) -> WindowHandle {
|
||||||
|
self.handle
|
||||||
|
}
|
||||||
|
|
||||||
|
fn native_handles(&self) -> Option<NativeWindowHandles> {
|
||||||
|
self.native_handles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn native_handles(window: &Window) -> Option<NativeWindowHandles> {
|
||||||
|
let display = window.display_handle().ok()?.as_raw();
|
||||||
|
let window = window.window_handle().ok()?.as_raw();
|
||||||
|
Some(NativeWindowHandles { display, window })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_source_buffers_synthetic_events() -> Result<(), PlatformError> {
|
||||||
|
let mut source = WinitEventSource::new();
|
||||||
|
source.push(PlatformEvent::Resumed);
|
||||||
|
source.push(PlatformEvent::QuitRequested);
|
||||||
|
let mut events = Vec::new();
|
||||||
|
source.poll(&mut events)?;
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
vec![PlatformEvent::Resumed, PlatformEvent::QuitRequested]
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_port_reports_default_request_profile() {
|
||||||
|
let window = WinitWindow::synthetic(640, 360);
|
||||||
|
let request = WinitWindow::default_render_request();
|
||||||
|
assert_eq!(
|
||||||
|
request.presentation,
|
||||||
|
fparkan_platform::PresentationMode::Fifo
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
window.drawable_size(),
|
||||||
|
PhysicalSize {
|
||||||
|
width: 640,
|
||||||
|
height: 360
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(window.native_handles().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smoke_window_plan_requires_native_handles_and_nonzero_extent() -> Result<(), PlatformError> {
|
||||||
|
let plan = WinitWindowPlan::smoke().validate()?;
|
||||||
|
|
||||||
|
assert_eq!(plan.width, DEFAULT_SMOKE_WIDTH);
|
||||||
|
assert_eq!(plan.height, DEFAULT_SMOKE_HEIGHT);
|
||||||
|
assert!(plan.requires_native_handles);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smoke_window_plan_rejects_zero_extent() {
|
||||||
|
let plan = WinitWindowPlan {
|
||||||
|
width: 0,
|
||||||
|
height: DEFAULT_SMOKE_HEIGHT,
|
||||||
|
requires_native_handles: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
plan.validate(),
|
||||||
|
Err(PlatformError::Backend {
|
||||||
|
context: "winit window plan",
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smoke_window_app_requires_created_native_window() {
|
||||||
|
let app = SmokeWindowApp::new(WinitWindowPlan::smoke());
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
app.into_probe(),
|
||||||
|
Err(PlatformError::Backend {
|
||||||
|
context: "winit smoke window",
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smoke_window_app_rejects_synthetic_window_without_native_handles() {
|
||||||
|
let mut app = SmokeWindowApp::new(WinitWindowPlan::smoke());
|
||||||
|
app.window = Some(WinitWindow::synthetic(
|
||||||
|
DEFAULT_SMOKE_WIDTH,
|
||||||
|
DEFAULT_SMOKE_HEIGHT,
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
app.into_probe(),
|
||||||
|
Err(PlatformError::Backend {
|
||||||
|
context: "winit smoke window",
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_events_push_expected_platform_events() {
|
||||||
|
let mut source = WinitEventSource::new();
|
||||||
|
let size = winit::dpi::PhysicalSize::new(1024u32, 768u32);
|
||||||
|
|
||||||
|
source.push_window_event(&WindowEvent::Resized(size));
|
||||||
|
source.push_window_event(&WindowEvent::Focused(false));
|
||||||
|
source.push_window_event(&WindowEvent::CloseRequested);
|
||||||
|
|
||||||
|
let mut events = Vec::new();
|
||||||
|
source
|
||||||
|
.poll(&mut events)
|
||||||
|
.expect("platform event pump should never fail");
|
||||||
|
|
||||||
|
assert!(events.contains(&PlatformEvent::Resize {
|
||||||
|
width: 1024,
|
||||||
|
height: 768,
|
||||||
|
}));
|
||||||
|
assert!(events.contains(&PlatformEvent::FocusChanged { focused: false }));
|
||||||
|
assert!(events.contains(&PlatformEvent::QuitRequested));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: no unsafe usage in this crate.
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "fparkan-render-gl"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
fparkan-render = { path = "../../crates/fparkan-render" }
|
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
@@ -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,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-render-vulkan"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ash = "0.38"
|
||||||
|
ash-window = "0.13"
|
||||||
|
fparkan-binary = { path = "../../crates/fparkan-binary" }
|
||||||
|
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||||
|
fparkan-render = { path = "../../crates/fparkan-render" }
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
unsafe_code = "allow"
|
||||||
|
missing_docs = "warn"
|
||||||
|
unreachable_pub = "warn"
|
||||||
|
unused_must_use = "deny"
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
all = { level = "deny", priority = -1 }
|
||||||
|
pedantic = { level = "warn", priority = -1 }
|
||||||
|
unwrap_used = "deny"
|
||||||
|
expect_used = "deny"
|
||||||
|
panic = "deny"
|
||||||
|
todo = "deny"
|
||||||
|
unimplemented = "deny"
|
||||||
|
dbg_macro = "deny"
|
||||||
|
print_stdout = "warn"
|
||||||
|
print_stderr = "warn"
|
||||||
|
lossy_float_literal = "deny"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,11 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
fparkan-assets = { path = "../../crates/fparkan-assets" }
|
||||||
fparkan-corpus = { path = "../../crates/fparkan-corpus" }
|
fparkan-corpus = { path = "../../crates/fparkan-corpus" }
|
||||||
fparkan-nres = { path = "../../crates/fparkan-nres" }
|
|
||||||
fparkan-prototype = { path = "../../crates/fparkan-prototype" }
|
fparkan-prototype = { path = "../../crates/fparkan-prototype" }
|
||||||
|
fparkan-inspection = { path = "../../crates/fparkan-inspection" }
|
||||||
fparkan-resource = { path = "../../crates/fparkan-resource" }
|
fparkan-resource = { path = "../../crates/fparkan-resource" }
|
||||||
fparkan-rsli = { path = "../../crates/fparkan-rsli" }
|
|
||||||
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
||||||
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||||
//! `FParkan` command-line tools.
|
//! `FParkan` command-line tools.
|
||||||
|
|
||||||
|
use fparkan_assets::extend_graph_report_with_visual_dependencies;
|
||||||
use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
|
use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
|
||||||
use fparkan_prototype::{
|
use fparkan_inspection::inspect_archive_file;
|
||||||
build_prototype_graph_report, extend_graph_report_with_visual_dependencies,
|
use fparkan_inspection::ArchiveInspection;
|
||||||
};
|
use fparkan_prototype::build_prototype_graph_report;
|
||||||
use fparkan_resource::{resource_name, CachedResourceRepository};
|
use fparkan_resource::{resource_name, CachedResourceRepository};
|
||||||
use fparkan_runtime::{
|
use fparkan_runtime::{
|
||||||
create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest,
|
create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest,
|
||||||
@@ -134,7 +154,7 @@ fn inspect_prototype(args: &[String]) -> Result<(), String> {
|
|||||||
let roots = [resource_name(key.as_bytes())];
|
let roots = [resource_name(key.as_bytes())];
|
||||||
let (graph, resolved, mut report) =
|
let (graph, resolved, mut report) =
|
||||||
build_prototype_graph_report(&repository, vfs.as_ref(), &roots);
|
build_prototype_graph_report(&repository, vfs.as_ref(), &roots);
|
||||||
extend_graph_report_with_visual_dependencies(&repository, &mut report, &resolved);
|
extend_graph_report_with_visual_dependencies(&repository, &mut report, &graph, &resolved);
|
||||||
println!("{}", prototype_inspect_json(&key, &graph, &report));
|
println!("{}", prototype_inspect_json(&key, &graph, &report));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -202,42 +222,36 @@ fn graph_mission(args: &[String]) -> Result<(), String> {
|
|||||||
|
|
||||||
fn inspect_archive(args: &[String]) -> Result<(), String> {
|
fn inspect_archive(args: &[String]) -> Result<(), String> {
|
||||||
let path = parse_archive_path(args)?;
|
let path = parse_archive_path(args)?;
|
||||||
let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?;
|
let inspection = inspect_archive_file(&path, 0).map_err(|err| err.to_string())?;
|
||||||
if bytes.starts_with(b"NRes") {
|
|
||||||
let document = fparkan_nres::decode(
|
match inspection {
|
||||||
Arc::from(bytes.into_boxed_slice()),
|
ArchiveInspection::Nres {
|
||||||
fparkan_nres::ReadProfile::Compatible,
|
entries,
|
||||||
)
|
lookup_order_valid,
|
||||||
.map_err(|err| err.to_string())?;
|
..
|
||||||
println!(
|
} => {
|
||||||
"{}",
|
println!(
|
||||||
archive_inspect_json(
|
"{}",
|
||||||
&path.display().to_string(),
|
archive_inspect_json(
|
||||||
"NRes",
|
&path.display().to_string(),
|
||||||
document.entries().len(),
|
"NRes",
|
||||||
Some(document.lookup_order_valid()),
|
entries,
|
||||||
)
|
Some(lookup_order_valid),
|
||||||
);
|
)
|
||||||
return Ok(());
|
);
|
||||||
|
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(
|
fn archive_inspect_json(
|
||||||
@@ -278,7 +292,7 @@ fn json_string(value: &str) -> String {
|
|||||||
'\r' => out.push_str("\\r"),
|
'\r' => out.push_str("\\r"),
|
||||||
'\t' => out.push_str("\\t"),
|
'\t' => out.push_str("\\t"),
|
||||||
c if c.is_control() => {
|
c if c.is_control() => {
|
||||||
let _ = write!(out, "\\u{:04x}", u32::from(c));
|
let _ = write!(out, "\\u{:04x}", c as u32);
|
||||||
}
|
}
|
||||||
c => out.push(c),
|
c => out.push(c),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
fparkan-assets = { path = "../../crates/fparkan-assets" }
|
||||||
|
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||||
fparkan-render = { path = "../../crates/fparkan-render" }
|
fparkan-render = { path = "../../crates/fparkan-render" }
|
||||||
|
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
|
||||||
|
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
|
||||||
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
||||||
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
||||||
fparkan-world = { path = "../../crates/fparkan-world" }
|
fparkan-world = { path = "../../crates/fparkan-world" }
|
||||||
|
|||||||
@@ -1,13 +1,37 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||||
//! `FParkan` rendered game composition root.
|
//! `FParkan` rendered game composition root.
|
||||||
|
|
||||||
|
use fparkan_assets::PreparedVisual;
|
||||||
|
use fparkan_platform::WindowPort;
|
||||||
|
use fparkan_platform_winit::WinitWindow;
|
||||||
use fparkan_render::{
|
use fparkan_render::{
|
||||||
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RecordingBackend, RenderBackend,
|
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderBackend, RenderCommand,
|
||||||
RenderCommand, RenderCommandList, RenderPhase,
|
RenderCommandList, RenderPhase,
|
||||||
};
|
};
|
||||||
|
use fparkan_render_vulkan::VulkanBackend;
|
||||||
use fparkan_runtime::{
|
use fparkan_runtime::{
|
||||||
create, frame, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest,
|
create, frame, load_mission, loaded_mission_assets, EngineConfig, EngineMode, EngineServices,
|
||||||
|
MissionAssets, MissionRequest,
|
||||||
};
|
};
|
||||||
use fparkan_vfs::DirectoryVfs;
|
use fparkan_vfs::DirectoryVfs;
|
||||||
use fparkan_world::WorldSnapshot;
|
use fparkan_world::WorldSnapshot;
|
||||||
@@ -47,7 +71,11 @@ fn run(args: &[String]) -> Result<String, String> {
|
|||||||
)
|
)
|
||||||
.map_err(|err| err.to_string())?;
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
let mut backend = RecordingBackend::default();
|
let mut backend = VulkanBackend::new();
|
||||||
|
let _request = WinitWindow::default_render_request();
|
||||||
|
let window = WinitWindow::synthetic(1280, 720);
|
||||||
|
let _ = window.drawable_size();
|
||||||
|
let _ = window.handle();
|
||||||
let mut last_draw_count = 0usize;
|
let mut last_draw_count = 0usize;
|
||||||
let mut last_tick = 0u64;
|
let mut last_tick = 0u64;
|
||||||
let mut last_hash = [0u8; 32];
|
let mut last_hash = [0u8; 32];
|
||||||
@@ -55,7 +83,8 @@ fn run(args: &[String]) -> Result<String, String> {
|
|||||||
let result = frame(&mut engine).map_err(|err| err.to_string())?;
|
let result = frame(&mut engine).map_err(|err| err.to_string())?;
|
||||||
last_tick = result.snapshot.tick.0;
|
last_tick = result.snapshot.tick.0;
|
||||||
last_hash = result.snapshot.hash.0;
|
last_hash = result.snapshot.hash.0;
|
||||||
let commands = render_snapshot_commands(&result.snapshot);
|
let mission_assets = loaded_mission_assets(&engine);
|
||||||
|
let commands = render_snapshot_commands_with_assets(&result.snapshot, mission_assets);
|
||||||
last_draw_count = commands
|
last_draw_count = commands
|
||||||
.commands
|
.commands
|
||||||
.iter()
|
.iter()
|
||||||
@@ -66,6 +95,8 @@ fn run(args: &[String]) -> Result<String, String> {
|
|||||||
.map_err(|err| format!("render backend: {err}"))?;
|
.map_err(|err| format!("render backend: {err}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let capture_report = backend.report();
|
||||||
|
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"{{\"mission\":{},\"objects\":{},\"frames\":{},\"tick\":{},\"draws\":{},\"captures\":{},\"last_capture_bytes\":{},\"hash\":{}}}",
|
"{{\"mission\":{},\"objects\":{},\"frames\":{},\"tick\":{},\"draws\":{},\"captures\":{},\"last_capture_bytes\":{},\"hash\":{}}}",
|
||||||
json_string(&args.mission),
|
json_string(&args.mission),
|
||||||
@@ -73,17 +104,43 @@ fn run(args: &[String]) -> Result<String, String> {
|
|||||||
args.frames,
|
args.frames,
|
||||||
last_tick,
|
last_tick,
|
||||||
last_draw_count,
|
last_draw_count,
|
||||||
backend.captures().len(),
|
capture_report.submissions,
|
||||||
backend.last_capture().map_or(0, <[u8]>::len),
|
capture_report.last_capture_size,
|
||||||
json_hash(&last_hash)
|
json_hash(&last_hash)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
fn render_snapshot_commands(snapshot: &WorldSnapshot) -> RenderCommandList {
|
fn render_snapshot_commands(snapshot: &WorldSnapshot) -> RenderCommandList {
|
||||||
|
render_snapshot_commands_with_assets(snapshot, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_snapshot_commands_with_assets(
|
||||||
|
snapshot: &WorldSnapshot,
|
||||||
|
mission_assets: Option<&MissionAssets>,
|
||||||
|
) -> RenderCommandList {
|
||||||
let mut commands = Vec::with_capacity(snapshot.objects.len() + 2);
|
let mut commands = Vec::with_capacity(snapshot.objects.len() + 2);
|
||||||
commands.push(RenderCommand::BeginFrame);
|
commands.push(RenderCommand::BeginFrame);
|
||||||
for (index, handle) in snapshot.objects.iter().enumerate() {
|
for (index, handle) in snapshot.objects.iter().enumerate() {
|
||||||
let stable_order = u64::from(handle.slot);
|
let stable_order = u64::from(handle.slot);
|
||||||
|
let prepared = mission_assets.and_then(|assets| {
|
||||||
|
assets
|
||||||
|
.visual_for_object(index)
|
||||||
|
.and_then(|visual_id| assets.visual_by_id(visual_id))
|
||||||
|
});
|
||||||
|
let mesh = if let Some(visual) = prepared {
|
||||||
|
visual.mesh.as_ref().map_or_else(
|
||||||
|
|| GpuMeshId(u64::from(handle.slot) + 1),
|
||||||
|
|_| GpuMeshId(visual.id.raw()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
GpuMeshId(u64::from(handle.slot) + 1)
|
||||||
|
};
|
||||||
|
let material = prepared
|
||||||
|
.and_then(PreparedVisual::primary_material_id)
|
||||||
|
.map_or(GpuMaterialId(1), |material_id| {
|
||||||
|
GpuMaterialId(material_id.raw())
|
||||||
|
});
|
||||||
let draw_id = snapshot
|
let draw_id = snapshot
|
||||||
.tick
|
.tick
|
||||||
.0
|
.0
|
||||||
@@ -93,8 +150,8 @@ fn render_snapshot_commands(snapshot: &WorldSnapshot) -> RenderCommandList {
|
|||||||
id: DrawId(draw_id),
|
id: DrawId(draw_id),
|
||||||
phase: RenderPhase::Opaque,
|
phase: RenderPhase::Opaque,
|
||||||
object_id: None,
|
object_id: None,
|
||||||
mesh: GpuMeshId(u64::from(handle.slot) + 1),
|
mesh,
|
||||||
material: GpuMaterialId(1),
|
material,
|
||||||
transform: identity_transform(index_to_f32(index)),
|
transform: identity_transform(index_to_f32(index)),
|
||||||
range: IndexRange { start: 0, count: 3 },
|
range: IndexRange { start: 0, count: 3 },
|
||||||
stable_order,
|
stable_order,
|
||||||
@@ -178,7 +235,7 @@ fn json_string(value: &str) -> String {
|
|||||||
'\t' => out.push_str("\\t"),
|
'\t' => out.push_str("\\t"),
|
||||||
c if c.is_control() => {
|
c if c.is_control() => {
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
let _ = write!(out, "\\u{:04x}", u32::from(c));
|
let _ = write!(out, "\\u{:04x}", c as u32);
|
||||||
}
|
}
|
||||||
c => out.push(c),
|
c => out.push(c),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||||
//! `FParkan` headless runtime entrypoint.
|
//! `FParkan` headless runtime entrypoint.
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,8 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-msh = { path = "../../crates/fparkan-msh" }
|
fparkan-inspection = { path = "../../crates/fparkan-inspection" }
|
||||||
fparkan-nres = { path = "../../crates/fparkan-nres" }
|
|
||||||
fparkan-resource = { path = "../../crates/fparkan-resource" }
|
|
||||||
fparkan-render = { path = "../../crates/fparkan-render" }
|
fparkan-render = { path = "../../crates/fparkan-render" }
|
||||||
fparkan-rsli = { path = "../../crates/fparkan-rsli" }
|
|
||||||
fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format" }
|
|
||||||
fparkan-texm = { path = "../../crates/fparkan-texm" }
|
|
||||||
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -1,20 +1,36 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||||
//! `FParkan` asset viewer composition root.
|
//! `FParkan` asset viewer composition root.
|
||||||
|
|
||||||
use fparkan_msh::{decode_msh, validate_msh};
|
use fparkan_inspection::{
|
||||||
use fparkan_nres::{decode as decode_nres, ReadProfile as NresReadProfile};
|
inspect_land_file, inspect_model_from_root, inspect_texture_from_root, ArchiveInspection,
|
||||||
|
LandFileKind, MapInspection, NresEntrySummary,
|
||||||
|
};
|
||||||
use fparkan_render::{
|
use fparkan_render::{
|
||||||
build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderPhase,
|
build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderPhase,
|
||||||
RenderProfile, RenderSnapshot, RenderSnapshotDraw,
|
RenderProfile, RenderSnapshot, RenderSnapshotDraw,
|
||||||
};
|
};
|
||||||
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
|
|
||||||
use fparkan_terrain_format::{decode_land_map, decode_land_msh};
|
|
||||||
use fparkan_texm::decode_texm;
|
|
||||||
use fparkan_vfs::DirectoryVfs;
|
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||||
@@ -44,35 +60,27 @@ fn run(args: &[String]) -> Result<String, String> {
|
|||||||
fn inspect_archive(args: &[String]) -> Result<String, String> {
|
fn inspect_archive(args: &[String]) -> Result<String, String> {
|
||||||
let file = parse_file(args)?;
|
let file = parse_file(args)?;
|
||||||
let limit = parse_limit(args)?;
|
let limit = parse_limit(args)?;
|
||||||
let bytes = std::fs::read(&file).map_err(|err| format!("{}: {err}", file.display()))?;
|
let inspection = fparkan_inspection::inspect_archive_file(&file, limit)?;
|
||||||
if bytes.starts_with(b"NRes") {
|
|
||||||
let document = decode_nres(
|
match inspection {
|
||||||
Arc::from(bytes.into_boxed_slice()),
|
ArchiveInspection::Nres {
|
||||||
NresReadProfile::Compatible,
|
entries,
|
||||||
)
|
lookup_order_valid,
|
||||||
.map_err(|err| err.to_string())?;
|
sample,
|
||||||
let sample = render_nres_entries(&document, limit);
|
} => Ok(format!(
|
||||||
return Ok(format!(
|
|
||||||
"{{\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}",
|
"{{\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}",
|
||||||
json_string(&file.display().to_string()),
|
json_string(&file.display().to_string()),
|
||||||
document.entries().len(),
|
entries,
|
||||||
document.lookup_order_valid(),
|
lookup_order_valid,
|
||||||
sample
|
render_nres_entries(&sample)
|
||||||
));
|
)),
|
||||||
}
|
ArchiveInspection::Rsli { entries } => Ok(format!(
|
||||||
if bytes.get(0..4) == Some(b"NL\0\x01") {
|
|
||||||
let document = fparkan_rsli::decode(
|
|
||||||
Arc::from(bytes.into_boxed_slice()),
|
|
||||||
fparkan_rsli::ReadProfile::Compatible,
|
|
||||||
)
|
|
||||||
.map_err(|err| err.to_string())?;
|
|
||||||
return Ok(format!(
|
|
||||||
"{{\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}",
|
"{{\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}",
|
||||||
json_string(&file.display().to_string()),
|
json_string(&file.display().to_string()),
|
||||||
document.entries().len()
|
entries
|
||||||
));
|
)),
|
||||||
|
ArchiveInspection::Unsupported => Err(format!("{}: unsupported archive magic", file.display())),
|
||||||
}
|
}
|
||||||
Err(format!("{}: unsupported archive magic", file.display()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inspect_model(args: &[String]) -> Result<String, String> {
|
fn inspect_model(args: &[String]) -> Result<String, String> {
|
||||||
@@ -81,21 +89,18 @@ fn inspect_model(args: &[String]) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let query = parse_resource_query(args)?;
|
let query = parse_resource_query(args)?;
|
||||||
let bytes = read_resource(&query)?;
|
let inspection = inspect_model_from_root(&query.root, &query.archive, &query.name)?;
|
||||||
let nested = decode_nres(bytes, NresReadProfile::Compatible).map_err(|err| err.to_string())?;
|
|
||||||
let document = decode_msh(&nested).map_err(|err| err.to_string())?;
|
|
||||||
let model = validate_msh(&document).map_err(|err| err.to_string())?;
|
|
||||||
|
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"{{\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}",
|
"{{\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}",
|
||||||
json_string(&query.archive),
|
json_string(&query.archive),
|
||||||
json_string(&query.name),
|
json_string(&query.name),
|
||||||
document.streams().len(),
|
inspection.streams,
|
||||||
model.node_count,
|
inspection.nodes,
|
||||||
model.slots.len(),
|
inspection.slots,
|
||||||
model.positions.len(),
|
inspection.positions,
|
||||||
model.indices.len(),
|
inspection.indices,
|
||||||
model.batches.len()
|
inspection.batches
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,54 +144,58 @@ impl ViewerModelService {
|
|||||||
|
|
||||||
fn inspect_texture(args: &[String]) -> Result<String, String> {
|
fn inspect_texture(args: &[String]) -> Result<String, String> {
|
||||||
let query = parse_resource_query(args)?;
|
let query = parse_resource_query(args)?;
|
||||||
let document = decode_texm(read_resource(&query)?).map_err(|err| err.to_string())?;
|
let inspection = inspect_texture_from_root(&query.root, &query.archive, &query.name)?;
|
||||||
|
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"{{\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}",
|
"{{\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}",
|
||||||
json_string(&query.archive),
|
json_string(&query.archive),
|
||||||
json_string(&query.name),
|
json_string(&query.name),
|
||||||
document.width(),
|
inspection.width,
|
||||||
document.height(),
|
inspection.height,
|
||||||
json_string(&format!("{:?}", document.format())),
|
json_string(&inspection.format),
|
||||||
document.mip_count(),
|
inspection.mips,
|
||||||
document.page_rects().len()
|
inspection.pages
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inspect_map(args: &[String]) -> Result<String, String> {
|
fn inspect_map(args: &[String]) -> Result<String, String> {
|
||||||
let file = parse_file(args)?;
|
let file = parse_file(args)?;
|
||||||
let kind = parse_option(args, &["--kind"]).ok_or_else(|| "missing --kind".to_string())?;
|
let kind = parse_option(args, &["--kind"]).ok_or_else(|| "missing --kind".to_string())?;
|
||||||
let bytes = std::fs::read(&file).map_err(|err| format!("{}: {err}", file.display()))?;
|
let inspection = inspect_land_file(
|
||||||
let nres = decode_nres(
|
&file,
|
||||||
Arc::from(bytes.into_boxed_slice()),
|
match kind.as_str() {
|
||||||
NresReadProfile::Compatible,
|
"land-msh" => LandFileKind::LandMsh,
|
||||||
)
|
"land-map" => LandFileKind::LandMap,
|
||||||
.map_err(|err| err.to_string())?;
|
_ => return Err(format!("unknown map kind: {kind}")),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
match kind.as_str() {
|
Ok(render_map_inspection_json(
|
||||||
"land-msh" => {
|
&file.display().to_string(),
|
||||||
let land = decode_land_msh(&nres).map_err(|err| err.to_string())?;
|
&kind,
|
||||||
Ok(format!(
|
&inspection,
|
||||||
"{{\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}",
|
))
|
||||||
json_string(&file.display().to_string()),
|
}
|
||||||
land.streams.len(),
|
|
||||||
land.positions.len(),
|
fn render_map_inspection_json(path: &str, kind: &str, inspection: &MapInspection) -> String {
|
||||||
land.faces.len(),
|
match kind {
|
||||||
land.slots.slots_raw.len()
|
"land-msh" => format!(
|
||||||
))
|
"{{\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}",
|
||||||
}
|
json_string(path),
|
||||||
"land-map" => {
|
inspection.streams,
|
||||||
let land = decode_land_map(&nres).map_err(|err| err.to_string())?;
|
inspection.positions,
|
||||||
Ok(format!(
|
inspection.faces,
|
||||||
"{{\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}",
|
inspection.slots
|
||||||
json_string(&file.display().to_string()),
|
),
|
||||||
land.areals.len(),
|
"land-map" => format!(
|
||||||
land.areal_count,
|
"{{\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}",
|
||||||
land.grid.cells_x,
|
json_string(path),
|
||||||
land.grid.cells_y
|
inspection.areals,
|
||||||
))
|
inspection.declared_areals,
|
||||||
}
|
inspection.grid_width,
|
||||||
_ => Err(format!("unknown map kind: {kind}")),
|
inspection.grid_height
|
||||||
|
),
|
||||||
|
_ => unreachable!("invalid land kind: {kind}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,19 +214,6 @@ fn parse_resource_query(args: &[String]) -> Result<ResourceQuery, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_resource(query: &ResourceQuery) -> Result<Arc<[u8]>, String> {
|
|
||||||
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(&query.root)));
|
|
||||||
let archive = repository
|
|
||||||
.open_archive(&archive_path(query.archive.as_bytes()).map_err(|err| err.to_string())?)
|
|
||||||
.map_err(|err| err.to_string())?;
|
|
||||||
let entry = repository
|
|
||||||
.find(archive, &resource_name(query.name.as_bytes()))
|
|
||||||
.map_err(|err| err.to_string())?
|
|
||||||
.ok_or_else(|| format!("resource not found: {}/{}", query.archive, query.name))?;
|
|
||||||
let bytes = repository.read(entry).map_err(|err| err.to_string())?;
|
|
||||||
Ok(Arc::from(bytes.into_owned()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_file(args: &[String]) -> Result<PathBuf, String> {
|
fn parse_file(args: &[String]) -> Result<PathBuf, String> {
|
||||||
parse_path_option(args, &["--file"], "--file")
|
parse_path_option(args, &["--file"], "--file")
|
||||||
}
|
}
|
||||||
@@ -233,19 +229,19 @@ fn parse_limit(args: &[String]) -> Result<usize, String> {
|
|||||||
.map(|value| value.unwrap_or(0))
|
.map(|value| value.unwrap_or(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_nres_entries(document: &fparkan_nres::NresDocument, limit: usize) -> String {
|
fn render_nres_entries(entries: &[NresEntrySummary]) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for (index, entry) in document.entries().iter().take(limit).enumerate() {
|
for (index, entry) in entries.iter().enumerate() {
|
||||||
if index > 0 {
|
if index > 0 {
|
||||||
out.push(',');
|
out.push(',');
|
||||||
}
|
}
|
||||||
let name = String::from_utf8_lossy(entry.name_bytes());
|
let name = &entry.name;
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
out,
|
out,
|
||||||
"{{\"name\":{},\"type\":{},\"size\":{}}}",
|
"{{\"name\":{},\"type\":{},\"size\":{}}}",
|
||||||
json_string(&name),
|
json_string(name),
|
||||||
entry.meta().type_id,
|
entry.type_id,
|
||||||
entry.meta().data_size
|
entry.data_size
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
@@ -278,7 +274,7 @@ fn json_string(value: &str) -> String {
|
|||||||
'\r' => out.push_str("\\r"),
|
'\r' => out.push_str("\\r"),
|
||||||
'\t' => out.push_str("\\t"),
|
'\t' => out.push_str("\\t"),
|
||||||
c if c.is_control() => {
|
c if c.is_control() => {
|
||||||
let _ = write!(out, "\\u{:04x}", u32::from(c));
|
let _ = write!(out, "\\u{:04x}", c as u32);
|
||||||
}
|
}
|
||||||
c => out.push(c),
|
c => out.push(c),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-vulkan-smoke"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||||
|
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
|
||||||
|
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,23 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
#![allow(clippy::cast_precision_loss)]
|
#![allow(clippy::cast_precision_loss)]
|
||||||
//! Deterministic animation sampling contracts.
|
//! Deterministic animation sampling contracts.
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ fparkan-material = { path = "../fparkan-material" }
|
|||||||
fparkan-msh = { path = "../fparkan-msh" }
|
fparkan-msh = { path = "../fparkan-msh" }
|
||||||
fparkan-nres = { path = "../fparkan-nres" }
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
fparkan-path = { path = "../fparkan-path" }
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
|
fparkan-mission-format = { path = "../fparkan-mission-format" }
|
||||||
fparkan-prototype = { path = "../fparkan-prototype" }
|
fparkan-prototype = { path = "../fparkan-prototype" }
|
||||||
fparkan-resource = { path = "../fparkan-resource" }
|
fparkan-resource = { path = "../fparkan-resource" }
|
||||||
fparkan-texm = { path = "../fparkan-texm" }
|
fparkan-texm = { path = "../fparkan-texm" }
|
||||||
|
fparkan-terrain = { path = "../fparkan-terrain" }
|
||||||
|
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,23 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! Bounded little-endian binary cursor and checked layout helpers.
|
//! Bounded little-endian binary cursor and checked layout helpers.
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|||||||
@@ -7,8 +7,16 @@ repository.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-binary = { path = "../fparkan-binary" }
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
|
fparkan-fx = { path = "../fparkan-fx" }
|
||||||
|
fparkan-material = { path = "../fparkan-material" }
|
||||||
|
fparkan-msh = { path = "../fparkan-msh" }
|
||||||
|
fparkan-mission-format = { path = "../fparkan-mission-format" }
|
||||||
fparkan-nres = { path = "../fparkan-nres" }
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
|
fparkan-prototype = { path = "../fparkan-prototype" }
|
||||||
fparkan-path = { path = "../fparkan-path" }
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
|
fparkan-rsli = { path = "../fparkan-rsli" }
|
||||||
|
fparkan-texm = { path = "../fparkan-texm" }
|
||||||
|
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! Licensed corpus discovery and aggregate reports.
|
//! Licensed corpus discovery and aggregate reports.
|
||||||
|
|
||||||
use fparkan_binary::{sha256, sha256_hex, Sha256Digest};
|
use fparkan_binary::{sha256, sha256_hex, Sha256Digest};
|
||||||
|
use fparkan_fx::{decode_fxid, FXID_KIND};
|
||||||
|
use fparkan_material::{decode_mat0, decode_wear, MAT0_KIND, WEAR_KIND};
|
||||||
|
use fparkan_mission_format::{decode_tma, TmaProfile};
|
||||||
|
use fparkan_msh::{decode_msh, validate_msh};
|
||||||
|
use fparkan_nres::NresDocument;
|
||||||
use fparkan_path::{ascii_lookup_key, normalize_relative, PathPolicy};
|
use fparkan_path::{ascii_lookup_key, normalize_relative, PathPolicy};
|
||||||
|
use fparkan_prototype::{decode_unit_dat, decode_unit_dat_binding};
|
||||||
|
use fparkan_rsli::{decode as decode_rsli, ReadProfile};
|
||||||
|
use fparkan_terrain_format::{decode_land_map, decode_land_msh};
|
||||||
|
use fparkan_texm::decode_texm;
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -10,6 +38,8 @@ use std::io::Write;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const TEXM_KIND: u32 = 0x6d78_6554;
|
||||||
|
|
||||||
/// Corpus kind.
|
/// Corpus kind.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum CorpusKind {
|
pub enum CorpusKind {
|
||||||
@@ -336,9 +366,11 @@ fn inspect_report_file(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if bytes.starts_with(b"NRes") {
|
if bytes.starts_with(b"NRes") {
|
||||||
variant = "nres".to_string();
|
if variant == "file" {
|
||||||
|
variant = "nres".to_string();
|
||||||
|
}
|
||||||
bump(metrics, "nres_files", 1);
|
bump(metrics, "nres_files", 1);
|
||||||
if let Err(message) = inspect_nres_metrics(bytes, metrics) {
|
if let Err(message) = inspect_nres_metrics(&bytes, metrics) {
|
||||||
return CorpusFileRecord {
|
return CorpusFileRecord {
|
||||||
path: entry.path.clone(),
|
path: entry.path.clone(),
|
||||||
status: CorpusFileStatus::Error,
|
status: CorpusFileStatus::Error,
|
||||||
@@ -346,9 +378,58 @@ fn inspect_report_file(
|
|||||||
message: Some(message),
|
message: Some(message),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if variant == "land_msh" {
|
||||||
|
if let Err(message) = inspect_land_metrics(&bytes, false) {
|
||||||
|
return CorpusFileRecord {
|
||||||
|
path: entry.path.clone(),
|
||||||
|
status: CorpusFileStatus::Error,
|
||||||
|
variant,
|
||||||
|
message: Some(message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if variant == "land_map" {
|
||||||
|
if let Err(message) = inspect_land_metrics(&bytes, true) {
|
||||||
|
return CorpusFileRecord {
|
||||||
|
path: entry.path.clone(),
|
||||||
|
status: CorpusFileStatus::Error,
|
||||||
|
variant,
|
||||||
|
message: Some(message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if bytes.starts_with(b"NL") {
|
} else if bytes.starts_with(b"NL") {
|
||||||
variant = "rsli".to_string();
|
variant = "rsli".to_string();
|
||||||
bump(metrics, "rsli_files", 1);
|
bump(metrics, "rsli_files", 1);
|
||||||
|
if let Err(message) = inspect_rsli_metrics(&bytes) {
|
||||||
|
return CorpusFileRecord {
|
||||||
|
path: entry.path.clone(),
|
||||||
|
status: CorpusFileStatus::Error,
|
||||||
|
variant,
|
||||||
|
message: Some(message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if lower.ends_with("data.tma") {
|
||||||
|
if let Err(message) = inspect_tma_metrics(&bytes) {
|
||||||
|
return CorpusFileRecord {
|
||||||
|
path: entry.path.clone(),
|
||||||
|
status: CorpusFileStatus::Error,
|
||||||
|
variant: "tma".to_string(),
|
||||||
|
message: Some(message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if has_extension(&lower, "dat")
|
||||||
|
&& (lower.starts_with("units/") || lower.contains("/units/"))
|
||||||
|
{
|
||||||
|
variant = "unit_dat".to_string();
|
||||||
|
if let Err(message) = inspect_unit_dat_metrics(&bytes) {
|
||||||
|
return CorpusFileRecord {
|
||||||
|
path: entry.path.clone(),
|
||||||
|
status: CorpusFileStatus::Error,
|
||||||
|
variant,
|
||||||
|
message: Some(message),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
CorpusFileRecord {
|
CorpusFileRecord {
|
||||||
path: entry.path.clone(),
|
path: entry.path.clone(),
|
||||||
@@ -379,26 +460,31 @@ fn inspect_path_metrics(lower: &str, metrics: &mut BTreeMap<String, u64>) -> Str
|
|||||||
variant.to_string()
|
variant.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) -> Result<(), String> {
|
fn inspect_nres_metrics(bytes: &[u8], metrics: &mut BTreeMap<String, u64>) -> Result<(), String> {
|
||||||
let entries = inspect_nres_entries(bytes)?;
|
let document = inspect_nres_document(bytes)?;
|
||||||
bump(metrics, "nres_entries", entries.len() as u64);
|
bump(metrics, "nres_entries", document.entries().len() as u64);
|
||||||
for entry in entries {
|
for entry in document.entries() {
|
||||||
let name = String::from_utf8_lossy(entry.name_bytes()).to_ascii_lowercase();
|
let name = String::from_utf8_lossy(entry.name_bytes()).to_ascii_lowercase();
|
||||||
if has_extension(&name, "msh") {
|
if has_extension(&name, "msh") {
|
||||||
bump(metrics, "msh_entries", 1);
|
bump(metrics, "msh_entries", 1);
|
||||||
|
validate_nres_msh_payload(&document, entry)?;
|
||||||
}
|
}
|
||||||
match entry.meta().type_id {
|
match entry.meta().type_id {
|
||||||
0x3054_414D => {
|
MAT0_KIND => {
|
||||||
bump(metrics, "mat0_entries", 1);
|
bump(metrics, "mat0_entries", 1);
|
||||||
|
validate_nres_mat0_payload(&document, entry)?;
|
||||||
}
|
}
|
||||||
0x6D78_6554 => {
|
TEXM_KIND => {
|
||||||
bump(metrics, "texm_entries", 1);
|
bump(metrics, "texm_entries", 1);
|
||||||
|
validate_nres_texm_payload(&document, entry)?;
|
||||||
}
|
}
|
||||||
0x4449_5846 => {
|
FXID_KIND => {
|
||||||
bump(metrics, "fxid_entries", 1);
|
bump(metrics, "fxid_entries", 1);
|
||||||
|
validate_nres_fxid_payload(&document, entry)?;
|
||||||
}
|
}
|
||||||
0x5241_4557 => {
|
WEAR_KIND => {
|
||||||
bump(metrics, "wear_entries", 1);
|
bump(metrics, "wear_entries", 1);
|
||||||
|
validate_nres_wear_payload(&document, entry)?;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -406,6 +492,110 @@ fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_nres_msh_payload(
|
||||||
|
document: &NresDocument,
|
||||||
|
entry: &fparkan_nres::NresEntry,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let payload = document
|
||||||
|
.payload(entry.id())
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
let nested = fparkan_nres::decode(
|
||||||
|
Arc::from(payload.to_vec().into_boxed_slice()),
|
||||||
|
fparkan_nres::ReadProfile::Compatible,
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
let model = decode_msh(&nested).map_err(|err| err.to_string())?;
|
||||||
|
validate_msh(&model).map_err(|err| err.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_nres_mat0_payload(
|
||||||
|
document: &NresDocument,
|
||||||
|
entry: &fparkan_nres::NresEntry,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let payload = document
|
||||||
|
.payload(entry.id())
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
decode_mat0(payload, entry.meta().attr2).map_err(|err| err.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_nres_wear_payload(
|
||||||
|
document: &NresDocument,
|
||||||
|
entry: &fparkan_nres::NresEntry,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let payload = document
|
||||||
|
.payload(entry.id())
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
decode_wear(payload).map_err(|err| err.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_nres_texm_payload(
|
||||||
|
document: &NresDocument,
|
||||||
|
entry: &fparkan_nres::NresEntry,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let payload = document
|
||||||
|
.payload(entry.id())
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
decode_texm(Arc::from(payload.to_vec().into_boxed_slice())).map_err(|err| err.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_nres_fxid_payload(
|
||||||
|
document: &NresDocument,
|
||||||
|
entry: &fparkan_nres::NresEntry,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let payload = document
|
||||||
|
.payload(entry.id())
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
decode_fxid(Arc::from(payload.to_vec().into_boxed_slice())).map_err(|err| err.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_rsli_metrics(bytes: &[u8]) -> Result<(), String> {
|
||||||
|
let _ = decode_rsli(
|
||||||
|
Arc::from(bytes.to_vec().into_boxed_slice()),
|
||||||
|
ReadProfile::Compatible,
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_tma_metrics(bytes: &[u8]) -> Result<(), String> {
|
||||||
|
let _ = decode_tma(
|
||||||
|
Arc::from(bytes.to_vec().into_boxed_slice()),
|
||||||
|
TmaProfile::Strict,
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_unit_dat_metrics(bytes: &[u8]) -> Result<(), String> {
|
||||||
|
if decode_unit_dat(bytes).is_err() && decode_unit_dat_binding(bytes).is_err() {
|
||||||
|
return Err("failed to parse unit.dat payload as unit or binding format".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_land_metrics(bytes: &[u8], is_map: bool) -> Result<(), String> {
|
||||||
|
let document = inspect_nres_document(bytes)?;
|
||||||
|
if is_map {
|
||||||
|
decode_land_map(&document).map_err(|err| err.to_string())?;
|
||||||
|
} else {
|
||||||
|
decode_land_msh(&document).map_err(|err| err.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_nres_document(bytes: &[u8]) -> Result<NresDocument, String> {
|
||||||
|
fparkan_nres::decode(
|
||||||
|
Arc::from(bytes.to_vec().into_boxed_slice()),
|
||||||
|
fparkan_nres::ReadProfile::Compatible,
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn bump(metrics: &mut BTreeMap<String, u64>, key: &str, delta: u64) {
|
fn bump(metrics: &mut BTreeMap<String, u64>, key: &str, delta: u64) {
|
||||||
if let Some(value) = metrics.get_mut(key) {
|
if let Some(value) = metrics.get_mut(key) {
|
||||||
*value = value.saturating_add(delta);
|
*value = value.saturating_add(delta);
|
||||||
@@ -418,15 +608,6 @@ fn has_extension(path: &str, expected: &str) -> bool {
|
|||||||
.is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
|
.is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inspect_nres_entries(bytes: Vec<u8>) -> Result<Vec<fparkan_nres::NresEntry>, String> {
|
|
||||||
let document = fparkan_nres::decode(
|
|
||||||
Arc::from(bytes.into_boxed_slice()),
|
|
||||||
fparkan_nres::ReadProfile::Compatible,
|
|
||||||
)
|
|
||||||
.map_err(|err| err.to_string())?;
|
|
||||||
Ok(document.entries().to_vec())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Computes stable manifest fingerprint.
|
/// Computes stable manifest fingerprint.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn fingerprint(manifest: &CorpusManifest) -> Sha256Digest {
|
pub fn fingerprint(manifest: &CorpusManifest) -> Sha256Digest {
|
||||||
@@ -686,15 +867,129 @@ mod tests {
|
|||||||
|
|
||||||
let report = report(&root, &manifest).expect("report");
|
let report = report(&root, &manifest).expect("report");
|
||||||
|
|
||||||
assert_eq!(report.failures, 0);
|
assert_eq!(report.failures, 1);
|
||||||
assert_eq!(report.records.len(), 1);
|
assert_eq!(report.records.len(), 1);
|
||||||
assert_eq!(report.records[0].status, CorpusFileStatus::Ok);
|
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
|
||||||
assert_eq!(report.records[0].variant, "nres");
|
assert_eq!(report.records[0].variant, "nres");
|
||||||
assert_eq!(report.metrics["nres_files"], 1);
|
assert_eq!(report.metrics["nres_files"], 1);
|
||||||
assert_eq!(report.metrics["nres_entries"], 3);
|
assert_eq!(report.metrics["nres_entries"], 3);
|
||||||
assert_eq!(report.metrics["msh_entries"], 1);
|
assert_eq!(report.metrics["msh_entries"], 1);
|
||||||
assert_eq!(report.metrics["mat0_entries"], 1);
|
assert_eq!(report.metrics["mat0_entries"], 0);
|
||||||
assert_eq!(report.metrics["texm_entries"], 1);
|
assert_eq!(report.metrics["texm_entries"], 0);
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_land_map_paths_use_production_land_parser() {
|
||||||
|
let root = temp_dir("report-land-map");
|
||||||
|
fs::create_dir_all(root.join("WORLD/MAP")).expect("land map dir");
|
||||||
|
fs::write(root.join("WORLD/MAP/land.map"), build_nres(&[])).expect("land map");
|
||||||
|
let manifest = CorpusManifest {
|
||||||
|
kind: CorpusKind::Unknown,
|
||||||
|
files: vec![ManifestEntry {
|
||||||
|
path: "WORLD/MAP/land.map".to_string(),
|
||||||
|
size: 16,
|
||||||
|
hash: sha256(b"land.map"),
|
||||||
|
}],
|
||||||
|
casefold_collisions: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let report = report(&root, &manifest).expect("report");
|
||||||
|
|
||||||
|
assert_eq!(report.failures, 1);
|
||||||
|
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
|
||||||
|
assert_eq!(report.records[0].variant, "land_map");
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_land_msh_paths_use_production_land_parser() {
|
||||||
|
let root = temp_dir("report-land-msh");
|
||||||
|
fs::create_dir_all(root.join("WORLD/MAP")).expect("land msh dir");
|
||||||
|
fs::write(root.join("WORLD/MAP/land.msh"), build_nres(&[])).expect("land msh");
|
||||||
|
let manifest = CorpusManifest {
|
||||||
|
kind: CorpusKind::Unknown,
|
||||||
|
files: vec![ManifestEntry {
|
||||||
|
path: "WORLD/MAP/land.msh".to_string(),
|
||||||
|
size: 16,
|
||||||
|
hash: sha256(b"land.msh"),
|
||||||
|
}],
|
||||||
|
casefold_collisions: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let report = report(&root, &manifest).expect("report");
|
||||||
|
|
||||||
|
assert_eq!(report.failures, 1);
|
||||||
|
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
|
||||||
|
assert_eq!(report.records[0].variant, "land_msh");
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_tma_paths_use_production_tma_parser() {
|
||||||
|
let root = temp_dir("report-tma");
|
||||||
|
fs::create_dir_all(root.join("MISSIONS/test")).expect("tma dir");
|
||||||
|
fs::write(root.join("MISSIONS/test/data.tma"), b"malformed tma").expect("tma");
|
||||||
|
let manifest = CorpusManifest {
|
||||||
|
kind: CorpusKind::Unknown,
|
||||||
|
files: vec![ManifestEntry {
|
||||||
|
path: "MISSIONS/test/data.tma".to_string(),
|
||||||
|
size: 12,
|
||||||
|
hash: sha256(b"malformed tma"),
|
||||||
|
}],
|
||||||
|
casefold_collisions: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let report = report(&root, &manifest).expect("report");
|
||||||
|
|
||||||
|
assert_eq!(report.failures, 1);
|
||||||
|
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
|
||||||
|
assert_eq!(report.records[0].variant, "tma");
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_unit_dat_paths_use_production_unit_parser() {
|
||||||
|
let root = temp_dir("report-unit");
|
||||||
|
fs::create_dir_all(root.join("units")).expect("unit dir");
|
||||||
|
fs::write(root.join("units/unit.dat"), vec![0u8; 120]).expect("unit");
|
||||||
|
let manifest = CorpusManifest {
|
||||||
|
kind: CorpusKind::Unknown,
|
||||||
|
files: vec![ManifestEntry {
|
||||||
|
path: "units/unit.dat".to_string(),
|
||||||
|
size: 120,
|
||||||
|
hash: sha256(&[0u8; 120]),
|
||||||
|
}],
|
||||||
|
casefold_collisions: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let report = report(&root, &manifest).expect("report");
|
||||||
|
|
||||||
|
assert_eq!(report.failures, 0);
|
||||||
|
assert_eq!(report.records[0].status, CorpusFileStatus::Ok);
|
||||||
|
assert_eq!(report.records[0].variant, "unit_dat");
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_rsli_paths_use_production_rsli_parser() {
|
||||||
|
let root = temp_dir("report-rsli");
|
||||||
|
fs::write(root.join("patch.nl"), b"NL malformed").expect("rsli");
|
||||||
|
let manifest = CorpusManifest {
|
||||||
|
kind: CorpusKind::Unknown,
|
||||||
|
files: vec![ManifestEntry {
|
||||||
|
path: "patch.nl".to_string(),
|
||||||
|
size: 12,
|
||||||
|
hash: sha256(b"NL malformed"),
|
||||||
|
}],
|
||||||
|
casefold_collisions: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let report = report(&root, &manifest).expect("report");
|
||||||
|
|
||||||
|
assert_eq!(report.failures, 1);
|
||||||
|
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
|
||||||
|
assert_eq!(report.records[0].variant, "rsli");
|
||||||
let _ = fs::remove_dir_all(root);
|
let _ = fs::remove_dir_all(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -1,8 +1,30 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! Structured diagnostics shared by `FParkan` crates.
|
//! Structured diagnostics shared by `FParkan` crates.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
/// Diagnostic severity.
|
/// Diagnostic severity.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum Severity {
|
pub enum Severity {
|
||||||
/// Informational note.
|
/// Informational note.
|
||||||
Info,
|
Info,
|
||||||
@@ -15,7 +37,8 @@ pub enum Severity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Evidence level for a contract or interpretation.
|
/// Evidence level for a contract or interpretation.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum EvidenceStatus {
|
pub enum EvidenceStatus {
|
||||||
/// Described by project documentation.
|
/// Described by project documentation.
|
||||||
Documented,
|
Documented,
|
||||||
@@ -30,7 +53,8 @@ pub enum EvidenceStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Operation phase where a diagnostic was produced.
|
/// Operation phase where a diagnostic was produced.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum Phase {
|
pub enum Phase {
|
||||||
/// Discovery.
|
/// Discovery.
|
||||||
Discover,
|
Discover,
|
||||||
@@ -55,7 +79,7 @@ pub enum Phase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Byte span in an input source.
|
/// Byte span in an input source.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||||
pub struct SourceSpan {
|
pub struct SourceSpan {
|
||||||
/// Start offset.
|
/// Start offset.
|
||||||
pub offset: u64,
|
pub offset: u64,
|
||||||
@@ -64,26 +88,31 @@ pub struct SourceSpan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Stable diagnostic code.
|
/// Stable diagnostic code.
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
|
||||||
pub struct DiagnosticCode(pub &'static str);
|
pub struct DiagnosticCode(pub &'static str);
|
||||||
|
|
||||||
/// Context attached to a diagnostic.
|
/// Context attached to a diagnostic.
|
||||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
|
||||||
pub struct DiagnosticContext {
|
pub struct DiagnosticContext {
|
||||||
/// Phase.
|
/// Phase.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub phase: Option<Phase>,
|
pub phase: Option<Phase>,
|
||||||
/// Redacted or logical path.
|
/// Redacted or logical path.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
/// Archive entry name.
|
/// Archive entry name.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub archive_entry: Option<String>,
|
pub archive_entry: Option<String>,
|
||||||
/// Object/prototype key.
|
/// Object/prototype key.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub object_key: Option<String>,
|
pub object_key: Option<String>,
|
||||||
/// Input span.
|
/// Input span.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub span: Option<SourceSpan>,
|
pub span: Option<SourceSpan>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Structured diagnostic with cause chain.
|
/// Structured diagnostic with cause chain.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||||
pub struct Diagnostic {
|
pub struct Diagnostic {
|
||||||
/// Stable code.
|
/// Stable code.
|
||||||
pub code: DiagnosticCode,
|
pub code: DiagnosticCode,
|
||||||
@@ -145,104 +174,13 @@ pub fn render_human(diagnostic: &Diagnostic) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders deterministic JSON without requiring a serialization dependency.
|
/// Renders deterministic JSON using the typed diagnostic schema.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn render_json(diagnostic: &Diagnostic) -> String {
|
pub fn render_json(diagnostic: &Diagnostic) -> String {
|
||||||
fn esc(value: &str) -> String {
|
match serde_json::to_string(diagnostic) {
|
||||||
let mut out = String::with_capacity(value.len() + 2);
|
Ok(json) => json,
|
||||||
for ch in value.chars() {
|
Err(err) => format!("{{\"error\":\"diagnostic serialization failed: {err}\"}}"),
|
||||||
match ch {
|
|
||||||
'\\' => out.push_str("\\\\"),
|
|
||||||
'"' => out.push_str("\\\""),
|
|
||||||
'\n' => out.push_str("\\n"),
|
|
||||||
'\r' => out.push_str("\\r"),
|
|
||||||
'\t' => out.push_str("\\t"),
|
|
||||||
_ => out.push(ch),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut out = String::new();
|
|
||||||
out.push('{');
|
|
||||||
out.push_str("\"code\":\"");
|
|
||||||
out.push_str(&esc(diagnostic.code.0));
|
|
||||||
out.push_str("\",\"severity\":\"");
|
|
||||||
out.push_str(match diagnostic.severity {
|
|
||||||
Severity::Info => "info",
|
|
||||||
Severity::Warning => "warning",
|
|
||||||
Severity::Error => "error",
|
|
||||||
Severity::Fatal => "fatal",
|
|
||||||
});
|
|
||||||
out.push_str("\",\"message\":\"");
|
|
||||||
out.push_str(&esc(&diagnostic.message));
|
|
||||||
out.push_str("\",\"context\":{");
|
|
||||||
if let Some(phase) = diagnostic.context.phase {
|
|
||||||
out.push_str("\"phase\":\"");
|
|
||||||
out.push_str(match phase {
|
|
||||||
Phase::Discover => "discover",
|
|
||||||
Phase::Read => "read",
|
|
||||||
Phase::Parse => "parse",
|
|
||||||
Phase::Validate => "validate",
|
|
||||||
Phase::Resolve => "resolve",
|
|
||||||
Phase::Prepare => "prepare",
|
|
||||||
Phase::Construct => "construct",
|
|
||||||
Phase::Register => "register",
|
|
||||||
Phase::Simulate => "simulate",
|
|
||||||
Phase::Render => "render",
|
|
||||||
});
|
|
||||||
out.push('"');
|
|
||||||
}
|
|
||||||
if let Some(path) = &diagnostic.context.path {
|
|
||||||
if diagnostic.context.phase.is_some() {
|
|
||||||
out.push(',');
|
|
||||||
}
|
|
||||||
out.push_str("\"path\":\"");
|
|
||||||
out.push_str(&esc(path));
|
|
||||||
out.push('"');
|
|
||||||
}
|
|
||||||
if let Some(entry) = &diagnostic.context.archive_entry {
|
|
||||||
if diagnostic.context.phase.is_some() || diagnostic.context.path.is_some() {
|
|
||||||
out.push(',');
|
|
||||||
}
|
|
||||||
out.push_str("\"archive_entry\":\"");
|
|
||||||
out.push_str(&esc(entry));
|
|
||||||
out.push('"');
|
|
||||||
}
|
|
||||||
if let Some(key) = &diagnostic.context.object_key {
|
|
||||||
if diagnostic.context.phase.is_some()
|
|
||||||
|| diagnostic.context.path.is_some()
|
|
||||||
|| diagnostic.context.archive_entry.is_some()
|
|
||||||
{
|
|
||||||
out.push(',');
|
|
||||||
}
|
|
||||||
out.push_str("\"object_key\":\"");
|
|
||||||
out.push_str(&esc(key));
|
|
||||||
out.push('"');
|
|
||||||
}
|
|
||||||
if let Some(span) = diagnostic.context.span {
|
|
||||||
if diagnostic.context.phase.is_some()
|
|
||||||
|| diagnostic.context.path.is_some()
|
|
||||||
|| diagnostic.context.archive_entry.is_some()
|
|
||||||
|| diagnostic.context.object_key.is_some()
|
|
||||||
{
|
|
||||||
out.push(',');
|
|
||||||
}
|
|
||||||
out.push_str("\"span\":{\"offset\":");
|
|
||||||
out.push_str(&span.offset.to_string());
|
|
||||||
out.push_str(",\"length\":");
|
|
||||||
out.push_str(&span.length.to_string());
|
|
||||||
out.push('}');
|
|
||||||
}
|
|
||||||
out.push_str("},\"causes\":[");
|
|
||||||
for (idx, cause) in diagnostic.causes.iter().enumerate() {
|
|
||||||
if idx > 0 {
|
|
||||||
out.push(',');
|
|
||||||
}
|
|
||||||
out.push_str(&render_json(cause));
|
|
||||||
}
|
|
||||||
out.push_str("]}");
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -298,4 +236,14 @@ mod tests {
|
|||||||
assert!(json.contains("\"code\":\"CAUSE\""));
|
assert!(json.contains("\"code\":\"CAUSE\""));
|
||||||
assert!(json.contains("\"span\":{\"offset\":16,\"length\":8}"));
|
assert!(json.contains("\"span\":{\"offset\":16,\"length\":8}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_escapes_all_control_characters() {
|
||||||
|
let value = diagnostic(DiagnosticCode("S1-H01"), "quote\"\u{0000}tab\tline\r\n");
|
||||||
|
let json = render_json(&value);
|
||||||
|
assert!(json.contains("\\u0000"));
|
||||||
|
assert!(json.contains("\\t"));
|
||||||
|
assert!(!json.contains('\t'));
|
||||||
|
assert!(!json.contains('\r'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! FXID effect contracts.
|
//! FXID effect contracts.
|
||||||
//!
|
//!
|
||||||
//! FXID decoding and command framing are implemented as compatibility
|
//! FXID decoding and command framing are implemented as compatibility
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
//! Shared inspection helpers for format-backed tooling.
|
||||||
|
|
||||||
|
use fparkan_msh::{decode_msh, validate_msh};
|
||||||
|
use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile};
|
||||||
|
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
|
||||||
|
use fparkan_rsli::decode as decode_rsli;
|
||||||
|
use fparkan_terrain_format::{decode_land_map, decode_land_msh};
|
||||||
|
use fparkan_texm::decode_texm;
|
||||||
|
use fparkan_vfs::DirectoryVfs;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Archive inspection variants.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ArchiveInspection {
|
||||||
|
/// `NRes` inspection summary.
|
||||||
|
Nres {
|
||||||
|
/// Archive entry count.
|
||||||
|
entries: usize,
|
||||||
|
/// Lookup order validity.
|
||||||
|
lookup_order_valid: bool,
|
||||||
|
/// Entry samples (subject to request limit).
|
||||||
|
sample: Vec<NresEntrySummary>,
|
||||||
|
},
|
||||||
|
/// `RsLi` inspection summary.
|
||||||
|
Rsli {
|
||||||
|
/// Archive entry count.
|
||||||
|
entries: usize,
|
||||||
|
},
|
||||||
|
/// Unknown/unsupported archive magic.
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NRes` entry summary.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct NresEntrySummary {
|
||||||
|
/// ASCII/legacy resource name.
|
||||||
|
pub name: String,
|
||||||
|
/// Entry type identifier.
|
||||||
|
pub type_id: u32,
|
||||||
|
/// Declared entry payload size.
|
||||||
|
pub data_size: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Model inspection payload.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ModelInspection {
|
||||||
|
/// Terrain stream/document stream count.
|
||||||
|
pub streams: usize,
|
||||||
|
/// Node count.
|
||||||
|
pub nodes: usize,
|
||||||
|
/// Slot count.
|
||||||
|
pub slots: usize,
|
||||||
|
/// Position count.
|
||||||
|
pub positions: usize,
|
||||||
|
/// Index count.
|
||||||
|
pub indices: usize,
|
||||||
|
/// Batch count.
|
||||||
|
pub batches: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Texture inspection payload.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct TextureInspection {
|
||||||
|
/// Width.
|
||||||
|
pub width: u32,
|
||||||
|
/// Height.
|
||||||
|
pub height: u32,
|
||||||
|
/// Texture format debug text.
|
||||||
|
pub format: String,
|
||||||
|
/// Mip level count.
|
||||||
|
pub mips: usize,
|
||||||
|
/// Total page rectangles.
|
||||||
|
pub pages: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Land map/msh inspection payload.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MapInspection {
|
||||||
|
/// Mapped mesh stream count.
|
||||||
|
pub streams: usize,
|
||||||
|
/// Slot count.
|
||||||
|
pub slots: usize,
|
||||||
|
/// Position count.
|
||||||
|
pub positions: usize,
|
||||||
|
/// Face count.
|
||||||
|
pub faces: usize,
|
||||||
|
/// Terrain areals.
|
||||||
|
pub areals: usize,
|
||||||
|
/// Declared areal count from map metadata.
|
||||||
|
pub declared_areals: u32,
|
||||||
|
/// Map grid width.
|
||||||
|
pub grid_width: u32,
|
||||||
|
/// Map grid height.
|
||||||
|
pub grid_height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supported land file kinds.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum LandFileKind {
|
||||||
|
/// `land.msh` payload.
|
||||||
|
LandMsh,
|
||||||
|
/// `land.map` payload.
|
||||||
|
LandMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inspects a format archive.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a string error when the archive cannot be read or decoded.
|
||||||
|
pub fn inspect_archive_file(path: &Path, sample_limit: usize) -> Result<ArchiveInspection, String> {
|
||||||
|
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
||||||
|
inspect_archive_bytes(&bytes, sample_limit, Some(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inspects archive bytes and returns a typed summary.
|
||||||
|
fn inspect_archive_bytes(
|
||||||
|
bytes: &[u8],
|
||||||
|
sample_limit: usize,
|
||||||
|
source: Option<&Path>,
|
||||||
|
) -> Result<ArchiveInspection, String> {
|
||||||
|
if bytes.starts_with(b"NRes") {
|
||||||
|
let document = decode_nres(
|
||||||
|
Arc::from(bytes.to_vec().into_boxed_slice()),
|
||||||
|
ReadProfile::Compatible,
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
let mut sample = Vec::new();
|
||||||
|
for entry in document.entries().iter().take(sample_limit) {
|
||||||
|
sample.push(NresEntrySummary {
|
||||||
|
name: String::from_utf8_lossy(entry.name_bytes()).to_string(),
|
||||||
|
type_id: entry.meta().type_id,
|
||||||
|
data_size: entry.meta().data_size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(ArchiveInspection::Nres {
|
||||||
|
entries: document.entries().len(),
|
||||||
|
lookup_order_valid: document.lookup_order_valid(),
|
||||||
|
sample,
|
||||||
|
})
|
||||||
|
} else if bytes.get(0..4) == Some(b"NL\0\x01") {
|
||||||
|
let document = decode_rsli(
|
||||||
|
Arc::from(bytes.to_vec().into_boxed_slice()),
|
||||||
|
fparkan_rsli::ReadProfile::Compatible,
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
Ok(ArchiveInspection::Rsli {
|
||||||
|
entries: document.entries().len(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
match source {
|
||||||
|
Some(path) => Err(format!("{}: unsupported archive magic", path.display())),
|
||||||
|
None => Err("unsupported archive magic".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inspects a model through repository-backed resource lookup.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a string error when the resource cannot be resolved or parsed as a
|
||||||
|
/// valid model payload.
|
||||||
|
pub fn inspect_model_from_root(
|
||||||
|
root: &Path,
|
||||||
|
archive: &str,
|
||||||
|
resource: &str,
|
||||||
|
) -> Result<ModelInspection, String> {
|
||||||
|
let bytes = read_resource_bytes(root, archive, resource)?;
|
||||||
|
let document = decode_nres(bytes, ReadProfile::Compatible).map_err(|err| err.to_string())?;
|
||||||
|
let msh = decode_msh(&document).map_err(|err| err.to_string())?;
|
||||||
|
let validated = validate_msh(&msh).map_err(|err| err.to_string())?;
|
||||||
|
Ok(ModelInspection {
|
||||||
|
streams: msh.streams().len(),
|
||||||
|
nodes: validated.node_count,
|
||||||
|
slots: validated.slots.len(),
|
||||||
|
positions: validated.positions.len(),
|
||||||
|
indices: validated.indices.len(),
|
||||||
|
batches: validated.batches.len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inspects a texture through repository-backed resource lookup.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a string error when the resource cannot be resolved or parsed as a
|
||||||
|
/// valid texture payload.
|
||||||
|
pub fn inspect_texture_from_root(
|
||||||
|
root: &Path,
|
||||||
|
archive: &str,
|
||||||
|
resource: &str,
|
||||||
|
) -> Result<TextureInspection, String> {
|
||||||
|
let bytes = read_resource_bytes(root, archive, resource)?;
|
||||||
|
let document = decode_texm(bytes).map_err(|err| err.to_string())?;
|
||||||
|
Ok(TextureInspection {
|
||||||
|
width: document.width(),
|
||||||
|
height: document.height(),
|
||||||
|
format: format!("{:?}", document.format()),
|
||||||
|
mips: document.mip_count(),
|
||||||
|
pages: document.page_rects().len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inspects a terrain land file by path.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a string error when the file cannot be read or parsed as the
|
||||||
|
/// requested terrain payload kind.
|
||||||
|
pub fn inspect_land_file(path: &Path, kind: LandFileKind) -> Result<MapInspection, String> {
|
||||||
|
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
||||||
|
let document = decode_nres(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
match kind {
|
||||||
|
LandFileKind::LandMsh => inspect_land_msh(&document),
|
||||||
|
LandFileKind::LandMap => inspect_land_map(&document),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_land_msh(document: &NresDocument) -> Result<MapInspection, String> {
|
||||||
|
let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?;
|
||||||
|
Ok(MapInspection {
|
||||||
|
streams: land_msh.streams.len(),
|
||||||
|
slots: land_msh.slots.slots_raw.len(),
|
||||||
|
positions: land_msh.positions.len(),
|
||||||
|
faces: land_msh.faces.len(),
|
||||||
|
areals: 0,
|
||||||
|
declared_areals: 0,
|
||||||
|
grid_width: 0,
|
||||||
|
grid_height: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_land_map(document: &NresDocument) -> Result<MapInspection, String> {
|
||||||
|
let land_map = decode_land_map(document).map_err(|err| err.to_string())?;
|
||||||
|
Ok(MapInspection {
|
||||||
|
streams: 0,
|
||||||
|
slots: 0,
|
||||||
|
positions: 0,
|
||||||
|
faces: 0,
|
||||||
|
areals: land_map.areals.len(),
|
||||||
|
declared_areals: land_map.areal_count,
|
||||||
|
grid_width: land_map.grid.cells_x,
|
||||||
|
grid_height: land_map.grid.cells_y,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_resource_bytes(root: &Path, archive: &str, name: &str) -> Result<Arc<[u8]>, String> {
|
||||||
|
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
|
||||||
|
let archive_path = archive_path(archive.as_bytes()).map_err(|err| err.to_string())?;
|
||||||
|
let resource_name = resource_name(name.as_bytes());
|
||||||
|
let archive_handle = repository
|
||||||
|
.open_archive(&archive_path)
|
||||||
|
.map_err(|err| format!("{err}"))?;
|
||||||
|
let Some(handle) = repository
|
||||||
|
.find(archive_handle, &resource_name)
|
||||||
|
.map_err(|err| format!("{err}"))?
|
||||||
|
else {
|
||||||
|
return Err(format!(
|
||||||
|
"resource not found: {archive}/{}",
|
||||||
|
String::from_utf8_lossy(name.as_bytes())
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let bytes = repository.read(handle).map_err(|err| format!("{err}"))?;
|
||||||
|
Ok(Arc::from(bytes.into_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inspect_rsli_rejects_malformed_archive() {
|
||||||
|
let dir = temp_dir("inspect");
|
||||||
|
let path = dir.join("test.rsli");
|
||||||
|
let mut file = fs::File::create(&path).expect("file");
|
||||||
|
file.write_all(b"NL\0\x01").expect("magic");
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
let error = inspect_archive_file(&path, 0).expect_err("malformed archive");
|
||||||
|
assert!(error.contains("entry table out of bounds"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nres_entry_summary_fields_are_readable() {
|
||||||
|
let dir = temp_dir("inspect-nres");
|
||||||
|
let archive = dir.join("test.nres");
|
||||||
|
let payload = Vec::from("NRes\x00\x00\x00\x00");
|
||||||
|
fs::write(&archive, &payload).expect("nres");
|
||||||
|
|
||||||
|
let _ = inspect_archive_file(&archive, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_dir(name: &str) -> PathBuf {
|
||||||
|
let base = PathBuf::from("/tmp")
|
||||||
|
.join("fparkan-inspection-tests")
|
||||||
|
.join(name);
|
||||||
|
let _ = fs::remove_dir_all(&base);
|
||||||
|
fs::create_dir_all(&base).expect("tmp dir");
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,23 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! WEAR/MAT0 material contracts.
|
//! WEAR/MAT0 material contracts.
|
||||||
|
|
||||||
use encoding_rs::WINDOWS_1251;
|
use encoding_rs::WINDOWS_1251;
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! Count-driven mission format primitives.
|
//! Count-driven mission format primitives.
|
||||||
|
|
||||||
use encoding_rs::WINDOWS_1251;
|
use encoding_rs::WINDOWS_1251;
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! Stage-3 MSH asset contract.
|
//! Stage-3 MSH asset contract.
|
||||||
|
|
||||||
use encoding_rs::WINDOWS_1251;
|
use encoding_rs::WINDOWS_1251;
|
||||||
@@ -1689,7 +1708,7 @@ mod tests {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::similar_names, clippy::too_many_arguments)]
|
||||||
fn batch_record(
|
fn batch_record(
|
||||||
batch_flags: u16,
|
batch_flags: u16,
|
||||||
material_index: u16,
|
material_index: u16,
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! Strict and lossless `NRes` archive support.
|
//! Strict and lossless `NRes` archive support.
|
||||||
|
|
||||||
use fparkan_binary::{Cursor, DecodeError};
|
use fparkan_binary::{Cursor, DecodeError};
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! Legacy path normalization and ASCII lookup semantics.
|
//! Legacy path normalization and ASCII lookup semantics.
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
@@ -24,13 +43,28 @@ impl OriginalPathBytes {
|
|||||||
|
|
||||||
/// Normalized relative path.
|
/// Normalized relative path.
|
||||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
pub struct NormalizedPath(String);
|
pub struct NormalizedPath {
|
||||||
|
raw: Vec<u8>,
|
||||||
|
display: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl NormalizedPath {
|
impl NormalizedPath {
|
||||||
/// Returns string view.
|
/// Returns string view.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
&self.0
|
&self.display
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns normalized byte view.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_bytes(&self) -> &[u8] {
|
||||||
|
&self.raw
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an OS path owned path buffer.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_path(&self) -> PathBuf {
|
||||||
|
as_os_path_from_bytes(&self.raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,8 +125,6 @@ pub enum PathError {
|
|||||||
ParentTraversal,
|
ParentTraversal,
|
||||||
/// Host path escape.
|
/// Host path escape.
|
||||||
EscapesRoot,
|
EscapesRoot,
|
||||||
/// Invalid UTF-8 after normalization.
|
|
||||||
InvalidUtf8,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for PathError {
|
impl fmt::Display for PathError {
|
||||||
@@ -103,7 +135,6 @@ impl fmt::Display for PathError {
|
|||||||
Self::Absolute => write!(f, "path must be relative and cannot be absolute"),
|
Self::Absolute => write!(f, "path must be relative and cannot be absolute"),
|
||||||
Self::ParentTraversal => write!(f, "path attempts to traverse outside its root"),
|
Self::ParentTraversal => write!(f, "path attempts to traverse outside its root"),
|
||||||
Self::EscapesRoot => write!(f, "normalized path escapes the configured root"),
|
Self::EscapesRoot => write!(f, "normalized path escapes the configured root"),
|
||||||
Self::InvalidUtf8 => write!(f, "path is not valid UTF-8 after normalization"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,8 +146,7 @@ impl std::error::Error for PathError {}
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns [`PathError`] when the input is empty, absolute, contains an
|
/// Returns [`PathError`] when the input is empty, absolute, contains an
|
||||||
/// embedded NUL, attempts parent traversal, or is not valid UTF-8 after
|
/// embedded NUL, attempts parent traversal, or has an invalid drive prefix.
|
||||||
/// legacy separator normalization.
|
|
||||||
pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPath, PathError> {
|
pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPath, PathError> {
|
||||||
if raw.is_empty() {
|
if raw.is_empty() {
|
||||||
return Err(PathError::Empty);
|
return Err(PathError::Empty);
|
||||||
@@ -124,22 +154,21 @@ pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPa
|
|||||||
if raw.contains(&0) {
|
if raw.contains(&0) {
|
||||||
return Err(PathError::EmbeddedNul);
|
return Err(PathError::EmbeddedNul);
|
||||||
}
|
}
|
||||||
let text = std::str::from_utf8(raw).map_err(|_| PathError::InvalidUtf8)?;
|
if raw.starts_with(b"/") || raw.starts_with(b"\\") || has_drive_prefix(raw) {
|
||||||
if text.starts_with('/') || text.starts_with('\\') || has_drive_prefix(text) {
|
|
||||||
return Err(PathError::Absolute);
|
return Err(PathError::Absolute);
|
||||||
}
|
}
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
for part in text.split(['/', '\\']) {
|
for part in raw.split(|byte| *byte == b'/' || *byte == b'\\') {
|
||||||
if part.is_empty() || part == "." {
|
if part.is_empty() || part == b"." {
|
||||||
if policy == PathPolicy::StrictLegacy {
|
if policy == PathPolicy::StrictLegacy {
|
||||||
return Err(PathError::ParentTraversal);
|
return Err(PathError::ParentTraversal);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if part == ".." {
|
if part == b".." {
|
||||||
return Err(PathError::ParentTraversal);
|
return Err(PathError::ParentTraversal);
|
||||||
}
|
}
|
||||||
if policy == PathPolicy::StrictLegacy && part.contains(':') {
|
if policy == PathPolicy::StrictLegacy && part.contains(&b':') {
|
||||||
return Err(PathError::Absolute);
|
return Err(PathError::Absolute);
|
||||||
}
|
}
|
||||||
parts.push(part);
|
parts.push(part);
|
||||||
@@ -147,7 +176,18 @@ pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPa
|
|||||||
if parts.is_empty() {
|
if parts.is_empty() {
|
||||||
return Err(PathError::Empty);
|
return Err(PathError::Empty);
|
||||||
}
|
}
|
||||||
Ok(NormalizedPath(parts.join("/")))
|
let mut normalized = Vec::new();
|
||||||
|
for (index, part) in parts.iter().enumerate() {
|
||||||
|
if index > 0 {
|
||||||
|
normalized.push(b'/');
|
||||||
|
}
|
||||||
|
normalized.extend_from_slice(part);
|
||||||
|
}
|
||||||
|
let display = String::from_utf8_lossy(&normalized).into_owned();
|
||||||
|
Ok(NormalizedPath {
|
||||||
|
raw: normalized,
|
||||||
|
display,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normalizes a relative path while preserving its original bytes.
|
/// Normalizes a relative path while preserving its original bytes.
|
||||||
@@ -166,8 +206,7 @@ pub fn normalize_relative_with_original(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_drive_prefix(text: &str) -> bool {
|
fn has_drive_prefix(bytes: &[u8]) -> bool {
|
||||||
let bytes = text.as_bytes();
|
|
||||||
bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
|
bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +223,11 @@ pub fn ascii_lookup_key(raw: &[u8]) -> LookupKey {
|
|||||||
/// Returns [`PathError::ParentTraversal`] when a normalized segment attempts
|
/// Returns [`PathError::ParentTraversal`] when a normalized segment attempts
|
||||||
/// to address a parent directory.
|
/// to address a parent directory.
|
||||||
pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
|
pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
|
||||||
if rel.0.split('/').any(|part| part == "..") {
|
if rel
|
||||||
|
.as_bytes()
|
||||||
|
.split(|byte| *byte == b'/')
|
||||||
|
.any(|part| part == b"..")
|
||||||
|
{
|
||||||
Err(PathError::ParentTraversal)
|
Err(PathError::ParentTraversal)
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -198,7 +241,20 @@ pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
|
|||||||
/// Returns [`PathError`] if the normalized path fails the escape check.
|
/// Returns [`PathError`] if the normalized path fails the escape check.
|
||||||
pub fn join_under(root: &Path, rel: &NormalizedPath) -> Result<PathBuf, PathError> {
|
pub fn join_under(root: &Path, rel: &NormalizedPath) -> Result<PathBuf, PathError> {
|
||||||
reject_escape(rel)?;
|
reject_escape(rel)?;
|
||||||
Ok(root.join(rel.as_str()))
|
Ok(root.join(rel.as_path()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf {
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::os::unix::ffi::OsStringExt;
|
||||||
|
|
||||||
|
PathBuf::from(OsString::from_vec(raw.to_vec()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf {
|
||||||
|
PathBuf::from(String::from_utf8_lossy(raw).into_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -292,6 +348,14 @@ mod tests {
|
|||||||
assert_eq!(&ascii_lookup_key(raw).0[5..13], &raw[5..13]);
|
assert_eq!(&ascii_lookup_key(raw).0[5..13], &raw[5..13]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_non_utf8_legacy_bytes() {
|
||||||
|
let path = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
|
||||||
|
.expect("raw legacy bytes");
|
||||||
|
|
||||||
|
assert_eq!(path.as_str(), "DATA/\u{FFFD}.bin");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn original_separators_and_raw_bytes_are_preserved() {
|
fn original_separators_and_raw_bytes_are_preserved() {
|
||||||
let raw = b"DATA\\Maps/Intro\\Land.msh";
|
let raw = b"DATA\\Maps/Intro\\Land.msh";
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
raw-window-handle = "0.6"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -1,39 +1,125 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
//! Platform ports for clocks, input, events, windows, and graphics requests.
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
//! Platform ports for clocks, event sources and window descriptors.
|
||||||
|
|
||||||
/// Monotonic instant.
|
use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
||||||
|
|
||||||
|
/// Monotonic instant measured in milliseconds since process start.
|
||||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
pub struct MonotonicInstant(pub u64);
|
pub struct MonotonicInstant(pub u64);
|
||||||
|
|
||||||
/// Monotonic clock.
|
/// Platform clock.
|
||||||
pub trait MonotonicClock {
|
pub trait MonotonicClock {
|
||||||
/// Current instant.
|
/// Current instant.
|
||||||
fn now(&self) -> MonotonicInstant;
|
fn now(&self) -> MonotonicInstant;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Platform event.
|
/// Platform event.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub enum PlatformEvent {
|
pub enum PlatformEvent {
|
||||||
/// Quit requested.
|
/// Window/application requested to quit.
|
||||||
Quit,
|
QuitRequested,
|
||||||
|
/// Window focus changed.
|
||||||
|
FocusChanged {
|
||||||
|
/// Whether the window is focused.
|
||||||
|
focused: bool,
|
||||||
|
},
|
||||||
|
/// Window resize or move to a new drawable size.
|
||||||
|
Resize {
|
||||||
|
/// Drawable width in physical pixels.
|
||||||
|
width: u32,
|
||||||
|
/// Drawable height in physical pixels.
|
||||||
|
height: u32,
|
||||||
|
},
|
||||||
|
/// Device pixel ratio changed.
|
||||||
|
DpiChanged {
|
||||||
|
/// Logical-to-physical scale factor.
|
||||||
|
scale: f64,
|
||||||
|
},
|
||||||
|
/// Window minimized/hidden.
|
||||||
|
Minimized {
|
||||||
|
/// Whether the window is minimized.
|
||||||
|
minimized: bool,
|
||||||
|
},
|
||||||
|
/// Window occlusion state changed.
|
||||||
|
Occluded {
|
||||||
|
/// Whether the window is occluded.
|
||||||
|
occluded: bool,
|
||||||
|
},
|
||||||
|
/// Window is being suspended.
|
||||||
|
Suspended,
|
||||||
|
/// Window resumed from suspend.
|
||||||
|
Resumed,
|
||||||
|
/// Keyboard/scancode input.
|
||||||
|
KeyboardInput {
|
||||||
|
/// Platform scancode.
|
||||||
|
scancode: u32,
|
||||||
|
/// Pressed state.
|
||||||
|
pressed: bool,
|
||||||
|
},
|
||||||
|
/// Mouse button input.
|
||||||
|
MouseInput {
|
||||||
|
/// Mouse button code.
|
||||||
|
button: u16,
|
||||||
|
/// Pressed state.
|
||||||
|
pressed: bool,
|
||||||
|
/// X position in window coordinates.
|
||||||
|
x: f64,
|
||||||
|
/// Y position in window coordinates.
|
||||||
|
y: f64,
|
||||||
|
},
|
||||||
|
/// Mouse cursor movement.
|
||||||
|
CursorMoved {
|
||||||
|
/// Cursor x.
|
||||||
|
x: f64,
|
||||||
|
/// Cursor y.
|
||||||
|
y: f64,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Platform error.
|
/// Platform error with optional source detail.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum PlatformError {
|
pub enum PlatformError {
|
||||||
/// Backend failed.
|
/// Backend/backend-specific failure.
|
||||||
Backend,
|
Backend {
|
||||||
|
/// Operation or subsystem.
|
||||||
|
context: &'static str,
|
||||||
|
/// Human-readable details.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for PlatformError {
|
impl std::fmt::Display for PlatformError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
write!(f, "{self:?}")
|
match self {
|
||||||
|
Self::Backend { context, message } => {
|
||||||
|
write!(f, "{context}: {message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for PlatformError {}
|
impl std::error::Error for PlatformError {}
|
||||||
|
|
||||||
/// Event source.
|
/// Event source contract for polling platform events.
|
||||||
pub trait EventSource {
|
pub trait EventSource {
|
||||||
/// Polls events.
|
/// Polls events.
|
||||||
///
|
///
|
||||||
@@ -43,7 +129,7 @@ pub trait EventSource {
|
|||||||
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError>;
|
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Physical size.
|
/// Physical window size.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub struct PhysicalSize {
|
pub struct PhysicalSize {
|
||||||
/// Width.
|
/// Width.
|
||||||
@@ -52,42 +138,96 @@ pub struct PhysicalSize {
|
|||||||
pub height: u32,
|
pub height: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Window port.
|
/// Window identity as a stable opaque handle token.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||||
|
pub struct WindowHandle {
|
||||||
|
/// Opaque integer token.
|
||||||
|
pub id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native raw window/display handles for render surface creation.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct NativeWindowHandles {
|
||||||
|
/// Raw display handle.
|
||||||
|
pub display: RawDisplayHandle,
|
||||||
|
/// Raw window handle.
|
||||||
|
pub window: RawWindowHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Window presentation and lifecycle port.
|
||||||
|
///
|
||||||
|
/// Presentation is not owned by the window abstraction. Render adapters
|
||||||
|
/// own swapchain and present lifecycle.
|
||||||
pub trait WindowPort {
|
pub trait WindowPort {
|
||||||
/// Drawable size.
|
/// Current drawable size.
|
||||||
fn drawable_size(&self) -> PhysicalSize;
|
fn drawable_size(&self) -> PhysicalSize;
|
||||||
/// Presents.
|
/// DPI scale for this window.
|
||||||
///
|
fn dpi_scale(&self) -> f64;
|
||||||
/// # Errors
|
/// Whether the window is focused.
|
||||||
///
|
fn has_focus(&self) -> bool;
|
||||||
/// Returns [`PlatformError`] when the backend cannot present the current
|
/// Whether the window is minimized.
|
||||||
/// frame.
|
fn is_minimized(&self) -> bool;
|
||||||
fn present(&mut self) -> Result<(), PlatformError>;
|
/// Whether the window is occluded.
|
||||||
|
fn is_occluded(&self) -> bool;
|
||||||
|
/// Opaque window identity.
|
||||||
|
fn handle(&self) -> WindowHandle;
|
||||||
|
/// Raw native handles for render surface creation, when backed by a native window.
|
||||||
|
fn native_handles(&self) -> Option<NativeWindowHandles> {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Graphics profile.
|
/// Render backend request contract.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum GraphicsProfile {
|
pub struct RenderRequest {
|
||||||
/// Desktop core.
|
/// Preferred color-space profile.
|
||||||
DesktopCore,
|
pub color_space: ColorSpace,
|
||||||
/// Embedded profile.
|
/// Preferred presentation mode.
|
||||||
Embedded,
|
pub presentation: PresentationMode,
|
||||||
|
/// Requested depth/stencil format.
|
||||||
|
pub depth: DepthStencilSupport,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Version.
|
/// Color-space profile.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub struct Version {
|
pub enum ColorSpace {
|
||||||
/// Major.
|
/// sRGB nonlinear.
|
||||||
pub major: u8,
|
Srgb,
|
||||||
/// Minor.
|
/// Linear color-space.
|
||||||
pub minor: u8,
|
Linear,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Graphics context request.
|
/// Presentation mode.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub struct GraphicsContextRequest {
|
pub enum PresentationMode {
|
||||||
/// Profile.
|
/// `VSync`.
|
||||||
pub profile: GraphicsProfile,
|
Fifo,
|
||||||
/// Version.
|
/// No `VSync`.
|
||||||
pub version: Version,
|
Immediate,
|
||||||
|
/// Triple-buffer mailbox fallback.
|
||||||
|
Mailbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Depth/stencil support profile requested by the composition root.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct DepthStencilSupport {
|
||||||
|
/// Depth bits.
|
||||||
|
pub depth_bits: u8,
|
||||||
|
/// Stencil bits.
|
||||||
|
pub stencil_bits: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderRequest {
|
||||||
|
/// Returns a conservative default request.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn conservative() -> Self {
|
||||||
|
Self {
|
||||||
|
color_space: ColorSpace::Srgb,
|
||||||
|
presentation: PresentationMode::Fifo,
|
||||||
|
depth: DepthStencilSupport {
|
||||||
|
depth_bits: 24,
|
||||||
|
stencil_bits: 8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ repository.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
encoding_rs = "0.8"
|
encoding_rs = "0.8"
|
||||||
fparkan-binary = { path = "../fparkan-binary" }
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
fparkan-material = { path = "../fparkan-material" }
|
|
||||||
fparkan-msh = { path = "../fparkan-msh" }
|
|
||||||
fparkan-nres = { path = "../fparkan-nres" }
|
|
||||||
fparkan-path = { path = "../fparkan-path" }
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
fparkan-resource = { path = "../fparkan-resource" }
|
fparkan-resource = { path = "../fparkan-resource" }
|
||||||
fparkan-texm = { path = "../fparkan-texm" }
|
|
||||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
|
|||||||
+583
-282
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,23 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! Backend-neutral render commands and deterministic captures.
|
//! Backend-neutral render commands and deterministic captures.
|
||||||
|
|
||||||
use fparkan_world::OriginalObjectId;
|
use fparkan_world::OriginalObjectId;
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! Resource identity and repository ports.
|
//! Resource identity and repository ports.
|
||||||
|
|
||||||
use fparkan_binary::Sha256Digest;
|
use fparkan_binary::Sha256Digest;
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! Stage-1 `RsLi` archive contract.
|
//! Stage-1 `RsLi` archive contract.
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
@@ -59,6 +78,71 @@ pub enum WriteProfile {
|
|||||||
Lossless,
|
Lossless,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Error returned when mutable editing is attempted.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum RsliMutationError {
|
||||||
|
/// Entry id is not present in this editable document.
|
||||||
|
EntryNotFound {
|
||||||
|
/// Requested entry id.
|
||||||
|
id: EntryId,
|
||||||
|
},
|
||||||
|
/// Entry name does not fit into a 12-byte fixed field.
|
||||||
|
AuthoringNameTooLong {
|
||||||
|
/// Observed length in bytes.
|
||||||
|
len: usize,
|
||||||
|
/// Maximum accepted length for an authoring field.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
/// Entry name contains an explicit NUL byte.
|
||||||
|
AuthoringNameContainsNul {
|
||||||
|
/// Byte offset within the provided name.
|
||||||
|
offset: usize,
|
||||||
|
},
|
||||||
|
/// Packed payload size overflows the format `u32` field.
|
||||||
|
PackedPayloadTooLarge {
|
||||||
|
/// Requested packed payload size.
|
||||||
|
size: usize,
|
||||||
|
/// Format maximum (`u32::MAX`).
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for RsliMutationError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::EntryNotFound { id } => write!(f, "entry id {id:?} is not present"),
|
||||||
|
Self::AuthoringNameTooLong { len, max } => {
|
||||||
|
write!(f, "authoring name is too long: {len} > {max}")
|
||||||
|
}
|
||||||
|
Self::AuthoringNameContainsNul { offset } => {
|
||||||
|
write!(f, "authoring name contains embedded NUL at {offset}")
|
||||||
|
}
|
||||||
|
Self::PackedPayloadTooLarge { size, max } => {
|
||||||
|
write!(f, "packed payload is too large: {size} > {max}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for RsliMutationError {}
|
||||||
|
|
||||||
|
/// Mutable editor for `RsliDocument` that can rebuild lookup tables.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RsliEditor {
|
||||||
|
original_image: Arc<[u8]>,
|
||||||
|
header: RsliHeader,
|
||||||
|
overlay: u32,
|
||||||
|
ao_trailer: Option<[u8; 6]>,
|
||||||
|
entries: Vec<EditableEntry>,
|
||||||
|
dirty: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct EditableEntry {
|
||||||
|
meta: EntryMeta,
|
||||||
|
packed: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
/// `RsLi` compatibility switches.
|
/// `RsLi` compatibility switches.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub struct RsliCompatibilityProfile {
|
pub struct RsliCompatibilityProfile {
|
||||||
@@ -493,6 +577,199 @@ impl RsliDocument {
|
|||||||
WriteProfile::Lossless => self.bytes.to_vec(),
|
WriteProfile::Lossless => self.bytes.to_vec(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a mutable editor from the parsed document.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`RsliError`] when source payloads cannot be copied from the
|
||||||
|
/// underlying archive image.
|
||||||
|
pub fn editor(&self) -> Result<RsliEditor, RsliError> {
|
||||||
|
let mut entries = Vec::with_capacity(self.records.len());
|
||||||
|
for (id, record) in self.records.iter().enumerate() {
|
||||||
|
let entry_id = EntryId(u32::try_from(id).map_err(|_| RsliError::IntegerOverflow)?);
|
||||||
|
let packed = self.packed_slice(entry_id, record)?.to_vec();
|
||||||
|
entries.push(EditableEntry {
|
||||||
|
meta: record.meta.clone(),
|
||||||
|
packed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RsliEditor {
|
||||||
|
original_image: self.bytes.clone(),
|
||||||
|
header: self.header.clone(),
|
||||||
|
overlay: self
|
||||||
|
.ao_trailer
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |overlay| overlay.overlay),
|
||||||
|
ao_trailer: self.ao_trailer.as_ref().map(|overlay| overlay.raw),
|
||||||
|
entries,
|
||||||
|
dirty: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RsliEditor {
|
||||||
|
/// Returns editable entries by original directory id.
|
||||||
|
#[must_use]
|
||||||
|
pub fn entry_count(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces packed payload bytes for an entry.
|
||||||
|
///
|
||||||
|
/// `unpacked_size` is stored explicitly for compatibility checks and does
|
||||||
|
/// not imply a packing transform.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`RsliMutationError`] when the entry id is unknown or the packed
|
||||||
|
/// payload is too large for the archive directory format.
|
||||||
|
pub fn set_packed_payload(
|
||||||
|
&mut self,
|
||||||
|
id: EntryId,
|
||||||
|
packed: impl Into<Vec<u8>>,
|
||||||
|
unpacked_size: u32,
|
||||||
|
) -> Result<(), RsliMutationError> {
|
||||||
|
let entry = self.entry_mut(id)?;
|
||||||
|
let packed = packed.into();
|
||||||
|
entry.meta.packed_size =
|
||||||
|
u32::try_from(packed.len()).map_err(|_| RsliMutationError::PackedPayloadTooLarge {
|
||||||
|
size: packed.len(),
|
||||||
|
max: u32::MAX as usize,
|
||||||
|
})?;
|
||||||
|
entry.packed = packed;
|
||||||
|
entry.meta.unpacked_size = unpacked_size;
|
||||||
|
self.dirty = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces entry packing method in-place.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`RsliMutationError`] when the entry id is unknown.
|
||||||
|
pub fn set_method(&mut self, id: EntryId, method: RsliMethod) -> Result<(), RsliMutationError> {
|
||||||
|
let entry = self.entry_mut(id)?;
|
||||||
|
entry.meta.method = method;
|
||||||
|
self.dirty = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces entry name in the fixed 12-byte table field.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`RsliMutationError`] when the entry id is unknown or the name
|
||||||
|
/// cannot be represented in the fixed authoring field.
|
||||||
|
pub fn set_name(&mut self, id: EntryId, name: &[u8]) -> Result<(), RsliMutationError> {
|
||||||
|
let entry = self.entry_mut(id)?;
|
||||||
|
entry.meta.name_raw = authoring_name_raw(name)?;
|
||||||
|
entry.meta.name = decode_name(c_name_bytes(&entry.meta.name_raw));
|
||||||
|
self.dirty = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes the document according to editor state.
|
||||||
|
///
|
||||||
|
/// For untouched documents returns the original image verbatim. On any
|
||||||
|
/// mutation this method rebuilds the lookup table and rewrites packed entry
|
||||||
|
/// bytes deterministically.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`RsliError`] when offsets, sizes or ids exceed in-memory limits.
|
||||||
|
pub fn encode(&self) -> Result<Vec<u8>, RsliError> {
|
||||||
|
if !self.dirty {
|
||||||
|
return Ok(self.original_image.to_vec());
|
||||||
|
}
|
||||||
|
self.encode_rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_rebuild(&self) -> Result<Vec<u8>, RsliError> {
|
||||||
|
let mut output = Vec::with_capacity(self.original_image.len());
|
||||||
|
|
||||||
|
let entry_count =
|
||||||
|
u16::try_from(self.entries.len()).map_err(|_| RsliError::IntegerOverflow)?;
|
||||||
|
let table_len = self
|
||||||
|
.entries
|
||||||
|
.len()
|
||||||
|
.checked_mul(32)
|
||||||
|
.ok_or(RsliError::IntegerOverflow)?;
|
||||||
|
|
||||||
|
let mut header = self.header.raw;
|
||||||
|
header[4..6].copy_from_slice(&entry_count.to_le_bytes());
|
||||||
|
output.extend_from_slice(&header);
|
||||||
|
|
||||||
|
let mut sorted = (0..self.entries.len()).collect::<Vec<_>>();
|
||||||
|
sorted.sort_by(|left, right| {
|
||||||
|
cmp_c_string(
|
||||||
|
c_name_bytes(&self.entries[*left].meta.name_raw),
|
||||||
|
c_name_bytes(&self.entries[*right].meta.name_raw),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut lookup_map = vec![0i16; self.entries.len()];
|
||||||
|
for (position, original) in sorted.iter().enumerate() {
|
||||||
|
lookup_map[*original] =
|
||||||
|
i16::try_from(position).map_err(|_| RsliError::IntegerOverflow)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cursor = 32usize
|
||||||
|
.checked_add(table_len)
|
||||||
|
.ok_or(RsliError::IntegerOverflow)?;
|
||||||
|
let mut table_plain = Vec::with_capacity(table_len);
|
||||||
|
for (index, entry) in self.entries.iter().enumerate() {
|
||||||
|
let mut row = [0u8; 32];
|
||||||
|
let name_len = entry.meta.name_raw.len().min(12);
|
||||||
|
row[0..name_len].copy_from_slice(&entry.meta.name_raw[..name_len]);
|
||||||
|
|
||||||
|
row[16..18].copy_from_slice(
|
||||||
|
&i16::try_from(entry.meta.flags)
|
||||||
|
.map_err(|_| RsliError::IntegerOverflow)?
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
row[18..20].copy_from_slice(&lookup_map[index].to_le_bytes());
|
||||||
|
row[20..24].copy_from_slice(&entry.meta.unpacked_size.to_le_bytes());
|
||||||
|
|
||||||
|
let packed_len =
|
||||||
|
u32::try_from(entry.packed.len()).map_err(|_| RsliError::IntegerOverflow)?;
|
||||||
|
let cursor_u32 = u32::try_from(cursor).map_err(|_| RsliError::IntegerOverflow)?;
|
||||||
|
let offset_raw = if self.overlay == 0 {
|
||||||
|
cursor_u32
|
||||||
|
} else {
|
||||||
|
cursor_u32
|
||||||
|
.checked_sub(self.overlay)
|
||||||
|
.ok_or(RsliError::IntegerOverflow)?
|
||||||
|
};
|
||||||
|
|
||||||
|
row[24..28].copy_from_slice(&offset_raw.to_le_bytes());
|
||||||
|
row[28..32].copy_from_slice(&packed_len.to_le_bytes());
|
||||||
|
table_plain.extend_from_slice(&row);
|
||||||
|
|
||||||
|
output.extend_from_slice(&entry.packed);
|
||||||
|
cursor = cursor
|
||||||
|
.checked_add(entry.packed.len())
|
||||||
|
.ok_or(RsliError::IntegerOverflow)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let seed =
|
||||||
|
u16::try_from(self.header.xor_seed & 0xFFFF).map_err(|_| RsliError::IntegerOverflow)?;
|
||||||
|
let encrypted = xor_stream(&table_plain, seed);
|
||||||
|
output.splice(32..32, encrypted);
|
||||||
|
|
||||||
|
if let Some(overlay) = &self.ao_trailer {
|
||||||
|
output.extend_from_slice(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry_mut(&mut self, id: EntryId) -> Result<&mut EditableEntry, RsliMutationError> {
|
||||||
|
self.entries
|
||||||
|
.get_mut(usize::try_from(id.0).map_err(|_| RsliMutationError::EntryNotFound { id })?)
|
||||||
|
.ok_or(RsliMutationError::EntryNotFound { id })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RsliDocument {
|
impl RsliDocument {
|
||||||
@@ -833,6 +1110,23 @@ fn decode_name(name: &[u8]) -> String {
|
|||||||
name.iter().map(|byte| char::from(*byte)).collect()
|
name.iter().map(|byte| char::from(*byte)).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn authoring_name_raw(name: &[u8]) -> Result<[u8; 12], RsliMutationError> {
|
||||||
|
if name.len() > 12 {
|
||||||
|
return Err(RsliMutationError::AuthoringNameTooLong {
|
||||||
|
len: name.len(),
|
||||||
|
max: 12,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut output = [0u8; 12];
|
||||||
|
for (offset, byte) in name.iter().copied().enumerate() {
|
||||||
|
if byte == 0 {
|
||||||
|
return Err(RsliMutationError::AuthoringNameContainsNul { offset });
|
||||||
|
}
|
||||||
|
output[offset] = byte;
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
|
fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
|
||||||
let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len());
|
let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len());
|
||||||
&raw[..len]
|
&raw[..len]
|
||||||
@@ -1814,6 +2108,86 @@ mod tests {
|
|||||||
assert_eq!(doc.encode(WriteProfile::Lossless), bytes);
|
assert_eq!(doc.encode(WriteProfile::Lossless), bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_roundtrip_without_mutations_is_identity() {
|
||||||
|
let bytes = synthetic_rsli(
|
||||||
|
&[
|
||||||
|
SyntheticEntry::stored(b"A", 0, b"alpha"),
|
||||||
|
SyntheticEntry::stored(b"B", 1, b"beta"),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
0x7777,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("editable archive");
|
||||||
|
let editor = doc.editor().expect("editor");
|
||||||
|
|
||||||
|
assert_eq!(editor.encode().expect("editor encode"), bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_can_mutate_names_and_payloads() {
|
||||||
|
let bytes = synthetic_rsli(
|
||||||
|
&[
|
||||||
|
SyntheticEntry::stored(b"A", 0, b"alpha"),
|
||||||
|
SyntheticEntry::stored(b"B", 1, b"beta"),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
0x7778,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive");
|
||||||
|
let mut editor = doc.editor().expect("editor");
|
||||||
|
editor.set_name(EntryId(1), b"ZETA").expect("edit name");
|
||||||
|
editor
|
||||||
|
.set_packed_payload(EntryId(0), b"repacked-alpha", 14)
|
||||||
|
.expect("edit packed payload");
|
||||||
|
editor
|
||||||
|
.set_method(EntryId(0), RsliMethod::RawDeflate)
|
||||||
|
.expect("edit method");
|
||||||
|
|
||||||
|
let rebuilt = editor.encode().expect("editor encode");
|
||||||
|
let doc = decode(arc(rebuilt), ReadProfile::Strict).expect("repacked archive");
|
||||||
|
|
||||||
|
let renamed = doc.find("ZETA").expect("renamed entry");
|
||||||
|
assert_eq!(doc.load(renamed).expect("renamed payload"), b"beta");
|
||||||
|
let original = doc
|
||||||
|
.find("A")
|
||||||
|
.or_else(|| doc.find("a"))
|
||||||
|
.expect("original renamed entry fallback");
|
||||||
|
assert_eq!(
|
||||||
|
doc.load(original).expect("updated payload"),
|
||||||
|
b"repacked-alpha"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
doc.entries()[original.0 as usize].method,
|
||||||
|
RsliMethod::Stored
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_rejects_unknown_entry_id_and_invalid_name() {
|
||||||
|
let bytes = synthetic_rsli(
|
||||||
|
&[SyntheticEntry::stored(b"A", 0, b"alpha")],
|
||||||
|
true,
|
||||||
|
0x7779,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive");
|
||||||
|
let mut editor = doc.editor().expect("editor");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
editor.set_name(EntryId(10), b"BAD"),
|
||||||
|
Err(RsliMutationError::EntryNotFound { id: EntryId(10) })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
editor.set_name(EntryId(0), b"TOO_LONG_ENTRY_NAME"),
|
||||||
|
Err(RsliMutationError::AuthoringNameTooLong { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generated_supported_methods_decode_expected_bytes() {
|
fn generated_supported_methods_decode_expected_bytes() {
|
||||||
let cases = [
|
let cases = [
|
||||||
|
|||||||
@@ -6,15 +6,12 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-mission-format = { path = "../fparkan-mission-format" }
|
fparkan-assets = { path = "../fparkan-assets" }
|
||||||
fparkan-nres = { path = "../fparkan-nres" }
|
|
||||||
fparkan-path = { path = "../fparkan-path" }
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
fparkan-platform = { path = "../fparkan-platform" }
|
fparkan-platform = { path = "../fparkan-platform" }
|
||||||
fparkan-prototype = { path = "../fparkan-prototype" }
|
fparkan-prototype = { path = "../fparkan-prototype" }
|
||||||
fparkan-render = { path = "../fparkan-render" }
|
fparkan-render = { path = "../fparkan-render" }
|
||||||
fparkan-resource = { path = "../fparkan-resource" }
|
fparkan-resource = { path = "../fparkan-resource" }
|
||||||
fparkan-terrain = { path = "../fparkan-terrain" }
|
|
||||||
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
|
||||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||||
fparkan-world = { path = "../fparkan-world" }
|
fparkan-world = { path = "../fparkan-world" }
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,37 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! Runtime orchestration for headless and rendered modes.
|
//! Runtime orchestration for headless and rendered modes.
|
||||||
|
|
||||||
use fparkan_mission_format::{
|
use fparkan_assets::{
|
||||||
decode_tma, decode_tma_land_path, LpString, MissionDocument, MissionError, TmaProfile,
|
decode_mission_land_path, decode_mission_payload, decode_nres_payload,
|
||||||
|
derive_mission_land_paths, extend_graph_report_with_visual_dependencies, prepare_terrain_world,
|
||||||
|
AssetError as AssetPreparationError, AssetManager, BuildCategory, MissionAssetPlan,
|
||||||
|
MissionDocument, MissionError, MissionTerrainPaths, NresError, TerrainFormatError,
|
||||||
|
TerrainPreparationError, TerrainWorld, TmaProfile,
|
||||||
};
|
};
|
||||||
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
|
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
|
||||||
use fparkan_prototype::{
|
use fparkan_prototype::{
|
||||||
build_prototype_graph_report, extend_graph_report_with_visual_dependencies, EffectivePrototype,
|
build_prototype_graph_report, PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport,
|
||||||
PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport,
|
|
||||||
};
|
};
|
||||||
use fparkan_resource::{resource_name, CachedResourceRepository};
|
use fparkan_resource::{resource_name, CachedResourceRepository};
|
||||||
use fparkan_terrain::TerrainWorld;
|
|
||||||
use fparkan_terrain_format::{
|
|
||||||
decode_build_dat, decode_land_map, decode_land_msh, BuildCategory, TerrainFormatError,
|
|
||||||
};
|
|
||||||
use fparkan_vfs::{Vfs, VfsError};
|
use fparkan_vfs::{Vfs, VfsError};
|
||||||
use fparkan_world::{
|
use fparkan_world::{
|
||||||
construct_object, new as new_world, register_object, step, InputSnapshot, ObjectDraft,
|
construct_object, new as new_world, register_object, step, InputSnapshot, ObjectDraft,
|
||||||
@@ -21,6 +39,8 @@ use fparkan_world::{
|
|||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub use fparkan_assets::MissionAssets;
|
||||||
|
|
||||||
/// Engine mode.
|
/// Engine mode.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum EngineMode {
|
pub enum EngineMode {
|
||||||
@@ -167,6 +187,8 @@ pub struct LoadedMission {
|
|||||||
pub graph_unit_component_count: usize,
|
pub graph_unit_component_count: usize,
|
||||||
/// Mission prototype graph root count.
|
/// Mission prototype graph root count.
|
||||||
pub graph_root_count: usize,
|
pub graph_root_count: usize,
|
||||||
|
/// Mission asset plan visual count after dependency preparation.
|
||||||
|
pub asset_visual_count: usize,
|
||||||
/// Expanded prototype requests resolved to effective prototypes.
|
/// Expanded prototype requests resolved to effective prototypes.
|
||||||
pub graph_resolved_count: usize,
|
pub graph_resolved_count: usize,
|
||||||
/// Reached mesh dependency count.
|
/// Reached mesh dependency count.
|
||||||
@@ -189,6 +211,14 @@ pub struct LoadedMission {
|
|||||||
pub graph_lightmap_request_count: usize,
|
pub graph_lightmap_request_count: usize,
|
||||||
/// Lightmap Texm entries decoded.
|
/// Lightmap Texm entries decoded.
|
||||||
pub graph_lightmap_resolved_count: usize,
|
pub graph_lightmap_resolved_count: usize,
|
||||||
|
/// Mission asset plan mesh-backed count after dependency preparation.
|
||||||
|
pub asset_model_count: usize,
|
||||||
|
/// Mission asset plan material count after dependency preparation.
|
||||||
|
pub asset_material_count: usize,
|
||||||
|
/// Mission asset plan texture count after dependency preparation.
|
||||||
|
pub asset_texture_count: usize,
|
||||||
|
/// Mission asset plan lightmap count after dependency preparation.
|
||||||
|
pub asset_lightmap_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Frame result.
|
/// Frame result.
|
||||||
@@ -222,7 +252,8 @@ struct LoadedMissionState {
|
|||||||
build_categories: Vec<BuildCategory>,
|
build_categories: Vec<BuildCategory>,
|
||||||
prototype_graph: PrototypeGraph,
|
prototype_graph: PrototypeGraph,
|
||||||
prototype_report: PrototypeGraphReport,
|
prototype_report: PrototypeGraphReport,
|
||||||
resolved_prototypes: Vec<EffectivePrototype>,
|
mission_assets: MissionAssets,
|
||||||
|
asset_plan: MissionAssetPlan,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Engine error.
|
/// Engine error.
|
||||||
@@ -251,7 +282,7 @@ pub enum EngineError {
|
|||||||
/// Resource path.
|
/// Resource path.
|
||||||
path: String,
|
path: String,
|
||||||
/// Source error.
|
/// Source error.
|
||||||
source: fparkan_nres::NresError,
|
source: NresError,
|
||||||
},
|
},
|
||||||
/// Mission decode error.
|
/// Mission decode error.
|
||||||
Mission {
|
Mission {
|
||||||
@@ -268,12 +299,19 @@ pub enum EngineError {
|
|||||||
source: TerrainFormatError,
|
source: TerrainFormatError,
|
||||||
},
|
},
|
||||||
/// Terrain runtime build error.
|
/// Terrain runtime build error.
|
||||||
Terrain(fparkan_terrain::TerrainError),
|
Terrain(fparkan_assets::TerrainError),
|
||||||
/// Prototype graph errors.
|
/// Prototype graph errors.
|
||||||
PrototypeGraph {
|
PrototypeGraph {
|
||||||
/// Root failures.
|
/// Root failures.
|
||||||
failures: Vec<PrototypeGraphFailure>,
|
failures: Vec<PrototypeGraphFailure>,
|
||||||
},
|
},
|
||||||
|
/// Asset preparation errors.
|
||||||
|
AssetPreparation {
|
||||||
|
/// Mission key.
|
||||||
|
mission: String,
|
||||||
|
/// Source error.
|
||||||
|
source: AssetPreparationError,
|
||||||
|
},
|
||||||
/// World error.
|
/// World error.
|
||||||
World(fparkan_world::WorldError),
|
World(fparkan_world::WorldError),
|
||||||
/// Scheduler phase order was violated.
|
/// Scheduler phase order was violated.
|
||||||
@@ -319,6 +357,9 @@ impl std::fmt::Display for EngineError {
|
|||||||
Self::PrototypeGraph { failures } => {
|
Self::PrototypeGraph { failures } => {
|
||||||
write!(f, "mission prototype graph has {} failures", failures.len())
|
write!(f, "mission prototype graph has {} failures", failures.len())
|
||||||
}
|
}
|
||||||
|
Self::AssetPreparation { mission, source } => {
|
||||||
|
write!(f, "{mission}: asset preparation failed: {source}")
|
||||||
|
}
|
||||||
Self::World(source) => write!(f, "{source}"),
|
Self::World(source) => write!(f, "{source}"),
|
||||||
Self::SchedulerPhaseOrder { previous, current } => write!(
|
Self::SchedulerPhaseOrder { previous, current } => write!(
|
||||||
f,
|
f,
|
||||||
@@ -346,6 +387,7 @@ impl std::error::Error for EngineError {
|
|||||||
Self::TerrainFormat { source, .. } => Some(source),
|
Self::TerrainFormat { source, .. } => Some(source),
|
||||||
Self::Terrain(source) => Some(source),
|
Self::Terrain(source) => Some(source),
|
||||||
Self::World(source) => Some(source),
|
Self::World(source) => Some(source),
|
||||||
|
Self::AssetPreparation { source, .. } => Some(source),
|
||||||
Self::MissingVfs
|
Self::MissingVfs
|
||||||
| Self::PrototypeGraph { .. }
|
| Self::PrototypeGraph { .. }
|
||||||
| Self::SchedulerPhaseOrder { .. }
|
| Self::SchedulerPhaseOrder { .. }
|
||||||
@@ -410,44 +452,52 @@ fn load_mission_with_options(
|
|||||||
let mission_bytes = read_vfs(&vfs, &mission_path)?;
|
let mission_bytes = read_vfs(&vfs, &mission_path)?;
|
||||||
|
|
||||||
trace.phases.push(MissionLoadPhase::Map);
|
trace.phases.push(MissionLoadPhase::Map);
|
||||||
let land_path = decode_tma_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| {
|
let land_path =
|
||||||
|
decode_mission_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| {
|
||||||
|
EngineError::Mission {
|
||||||
|
path: mission_path.as_str().to_string(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
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_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 build_dat_path = normalize_engine_path("BuildDat", "BuildDat.lst")?;
|
||||||
|
let build_dat = read_vfs(&vfs, &build_dat_path)?;
|
||||||
|
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_mission_payload(mission_bytes, TmaProfile::Strict).map_err(|source| {
|
||||||
EngineError::Mission {
|
EngineError::Mission {
|
||||||
path: mission_path.as_str().to_string(),
|
path: mission_path.as_str().to_string(),
|
||||||
source,
|
source,
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
let (land_msh_path, land_map_path) = terrain_paths_from_land_path(&land_path)?;
|
|
||||||
let 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 {
|
|
||||||
path: land_msh_path.as_str().to_string(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
let land_map =
|
|
||||||
decode_land_map(&land_map_nres).map_err(|source| EngineError::TerrainFormat {
|
|
||||||
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,
|
|
||||||
})?;
|
|
||||||
trace.phases.push(MissionLoadPhase::Tma);
|
|
||||||
let mission =
|
|
||||||
decode_tma(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
|
trace.transforms = mission
|
||||||
.objects
|
.objects
|
||||||
.iter()
|
.iter()
|
||||||
@@ -471,6 +521,7 @@ fn load_mission_with_options(
|
|||||||
extend_graph_report_with_visual_dependencies(
|
extend_graph_report_with_visual_dependencies(
|
||||||
&repository,
|
&repository,
|
||||||
&mut prototype_report,
|
&mut prototype_report,
|
||||||
|
&prototype_graph,
|
||||||
&resolved_prototypes,
|
&resolved_prototypes,
|
||||||
);
|
);
|
||||||
if !prototype_report.is_success() {
|
if !prototype_report.is_success() {
|
||||||
@@ -478,6 +529,16 @@ fn load_mission_with_options(
|
|||||||
failures: prototype_report.failures.clone(),
|
failures: prototype_report.failures.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let mission_assets = AssetManager::new(repository)
|
||||||
|
.prepare_mission_assets(
|
||||||
|
&prototype_graph.root_prototype_request_spans,
|
||||||
|
&resolved_prototypes,
|
||||||
|
)
|
||||||
|
.map_err(|source| EngineError::AssetPreparation {
|
||||||
|
mission: request.key.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
let mission_asset_plan = mission_assets.to_plan();
|
||||||
trace.phases.push(MissionLoadPhase::Assets);
|
trace.phases.push(MissionLoadPhase::Assets);
|
||||||
|
|
||||||
let mut new_runtime_world = new_world(WorldConfig);
|
let mut new_runtime_world = new_world(WorldConfig);
|
||||||
@@ -519,6 +580,7 @@ fn load_mission_with_options(
|
|||||||
graph_direct_reference_count: prototype_report.direct_reference_count,
|
graph_direct_reference_count: prototype_report.direct_reference_count,
|
||||||
graph_unit_component_count: prototype_report.unit_component_count,
|
graph_unit_component_count: prototype_report.unit_component_count,
|
||||||
graph_root_count: prototype_report.root_count,
|
graph_root_count: prototype_report.root_count,
|
||||||
|
asset_visual_count: mission_asset_plan.visual_count,
|
||||||
graph_resolved_count: prototype_report.resolved_count,
|
graph_resolved_count: prototype_report.resolved_count,
|
||||||
graph_mesh_dependency_count: prototype_report.mesh_dependency_count,
|
graph_mesh_dependency_count: prototype_report.mesh_dependency_count,
|
||||||
graph_failure_count: prototype_report.failures.len(),
|
graph_failure_count: prototype_report.failures.len(),
|
||||||
@@ -530,6 +592,10 @@ fn load_mission_with_options(
|
|||||||
graph_texture_resolved_count: prototype_report.texture_resolved_count,
|
graph_texture_resolved_count: prototype_report.texture_resolved_count,
|
||||||
graph_lightmap_request_count: prototype_report.lightmap_request_count,
|
graph_lightmap_request_count: prototype_report.lightmap_request_count,
|
||||||
graph_lightmap_resolved_count: prototype_report.lightmap_resolved_count,
|
graph_lightmap_resolved_count: prototype_report.lightmap_resolved_count,
|
||||||
|
asset_model_count: mission_asset_plan.model_count,
|
||||||
|
asset_material_count: mission_asset_plan.material_count,
|
||||||
|
asset_texture_count: mission_asset_plan.texture_count,
|
||||||
|
asset_lightmap_count: mission_asset_plan.lightmap_count,
|
||||||
};
|
};
|
||||||
|
|
||||||
engine.world = new_runtime_world;
|
engine.world = new_runtime_world;
|
||||||
@@ -540,7 +606,8 @@ fn load_mission_with_options(
|
|||||||
build_categories,
|
build_categories,
|
||||||
prototype_graph,
|
prototype_graph,
|
||||||
prototype_report,
|
prototype_report,
|
||||||
resolved_prototypes,
|
mission_assets,
|
||||||
|
asset_plan: mission_asset_plan,
|
||||||
});
|
});
|
||||||
Ok((summary, trace))
|
Ok((summary, trace))
|
||||||
}
|
}
|
||||||
@@ -618,13 +685,16 @@ pub fn loaded_prototype_graph_report(engine: &Engine) -> Option<&PrototypeGraphR
|
|||||||
engine.loaded.as_ref().map(|state| &state.prototype_report)
|
engine.loaded.as_ref().map(|state| &state.prototype_report)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns resolved effective prototypes for the loaded mission.
|
/// Returns the prepared mission asset plan for the loaded mission.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn loaded_resolved_prototypes(engine: &Engine) -> Option<&[EffectivePrototype]> {
|
pub fn loaded_mission_asset_plan(engine: &Engine) -> Option<&MissionAssetPlan> {
|
||||||
engine
|
engine.loaded.as_ref().map(|state| &state.asset_plan)
|
||||||
.loaded
|
}
|
||||||
.as_ref()
|
|
||||||
.map(|state| state.resolved_prototypes.as_slice())
|
/// Returns prepared mission assets for the loaded mission.
|
||||||
|
#[must_use]
|
||||||
|
pub fn loaded_mission_assets(engine: &Engine) -> Option<&MissionAssets> {
|
||||||
|
engine.loaded.as_ref().map(|state| &state.mission_assets)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -716,49 +786,6 @@ fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, Engi
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode_nres(
|
|
||||||
vfs: &Arc<dyn Vfs>,
|
|
||||||
path: &NormalizedPath,
|
|
||||||
) -> Result<fparkan_nres::NresDocument, EngineError> {
|
|
||||||
let bytes = read_vfs(vfs, path)?;
|
|
||||||
fparkan_nres::decode(bytes, fparkan_nres::ReadProfile::Compatible).map_err(|source| {
|
|
||||||
EngineError::Nres {
|
|
||||||
path: path.as_str().to_string(),
|
|
||||||
source,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn terrain_paths(
|
|
||||||
mission: &MissionDocument,
|
|
||||||
) -> Result<(NormalizedPath, NormalizedPath), EngineError> {
|
|
||||||
terrain_paths_from_land_path(&mission.land_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn terrain_paths_from_land_path(
|
|
||||||
land_path: &LpString,
|
|
||||||
) -> Result<(NormalizedPath, NormalizedPath), EngineError> {
|
|
||||||
let land_path_raw = String::from_utf8_lossy(&land_path.raw).to_string();
|
|
||||||
let normalized =
|
|
||||||
normalize_relative(&land_path.raw, PathPolicy::StrictLegacy).map_err(|source| {
|
|
||||||
EngineError::Path {
|
|
||||||
role: "mission land",
|
|
||||||
value: land_path_raw.clone(),
|
|
||||||
source,
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
let Some((parent, _stem)) = normalized.as_str().rsplit_once('/') else {
|
|
||||||
return Err(EngineError::Path {
|
|
||||||
role: "mission land",
|
|
||||||
value: normalized.as_str().to_string(),
|
|
||||||
source: PathError::Empty,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
let mesh = normalize_engine_path("Land.msh", &format!("{parent}/Land.msh"))?;
|
|
||||||
let map = normalize_engine_path("Land.map", &format!("{parent}/Land.map"))?;
|
|
||||||
Ok((mesh, map))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! Terrain disk format primitives.
|
//! Terrain disk format primitives.
|
||||||
|
|
||||||
use fparkan_binary::{checked_count_bytes, Cursor, DecodeError};
|
use fparkan_binary::{checked_count_bytes, Cursor, DecodeError};
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! Validated terrain runtime queries.
|
//! Validated terrain runtime queries.
|
||||||
|
|
||||||
use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument};
|
use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument};
|
||||||
@@ -524,26 +543,29 @@ struct RuntimeGrid {
|
|||||||
|
|
||||||
impl RuntimeGrid {
|
impl RuntimeGrid {
|
||||||
fn from_land_map(map: &LandMapDocument) -> Result<Self, TerrainError> {
|
fn from_land_map(map: &LandMapDocument) -> Result<Self, TerrainError> {
|
||||||
let mut min = [f32::INFINITY, f32::INFINITY];
|
let mut bounds_min = [f32::INFINITY, f32::INFINITY];
|
||||||
let mut max = [f32::NEG_INFINITY, f32::NEG_INFINITY];
|
let mut bounds_max = [f32::NEG_INFINITY, f32::NEG_INFINITY];
|
||||||
for areal in &map.areals {
|
for areal in &map.areals {
|
||||||
for vertex in &areal.vertices {
|
for vertex in &areal.vertices {
|
||||||
min[0] = min[0].min(vertex[0]);
|
bounds_min[0] = bounds_min[0].min(vertex[0]);
|
||||||
min[1] = min[1].min(vertex[2]);
|
bounds_min[1] = bounds_min[1].min(vertex[2]);
|
||||||
max[0] = max[0].max(vertex[0]);
|
bounds_max[0] = bounds_max[0].max(vertex[0]);
|
||||||
max[1] = max[1].max(vertex[2]);
|
bounds_max[1] = bounds_max[1].max(vertex[2]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !min[0].is_finite() || !min[1].is_finite() || !max[0].is_finite() || !max[1].is_finite()
|
if !bounds_min[0].is_finite()
|
||||||
|
|| !bounds_min[1].is_finite()
|
||||||
|
|| !bounds_max[0].is_finite()
|
||||||
|
|| !bounds_max[1].is_finite()
|
||||||
{
|
{
|
||||||
min = [0.0, 0.0];
|
bounds_min = [0.0, 0.0];
|
||||||
max = [1.0, 1.0];
|
bounds_max = [1.0, 1.0];
|
||||||
}
|
}
|
||||||
if (min[0] - max[0]).abs() <= f32::EPSILON {
|
if (bounds_min[0] - bounds_max[0]).abs() <= f32::EPSILON {
|
||||||
max[0] += 1.0;
|
bounds_max[0] += 1.0;
|
||||||
}
|
}
|
||||||
if (min[1] - max[1]).abs() <= f32::EPSILON {
|
if (bounds_min[1] - bounds_max[1]).abs() <= f32::EPSILON {
|
||||||
max[1] += 1.0;
|
bounds_max[1] += 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut cells = Vec::with_capacity(map.grid.cells.len());
|
let mut cells = Vec::with_capacity(map.grid.cells.len());
|
||||||
@@ -568,8 +590,8 @@ impl RuntimeGrid {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
cells_x: map.grid.cells_x,
|
cells_x: map.grid.cells_x,
|
||||||
cells_y: map.grid.cells_y,
|
cells_y: map.grid.cells_y,
|
||||||
min,
|
min: bounds_min,
|
||||||
max,
|
max: bounds_max,
|
||||||
cells,
|
cells,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! Dev-only synthetic builders and fake ports.
|
//! Dev-only synthetic builders and fake ports.
|
||||||
|
|
||||||
use fparkan_render::{FrameOutput, RenderBackend, RenderCommandList, RenderError};
|
use fparkan_render::{FrameOutput, RenderBackend, RenderCommandList, RenderError};
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! Stage-3 Texm texture contract.
|
//! Stage-3 Texm texture contract.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|||||||
+131
-23
@@ -1,10 +1,33 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! Virtual filesystem ports for resource loading.
|
//! Virtual filesystem ports for resource loading.
|
||||||
|
|
||||||
use fparkan_binary::{sha256, Sha256Digest};
|
use fparkan_binary::{sha256, Sha256Digest};
|
||||||
use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath};
|
use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::os::windows::fs::MetadataExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
@@ -110,6 +133,7 @@ impl DirectoryVfs {
|
|||||||
struct CachedHostFingerprint {
|
struct CachedHostFingerprint {
|
||||||
len: u64,
|
len: u64,
|
||||||
modified: Option<SystemTime>,
|
modified: Option<SystemTime>,
|
||||||
|
identity: Option<u64>,
|
||||||
fingerprint: Sha256Digest,
|
fingerprint: Sha256Digest,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,14 +144,23 @@ impl Vfs for DirectoryVfs {
|
|||||||
|
|
||||||
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
||||||
let host = self.host_path(path)?;
|
let host = self.host_path(path)?;
|
||||||
if fs::symlink_metadata(&host)
|
let pre_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
|
||||||
.map_err(VfsError::Io)?
|
if pre_metadata.file_type().is_symlink() || !pre_metadata.is_file() {
|
||||||
.file_type()
|
return Err(VfsError::Path);
|
||||||
.is_symlink()
|
}
|
||||||
|
let pre_identity = file_identity(&pre_metadata);
|
||||||
|
let pre_len = pre_metadata.len();
|
||||||
|
let pre_modified = pre_metadata.modified().ok();
|
||||||
|
let bytes = fs::read(&host).map_err(VfsError::Io)?;
|
||||||
|
let post_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
|
||||||
|
if post_metadata.file_type().is_symlink()
|
||||||
|
|| !post_metadata.is_file()
|
||||||
|
|| post_metadata.len() != pre_len
|
||||||
|
|| post_metadata.modified().ok() != pre_modified
|
||||||
|
|| file_identity(&post_metadata) != pre_identity
|
||||||
{
|
{
|
||||||
return Err(VfsError::Path);
|
return Err(VfsError::Path);
|
||||||
}
|
}
|
||||||
let bytes = fs::read(host).map_err(VfsError::Io)?;
|
|
||||||
Ok(Arc::from(bytes.into_boxed_slice()))
|
Ok(Arc::from(bytes.into_boxed_slice()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +281,11 @@ fn metadata_from_host_file_with_cache(
|
|||||||
.map_err(|_| VfsError::Path)?
|
.map_err(|_| VfsError::Path)?
|
||||||
.get(path)
|
.get(path)
|
||||||
.cloned()
|
.cloned()
|
||||||
.filter(|cached| cached.len == len && cached.modified == modified)
|
.filter(|cached| {
|
||||||
|
cached.len == len
|
||||||
|
&& cached.modified == modified
|
||||||
|
&& cached.identity == file_identity(metadata)
|
||||||
|
})
|
||||||
{
|
{
|
||||||
return Ok(VfsMetadata {
|
return Ok(VfsMetadata {
|
||||||
len,
|
len,
|
||||||
@@ -266,6 +303,7 @@ fn metadata_from_host_file_with_cache(
|
|||||||
CachedHostFingerprint {
|
CachedHostFingerprint {
|
||||||
len,
|
len,
|
||||||
modified,
|
modified,
|
||||||
|
identity: file_identity(metadata),
|
||||||
fingerprint,
|
fingerprint,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -275,15 +313,15 @@ fn metadata_from_host_file_with_cache(
|
|||||||
/// In-memory VFS.
|
/// In-memory VFS.
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct MemoryVfs {
|
pub struct MemoryVfs {
|
||||||
files: BTreeMap<String, Arc<[u8]>>,
|
files: BTreeMap<Vec<u8>, Arc<[u8]>>,
|
||||||
lookup: BTreeMap<Vec<u8>, Vec<String>>,
|
lookup: BTreeMap<Vec<u8>, Vec<Vec<u8>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryVfs {
|
impl MemoryVfs {
|
||||||
/// Inserts a file.
|
/// Inserts a file.
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) {
|
pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) {
|
||||||
let path = path.as_str().to_string();
|
let path = path.as_bytes().to_vec();
|
||||||
self.files.insert(path, bytes);
|
self.files.insert(path, bytes);
|
||||||
self.rebuild_lookup();
|
self.rebuild_lookup();
|
||||||
}
|
}
|
||||||
@@ -292,7 +330,7 @@ impl MemoryVfs {
|
|||||||
self.lookup.clear();
|
self.lookup.clear();
|
||||||
for path in self.files.keys() {
|
for path in self.files.keys() {
|
||||||
self.lookup
|
self.lookup
|
||||||
.entry(ascii_lookup_key(path.as_bytes()).0)
|
.entry(ascii_lookup_key(path).0)
|
||||||
.or_default()
|
.or_default()
|
||||||
.push(path.clone());
|
.push(path.clone());
|
||||||
}
|
}
|
||||||
@@ -301,20 +339,41 @@ impl MemoryVfs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_path(&self, path: &NormalizedPath) -> Result<&str, VfsError> {
|
fn resolve_path(&self, path: &NormalizedPath) -> Result<&[u8], VfsError> {
|
||||||
let key = ascii_lookup_key(path.as_str().as_bytes()).0;
|
let key = ascii_lookup_key(path.as_bytes()).0;
|
||||||
let matches = self
|
let matches = self
|
||||||
.lookup
|
.lookup
|
||||||
.get(&key)
|
.get(&key)
|
||||||
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
|
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
|
||||||
match matches.as_slice() {
|
match matches.as_slice() {
|
||||||
[single] => Ok(single.as_str()),
|
[single] => Ok(single.as_slice()),
|
||||||
[] => Err(VfsError::NotFound(path.as_str().to_string())),
|
[] => Err(VfsError::NotFound(path.as_str().to_string())),
|
||||||
_ => Err(VfsError::Ambiguous(path.as_str().to_string())),
|
_ => Err(VfsError::Ambiguous(path.as_str().to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[allow(clippy::unnecessary_wraps)]
|
||||||
|
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
|
||||||
|
Some(metadata.dev().rotate_left(32) ^ metadata.ino())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[allow(clippy::unnecessary_wraps)]
|
||||||
|
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
|
||||||
|
Some(
|
||||||
|
(metadata.volume_serial_number() as u64).rotate_left(40)
|
||||||
|
^ ((metadata.file_index_high() as u64) << 32)
|
||||||
|
^ metadata.file_index_low() as u64,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(unix, windows)))]
|
||||||
|
fn file_identity(_metadata: &fs::Metadata) -> Option<u64> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
impl Vfs for MemoryVfs {
|
impl Vfs for MemoryVfs {
|
||||||
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
||||||
let resolved = self.resolve_path(path)?;
|
let resolved = self.resolve_path(path)?;
|
||||||
@@ -339,16 +398,10 @@ impl Vfs for MemoryVfs {
|
|||||||
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for (path, bytes) in &self.files {
|
for (path, bytes) in &self.files {
|
||||||
if path
|
if has_segment_boundary_prefix_bytes(path, prefix.as_bytes()) {
|
||||||
.as_bytes()
|
let normalized =
|
||||||
.get(..prefix.as_str().len())
|
fparkan_path::normalize_relative(path, fparkan_path::PathPolicy::StrictLegacy)
|
||||||
.is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_str().as_bytes()))
|
.map_err(|_| VfsError::Path)?;
|
||||||
{
|
|
||||||
let normalized = fparkan_path::normalize_relative(
|
|
||||||
path.as_bytes(),
|
|
||||||
fparkan_path::PathPolicy::StrictLegacy,
|
|
||||||
)
|
|
||||||
.map_err(|_| VfsError::Path)?;
|
|
||||||
out.push(VfsEntry {
|
out.push(VfsEntry {
|
||||||
path: normalized,
|
path: normalized,
|
||||||
metadata: VfsMetadata {
|
metadata: VfsMetadata {
|
||||||
@@ -362,6 +415,25 @@ impl Vfs for MemoryVfs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn has_segment_boundary_prefix_bytes(haystack: &[u8], needle: &[u8]) -> bool {
|
||||||
|
if haystack.len() < needle.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if haystack.len() == needle.len() {
|
||||||
|
return haystack
|
||||||
|
.iter()
|
||||||
|
.zip(needle.iter())
|
||||||
|
.all(|(left, right)| left.eq_ignore_ascii_case(right));
|
||||||
|
}
|
||||||
|
if haystack[needle.len()] != b'/' {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
haystack[..needle.len()]
|
||||||
|
.iter()
|
||||||
|
.zip(needle.iter())
|
||||||
|
.all(|(left, right)| left.eq_ignore_ascii_case(right))
|
||||||
|
}
|
||||||
|
|
||||||
/// Layered VFS with deterministic first-layer precedence.
|
/// Layered VFS with deterministic first-layer precedence.
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct OverlayVfs {
|
pub struct OverlayVfs {
|
||||||
@@ -507,6 +579,22 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(root).expect("cleanup");
|
std::fs::remove_dir_all(root).expect("cleanup");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn memory_vfs_list_prefix_is_boundary_safe() {
|
||||||
|
let mut vfs = MemoryVfs::default();
|
||||||
|
let exact = normalize_relative(b"DATA/Land.map", PathPolicy::StrictLegacy).expect("path");
|
||||||
|
let sibling =
|
||||||
|
normalize_relative(b"DATA2/Land.map", PathPolicy::StrictLegacy).expect("path");
|
||||||
|
vfs.insert(exact.clone(), Arc::from(b"exact".as_slice()));
|
||||||
|
vfs.insert(sibling, Arc::from(b"sibling".as_slice()));
|
||||||
|
|
||||||
|
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
|
||||||
|
let entries = vfs.list(&prefix).expect("list");
|
||||||
|
|
||||||
|
assert_eq!(entries.len(), 1);
|
||||||
|
assert_eq!(entries[0].path.as_str(), exact.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn directory_vfs_fingerprint_changes_for_same_length_content() {
|
fn directory_vfs_fingerprint_changes_for_same_length_content() {
|
||||||
let root = unique_test_dir("content-fingerprint");
|
let root = unique_test_dir("content-fingerprint");
|
||||||
@@ -589,6 +677,26 @@ mod tests {
|
|||||||
assert!(matches!(vfs.read(&query), Err(VfsError::Ambiguous(_))));
|
assert!(matches!(vfs.read(&query), Err(VfsError::Ambiguous(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn memory_vfs_distinguishes_non_utf8_path_bytes() {
|
||||||
|
let mut vfs = MemoryVfs::default();
|
||||||
|
let ascii =
|
||||||
|
normalize_relative(b"DATA/normal.bin", PathPolicy::HostCompatible).expect("ascii path");
|
||||||
|
let binary =
|
||||||
|
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("binary path");
|
||||||
|
vfs.insert(ascii.clone(), Arc::from(b"ascii".as_slice()));
|
||||||
|
vfs.insert(binary.clone(), Arc::from(b"binary".as_slice()));
|
||||||
|
|
||||||
|
let binary_query =
|
||||||
|
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("binary query");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
vfs.read(&binary_query).expect("read binary").as_ref(),
|
||||||
|
b"binary"
|
||||||
|
);
|
||||||
|
assert_eq!(vfs.read(&ascii).expect("read ascii").as_ref(), b"ascii");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn overlay_vfs_uses_first_matching_layer() {
|
fn overlay_vfs_uses_first_matching_layer() {
|
||||||
let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path");
|
let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path");
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
//! Deterministic world identity, queue, lifecycle, and snapshots.
|
//! Deterministic world identity, queue, lifecycle, and snapshots.
|
||||||
|
|
||||||
use fparkan_binary::sha256;
|
use fparkan_binary::sha256;
|
||||||
|
|||||||
@@ -114,23 +114,16 @@ key, configuration, device profile, initial state, input/time script и верс
|
|||||||
## Local evidence requests
|
## Local evidence requests
|
||||||
|
|
||||||
На текущем рабочем месте закрыты статические, corpus и headless runtime gates.
|
На текущем рабочем месте закрыты статические, corpus и headless runtime gates.
|
||||||
Для macOS Desktop GL есть только безопасный command/state trace и исторический
|
Для локально воспроизводимого Desktop backend подтверждено только command/state trace
|
||||||
одноразовый offscreen pixel probe:
|
в существующем GL-воркфлоу:
|
||||||
|
|
||||||
- `cargo test -p fparkan-render-gl --offline desktop_gl33_triangle_command_capture`;
|
- `fixtures/acceptance/macos-gl33-triangle-capture.json`;
|
||||||
- `fixtures/acceptance/macos-gl33-triangle-capture.json`.
|
|
||||||
|
|
||||||
`S3-GL-001` не считается закрытым: временный `rustc` probe создал CGL/OpenGL
|
`S3-GL-001` пока не закрыт: текущая evidence не отражает полноценный
|
||||||
offscreen FBO, выполнил shader-based triangle draw, прочитал RGBA pixels и
|
`winit`+`fparkan-render-vulkan` path с real surface/present pipeline.
|
||||||
сохранил hash capture, но постоянный workspace adapter по-прежнему не создаёт
|
Для закрытия требования требуется постоянный workspace-владельческий backend на
|
||||||
SDL window, GL context, GPU resources, shader programs, draw calls или present.
|
`winit`/`fparkan-platform-winit` + `fparkan-render-vulkan` с реальным
|
||||||
Probe не добавляет project-owned `unsafe` в workspace и остаётся только external
|
surface/present pipeline, command/state parity и licensed frame capture.
|
||||||
evidence request artifact.
|
|
||||||
|
|
||||||
Для повышения `S3-GL-001` до `covered` нужен постоянный macOS backend через
|
|
||||||
выбранную safe facade stack: SDL event/window/context lifecycle, Desktop GL 3.3
|
|
||||||
shader/buffer/texture/draw/present path, hidden-window/offscreen smoke test и
|
|
||||||
licensed local model/terrain frame capture.
|
|
||||||
|
|
||||||
Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2
|
Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2
|
||||||
backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же
|
backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
Baseline command:
|
Baseline command:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
env RUSTC=/Users/valentineus/.rustup/toolchains/stable-aarch64-apple-darwin/bin/rustc /opt/homebrew/bin/rustup run stable cargo test --workspace --offline
|
cargo xtask ci
|
||||||
```
|
```
|
||||||
|
|
||||||
Result on 2026-06-22:
|
Result on 2026-06-23:
|
||||||
|
|
||||||
- library and binary unit tests compile and pass after aligning SDL2 versions and pinning `toml` to cached `0.8`;
|
- canonical pipeline now uses a fixed MSRV/toolchain, policy checks,
|
||||||
- doctests fail in this shell because `rustdoc` is not in PATH unless `RUSTDOC` is also set to the real toolchain binary;
|
full-format workspace test command, `clippy`/`doc`/`cargo deny` gates and
|
||||||
- full online dependency resolution is unavailable in the sandbox.
|
typed manifest parsing in `xtask`;
|
||||||
|
- `rpath`/offline mode is still useful for synthetic local checks;
|
||||||
|
- full online dependency resolution remains unavailable in the sandbox.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ behavior unit state machines, target and path requests
|
|||||||
physics control systems, collision proxies and contacts
|
physics control systems, collision proxies and contacts
|
||||||
animation pose sampling, hierarchy and blending
|
animation pose sampling, hierarchy and blending
|
||||||
audio sample cache, sources, listener and buses
|
audio sample cache, sources, listener and buses
|
||||||
render legacy-state compatibility and modern backend
|
render immutable frame contracts and modern backend
|
||||||
network game message schema plus transport adapters
|
network game message schema plus transport adapters
|
||||||
tools validators, extractors, viewers, captures and editors
|
tools validators, extractors, viewers, captures and editors
|
||||||
```
|
```
|
||||||
@@ -103,8 +103,8 @@ CPU assets и GPU resources имеют отдельные бюджеты и от
|
|||||||
|
|
||||||
### Backend adapters
|
### Backend adapters
|
||||||
|
|
||||||
Render, audio, input и network получают отдельные adapters. Legacy compatibility
|
Render, audio, input и network получают отдельные adapters. Compatibility state
|
||||||
state живёт выше Vulkan, D3D11 или Metal backend; DirectPlay compatibility живёт
|
живёт вне Vulkan, D3D11 или Metal backend; DirectPlay compatibility живёт
|
||||||
отдельно от modern transport. Так можно заменить платформу, не меняя форматы,
|
отдельно от modern transport. Так можно заменить платформу, не меняя форматы,
|
||||||
игровую семантику и regression corpus.
|
игровую семантику и regression corpus.
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ L0-P2-001 covered cargo test -p fparkan-corpus --offline licensed_part2_manifest
|
|||||||
L0-P2-002 covered cargo test -p fparkan-corpus --offline licensed_part2_has_no_casefold_relative_path_collisions
|
L0-P2-002 covered cargo test -p fparkan-corpus --offline licensed_part2_has_no_casefold_relative_path_collisions
|
||||||
S0-ARCH-001 covered cargo xtask policy runs cargo metadata --offline --no-deps successfully
|
S0-ARCH-001 covered cargo xtask policy runs cargo metadata --offline --no-deps successfully
|
||||||
S0-ARCH-002 covered cargo xtask policy rejects forbidden GUI/adapter dependencies from domain crates
|
S0-ARCH-002 covered cargo xtask policy rejects forbidden GUI/adapter dependencies from domain crates
|
||||||
S0-ARCH-003 covered cargo xtask policy rejects platform/render adapter dependencies from fparkan-headless
|
S0-ARCH-003 covered cargo xtask policy rejects platform/render adapter dependencies from the transitive fparkan-headless workspace manifest closure
|
||||||
S0-ARCH-004 covered cargo xtask policy scans workspace-owned Rust/TOML for unsafe constructs and workspace lints forbid unsafe_code
|
S0-ARCH-004 covered cargo xtask policy scans workspace-owned Rust/TOML for unsafe constructs and workspace lints forbid unsafe_code
|
||||||
S0-ARCH-005 covered cargo xtask policy rejects Python source files, Python shebangs, and Python CI workflow steps while allowing docs requirements.txt
|
S0-ARCH-005 covered cargo xtask policy rejects Python source files, Python shebangs, and Python CI workflow steps while allowing docs requirements.txt
|
||||||
S0-ARCH-006 covered cargo xtask policy rejects non-fparkan package directories under crates/
|
S0-ARCH-006 covered cargo xtask policy rejects non-fparkan package directories under crates/
|
||||||
|
S0-ARCH-007 covered cargo xtask ci runs fmt, policy, workspace test, clippy, rustdoc warnings, cargo-deny or built-in supply-chain fallback, and strict acceptance audit
|
||||||
|
S0-ARCH-008 covered cargo xtask policy rejects moving Rust toolchains and workspace rust-version drift
|
||||||
|
S0-ARCH-009 covered .github/workflows/ci.yml runs a pinned MSRV backend-neutral crate job
|
||||||
|
S0-ARCH-010 covered cargo xtask acceptance audit emits commit_sha, rust_toolchain, and msrv metadata into the JSON artifact
|
||||||
|
S0-ARCH-011 blocked cargo run -p fparkan-vulkan-smoke emits explicit per-platform blocked artifacts until real Vulkan 300-frame validation=0 runner is available
|
||||||
S0-DIAG-001 covered cargo test -p fparkan-diagnostics --offline diagnostic_chain_preserves_context
|
S0-DIAG-001 covered cargo test -p fparkan-diagnostics --offline diagnostic_chain_preserves_context
|
||||||
S0-DIAG-002 covered cargo test -p fparkan-diagnostics --offline json_is_stable
|
S0-DIAG-002 covered cargo test -p fparkan-diagnostics --offline json_is_stable
|
||||||
S0-CORPUS-001 covered cargo test -p fparkan-corpus --offline deterministic_traversal_is_creation_order_independent
|
S0-CORPUS-001 covered cargo test -p fparkan-corpus --offline deterministic_traversal_is_creation_order_independent
|
||||||
@@ -21,7 +26,44 @@ S0-CORPUS-005 covered cargo test -p fparkan-corpus --offline fingerprint_changes
|
|||||||
S0-CORPUS-006 covered cargo test -p fparkan-corpus --offline atomic_report_write
|
S0-CORPUS-006 covered cargo test -p fparkan-corpus --offline atomic_report_write
|
||||||
S0-CLI-001 covered cargo test -p fparkan-cli --offline stable_exit_codes_are_mapped
|
S0-CLI-001 covered cargo test -p fparkan-cli --offline stable_exit_codes_are_mapped
|
||||||
S0-CLI-002 covered cargo test -p fparkan-cli --offline accepts_json_format_option archive_json_has_schema_version
|
S0-CLI-002 covered cargo test -p fparkan-cli --offline accepts_json_format_option archive_json_has_schema_version
|
||||||
S0-GL-001 covered cargo test -p fparkan-platform-sdl -p fparkan-render-gl --offline adapter_boundary_is_project_owned_unsafe_free
|
S0-PLAT-001 covered cargo test -p fparkan-platform-winit --offline window_port_reports_default_request_profile
|
||||||
|
S0-PLAT-002 covered cargo clippy -p fparkan-platform -p fparkan-platform-winit --all-targets --all-features --locked -- -D warnings
|
||||||
|
S0-PLAT-003 covered cargo test -p fparkan-platform-winit --offline smoke_window_plan_requires_native_handles_and_nonzero_extent smoke_window_plan_rejects_zero_extent
|
||||||
|
S0-PLAT-004 covered cargo test -p fparkan-platform-winit --offline smoke_window_app_requires_created_native_window smoke_window_app_rejects_synthetic_window_without_native_handles
|
||||||
|
S0-VK-001 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents
|
||||||
|
S0-VK-002 covered cargo test -p fparkan-render-vulkan --offline device_scoring_is_deterministic_and_prefers_discrete_unified_queue
|
||||||
|
S0-VK-003 covered cargo test -p fparkan-render-vulkan --offline portability_subset_is_reported_and_enabled_when_exposed
|
||||||
|
S0-VK-004 covered cargo test -p fparkan-render-vulkan --offline rejects_missing_graphics_present_swapchain_and_format
|
||||||
|
S0-VK-005 covered cargo test -p fparkan-render-vulkan --offline capability_report_json_is_stable
|
||||||
|
S0-VK-006 covered cargo test -p fparkan-render-vulkan --offline loader_probe_report_json_is_stable
|
||||||
|
S0-VK-007 covered cargo xtask policy
|
||||||
|
S0-VK-008 covered cargo test -p fparkan-render-vulkan --offline instance_plan_is_sorted_deduplicated_and_portability_aware
|
||||||
|
S0-VK-009 covered cargo test -p fparkan-render-vulkan --offline instance_plan_adds_portability_extension_when_requested
|
||||||
|
S0-VK-010 covered cargo test -p fparkan-render-vulkan --offline invalid_instance_extension_name_is_reported_before_loader_use
|
||||||
|
S0-VK-011 covered cargo test -p fparkan-render-vulkan --offline surface_plan_requires_native_handles
|
||||||
|
S0-VK-012 covered cargo test -p fparkan-render-vulkan --offline surface_plan_json_is_stable
|
||||||
|
S0-VK-013 covered cargo test -p fparkan-render-vulkan --offline static_surface_extension_name_is_decoded
|
||||||
|
S0-VK-014 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_prefers_srgb_mailbox_and_clamps_extent
|
||||||
|
S0-VK-015 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_uses_fifo_and_current_extent_fallbacks
|
||||||
|
S0-VK-016 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_rejects_missing_surface_data_and_empty_extent
|
||||||
|
S0-VK-017 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_json_and_recreation_reports_are_stable
|
||||||
|
S0-VK-018 covered cargo test -p fparkan-render-vulkan --offline triangle_shader_manifest_hashes_are_stable
|
||||||
|
S0-VK-019 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_report_json_is_stable
|
||||||
|
S0-VK-020 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_rejects_invalid_spirv_containers
|
||||||
|
S0-VK-021 covered cargo test -p fparkan-render-vulkan --offline frame_submission_plan_json_is_stable
|
||||||
|
S0-VK-022 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents
|
||||||
|
S0-VK-023 covered cargo test -p fparkan-vulkan-smoke --offline rejects_false_pass_without_full_evidence blocked_report_includes_shader_manifest_and_bootstrap_status
|
||||||
|
S0-VK-024 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_loader_probe formats_vulkan_api_version
|
||||||
|
S0-VK-025 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_instance_probe parses_instance_probe_as_loader_probe
|
||||||
|
S0-VK-026 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_window_probe rejects_passed_without_surface_probe parses_surface_probe_as_instance_probe
|
||||||
|
S0-VK-027 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_swapchain_recreation blocked_report_includes_shader_manifest_and_bootstrap_status
|
||||||
|
S0-VK-028 covered cargo test -p fparkan-vulkan-smoke --offline reports_rustc_host_triple blocked_report_includes_shader_manifest_and_bootstrap_status
|
||||||
|
S0-VK-029 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_three_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
|
||||||
|
S0-VK-030 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_with_failed_surface
|
||||||
|
S0-VK-031 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_selected_device
|
||||||
|
S0-VK-032 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_swapchain
|
||||||
|
S0-VK-033 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_logical_device
|
||||||
|
S0-VK-034 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_three_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
|
||||||
S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow
|
S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow
|
||||||
S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read
|
S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read
|
||||||
L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
|
L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
|
||||||
@@ -71,6 +113,8 @@ S1-PATH-005 covered cargo test -p fparkan-path --offline rejects_escape
|
|||||||
S1-PATH-006 covered cargo test -p fparkan-path --offline rejects_absolute_drive_and_nul_paths
|
S1-PATH-006 covered cargo test -p fparkan-path --offline rejects_absolute_drive_and_nul_paths
|
||||||
S1-PATH-007 covered cargo test -p fparkan-path --offline join_under_keeps_normalized_path_below_root
|
S1-PATH-007 covered cargo test -p fparkan-path --offline join_under_keeps_normalized_path_below_root
|
||||||
S1-PATH-008 covered cargo test -p fparkan-path --offline original_separators_and_raw_bytes_are_preserved
|
S1-PATH-008 covered cargo test -p fparkan-path --offline original_separators_and_raw_bytes_are_preserved
|
||||||
|
S1-PATH-009 covered cargo test -p fparkan-path --offline accepts_non_utf8_legacy_bytes
|
||||||
|
S1-VFS-005 covered cargo test -p fparkan-vfs --offline memory_vfs_list_prefix_is_boundary_safe
|
||||||
S1-RSLI-001 covered cargo test -p fparkan-rsli --offline parses_minimal_empty_library
|
S1-RSLI-001 covered cargo test -p fparkan-rsli --offline parses_minimal_empty_library
|
||||||
S1-RSLI-002 covered cargo test -p fparkan-rsli --offline rejects_invalid_header_fields
|
S1-RSLI-002 covered cargo test -p fparkan-rsli --offline rejects_invalid_header_fields
|
||||||
S1-RSLI-003 covered cargo test -p fparkan-rsli --offline rejects_entry_table_bounds
|
S1-RSLI-003 covered cargo test -p fparkan-rsli --offline rejects_entry_table_bounds
|
||||||
@@ -222,7 +266,7 @@ S3-RENDER-008 covered cargo test -p fparkan-render --offline recording_backend_s
|
|||||||
S3-RENDER-009 covered cargo xtask policy
|
S3-RENDER-009 covered cargo xtask policy
|
||||||
S3-GL-001 omitted permanent macOS Desktop GL 3.3 adapter is not implemented; historical CGL probe is retained as external evidence only
|
S3-GL-001 omitted permanent macOS Desktop GL 3.3 adapter is not implemented; historical CGL probe is retained as external evidence only
|
||||||
S3-GL-002 omitted outside the current macOS-focused goal scope; GLES2 remains documented for portable/non-macOS targets
|
S3-GL-002 omitted outside the current macOS-focused goal scope; GLES2 remains documented for portable/non-macOS targets
|
||||||
S3-GL-003 covered cargo test -p fparkan-render-gl --offline shader_compile_failure_diagnostic_contains_profile_and_log
|
S3-GL-003 blocked legacy fparkan-render-gl adapter removed while Vulkan renderer path is being brought in as the stage-3 backend
|
||||||
S3-VIEWER-001 covered cargo test -p fparkan-viewer --offline model_fixture_uses_viewer_service_and_render_commands
|
S3-VIEWER-001 covered cargo test -p fparkan-viewer --offline model_fixture_uses_viewer_service_and_render_commands
|
||||||
S4-ANIM-001 covered cargo test -p fparkan-animation --offline anim_key24_decodes_signed_quaternion
|
S4-ANIM-001 covered cargo test -p fparkan-animation --offline anim_key24_decodes_signed_quaternion
|
||||||
S4-ANIM-002 covered cargo test -p fparkan-animation --offline frame_map_decodes_u16_and_uses_attr_frame_count
|
S4-ANIM-002 covered cargo test -p fparkan-animation --offline frame_map_decodes_u16_and_uses_attr_frame_count
|
||||||
|
|||||||
|
@@ -0,0 +1,409 @@
|
|||||||
|
# Stage 0-2 acceptance IDs
|
||||||
|
|
||||||
|
`L0-COPYRIGHT-001`
|
||||||
|
`L0-P1-001`
|
||||||
|
`L0-P1-002`
|
||||||
|
`L0-P2-001`
|
||||||
|
`L0-P2-002`
|
||||||
|
`S0-ARCH-001`
|
||||||
|
`S0-ARCH-002`
|
||||||
|
`S0-ARCH-003`
|
||||||
|
`S0-ARCH-004`
|
||||||
|
`S0-ARCH-005`
|
||||||
|
`S0-ARCH-006`
|
||||||
|
`S0-ARCH-007`
|
||||||
|
`S0-ARCH-008`
|
||||||
|
`S0-ARCH-009`
|
||||||
|
`S0-ARCH-010`
|
||||||
|
`S0-ARCH-011`
|
||||||
|
`S0-DIAG-001`
|
||||||
|
`S0-DIAG-002`
|
||||||
|
`S0-CORPUS-001`
|
||||||
|
`S0-CORPUS-002`
|
||||||
|
`S0-CORPUS-003`
|
||||||
|
`S0-CORPUS-004`
|
||||||
|
`S0-CORPUS-005`
|
||||||
|
`S0-CORPUS-006`
|
||||||
|
`S0-CLI-001`
|
||||||
|
`S0-CLI-002`
|
||||||
|
`S0-PLAT-001`
|
||||||
|
`S0-PLAT-002`
|
||||||
|
`S0-PLAT-003`
|
||||||
|
`S0-PLAT-004`
|
||||||
|
`S0-VK-001`
|
||||||
|
`S0-VK-002`
|
||||||
|
`S0-VK-003`
|
||||||
|
`S0-VK-004`
|
||||||
|
`S0-VK-005`
|
||||||
|
`S0-VK-006`
|
||||||
|
`S0-VK-007`
|
||||||
|
`S0-VK-008`
|
||||||
|
`S0-VK-009`
|
||||||
|
`S0-VK-010`
|
||||||
|
`S0-VK-011`
|
||||||
|
`S0-VK-012`
|
||||||
|
`S0-VK-013`
|
||||||
|
`S0-VK-014`
|
||||||
|
`S0-VK-015`
|
||||||
|
`S0-VK-016`
|
||||||
|
`S0-VK-017`
|
||||||
|
`S0-VK-018`
|
||||||
|
`S0-VK-019`
|
||||||
|
`S0-VK-020`
|
||||||
|
`S0-VK-021`
|
||||||
|
`S0-VK-022`
|
||||||
|
`S0-VK-023`
|
||||||
|
`S0-VK-024`
|
||||||
|
`S0-VK-025`
|
||||||
|
`S0-VK-026`
|
||||||
|
`S0-VK-027`
|
||||||
|
`S0-VK-028`
|
||||||
|
`S0-VK-029`
|
||||||
|
`S0-VK-030`
|
||||||
|
`S0-VK-031`
|
||||||
|
`S0-VK-032`
|
||||||
|
`S0-VK-033`
|
||||||
|
`S0-VK-034`
|
||||||
|
`S0-LIMIT-001`
|
||||||
|
`S0-LIMIT-002`
|
||||||
|
`L1-P1-NRES-001`
|
||||||
|
`L1-P2-NRES-001`
|
||||||
|
`L1-P1-NRES-002`
|
||||||
|
`L1-P2-NRES-002`
|
||||||
|
`L1-P1-NRES-003`
|
||||||
|
`L1-P2-NRES-003`
|
||||||
|
`L1-P1-RSLI-001`
|
||||||
|
`L1-P2-RSLI-001`
|
||||||
|
`L1-RSLI-QUIRK-001`
|
||||||
|
`L1-P1-PATH-001`
|
||||||
|
`L1-P2-PATH-001`
|
||||||
|
`S1-NRES-001`
|
||||||
|
`S1-NRES-002`
|
||||||
|
`S1-NRES-003`
|
||||||
|
`S1-NRES-004`
|
||||||
|
`S1-NRES-005`
|
||||||
|
`S1-NRES-006`
|
||||||
|
`S1-NRES-007`
|
||||||
|
`S1-NRES-008`
|
||||||
|
`S1-NRES-009`
|
||||||
|
`S1-NRES-010`
|
||||||
|
`S1-NRES-020`
|
||||||
|
`S1-NRES-021`
|
||||||
|
`S1-NRES-011`
|
||||||
|
`S1-NRES-012`
|
||||||
|
`S1-NRES-013`
|
||||||
|
`S1-NRES-014`
|
||||||
|
`S1-NRES-015`
|
||||||
|
`S1-NRES-016`
|
||||||
|
`S1-NRES-017`
|
||||||
|
`S1-NRES-018`
|
||||||
|
`S1-NRES-019`
|
||||||
|
`S1-NRES-022`
|
||||||
|
`S1-NRES-023`
|
||||||
|
`S1-NRES-024`
|
||||||
|
`S1-NRES-025`
|
||||||
|
`S1-NRES-PROP-001`
|
||||||
|
`S1-NRES-PROP-002`
|
||||||
|
`S1-NRES-FUZZ-001`
|
||||||
|
`S1-PATH-001`
|
||||||
|
`S1-PATH-002`
|
||||||
|
`S1-PATH-003`
|
||||||
|
`S1-PATH-004`
|
||||||
|
`S1-PATH-005`
|
||||||
|
`S1-PATH-006`
|
||||||
|
`S1-PATH-007`
|
||||||
|
`S1-PATH-008`
|
||||||
|
`S1-PATH-009`
|
||||||
|
`S1-VFS-005`
|
||||||
|
`S1-RSLI-001`
|
||||||
|
`S1-RSLI-002`
|
||||||
|
`S1-RSLI-003`
|
||||||
|
`S1-RSLI-004`
|
||||||
|
`S1-RSLI-005`
|
||||||
|
`S1-RSLI-006`
|
||||||
|
`S1-RSLI-007`
|
||||||
|
`S1-RSLI-008`
|
||||||
|
`S1-RSLI-009`
|
||||||
|
`S1-RSLI-010`
|
||||||
|
`S1-RSLI-011`
|
||||||
|
`S1-RSLI-012`
|
||||||
|
`S1-RSLI-013`
|
||||||
|
`S1-RSLI-014`
|
||||||
|
`S1-RSLI-015`
|
||||||
|
`S1-RSLI-016`
|
||||||
|
`S1-RSLI-017`
|
||||||
|
`S1-RSLI-018`
|
||||||
|
`S1-RSLI-019`
|
||||||
|
`S1-RSLI-020`
|
||||||
|
`S1-RSLI-021`
|
||||||
|
`S1-RSLI-022`
|
||||||
|
`S1-RSLI-023`
|
||||||
|
`S1-RSLI-PROP-001`
|
||||||
|
`S1-RSLI-FUZZ-001`
|
||||||
|
`S1-RES-001`
|
||||||
|
`S1-RES-002`
|
||||||
|
`S1-RES-003`
|
||||||
|
`S1-RES-004`
|
||||||
|
`S1-VFS-001`
|
||||||
|
`S1-VFS-002`
|
||||||
|
`S1-VFS-003`
|
||||||
|
`S1-VFS-004`
|
||||||
|
`L2-P1-UNIT-001`
|
||||||
|
`L2-P2-UNIT-001`
|
||||||
|
`L2-P1-REG-001`
|
||||||
|
`L2-P2-REG-001`
|
||||||
|
`L2-P1-GRAPH-001`
|
||||||
|
`L2-P2-GRAPH-001`
|
||||||
|
`L2-P1-INHERIT-001`
|
||||||
|
`L2-P2-INHERIT-001`
|
||||||
|
`L2-P1-NONGEO-001`
|
||||||
|
`L2-P2-NONGEO-001`
|
||||||
|
`L2-P1-GRAPH-002`
|
||||||
|
`L2-P2-GRAPH-002`
|
||||||
|
`S2-REG-001`
|
||||||
|
`S2-REG-002`
|
||||||
|
`S2-REG-003`
|
||||||
|
`S2-REG-004`
|
||||||
|
`S2-UNIT-001`
|
||||||
|
`S2-UNIT-002`
|
||||||
|
`S2-UNIT-003`
|
||||||
|
`S2-UNIT-004`
|
||||||
|
`S2-UNIT-005`
|
||||||
|
`S2-UNIT-006`
|
||||||
|
`S2-UNIT-007`
|
||||||
|
`S2-PROTO-001`
|
||||||
|
`S2-PROTO-002`
|
||||||
|
`S2-PROTO-003`
|
||||||
|
`S2-PROTO-004`
|
||||||
|
`S2-PROTO-005`
|
||||||
|
`S2-PROTO-006`
|
||||||
|
`S2-PROTO-007`
|
||||||
|
`S2-PROTO-008`
|
||||||
|
`S2-PROTO-009`
|
||||||
|
`S2-PROTO-010`
|
||||||
|
`S2-PROTO-011`
|
||||||
|
`S2-PROTO-012`
|
||||||
|
`S2-PROTO-013`
|
||||||
|
`S2-PROTO-014`
|
||||||
|
`S2-GRAPH-001`
|
||||||
|
`S2-GRAPH-002`
|
||||||
|
`S2-GRAPH-003`
|
||||||
|
`S2-GRAPH-004`
|
||||||
|
`S2-GRAPH-005`
|
||||||
|
`S2-GRAPH-006`
|
||||||
|
`S2-PROP-001`
|
||||||
|
`S2-FUZZ-001`
|
||||||
|
`L3-P1-MSH-001`
|
||||||
|
`L3-P2-MSH-001`
|
||||||
|
`L3-P1-TEXM-001`
|
||||||
|
`L3-P2-TEXM-001`
|
||||||
|
`L3-P1-MAT0-001`
|
||||||
|
`L3-P2-MAT0-001`
|
||||||
|
`L3-P1-WEAR-001`
|
||||||
|
`L3-P2-WEAR-001`
|
||||||
|
`L3-P1-ASSET-001`
|
||||||
|
`L3-P2-ASSET-001`
|
||||||
|
`L3-P1-CAPTURE-001`
|
||||||
|
`L3-P2-CAPTURE-001`
|
||||||
|
`S3-WEAR-001`
|
||||||
|
`S3-WEAR-002`
|
||||||
|
`S3-WEAR-003`
|
||||||
|
`S3-WEAR-004`
|
||||||
|
`S3-WEAR-005`
|
||||||
|
`S3-MAT0-001`
|
||||||
|
`S3-MAT0-002`
|
||||||
|
`S3-MAT0-003`
|
||||||
|
`S3-MAT0-004`
|
||||||
|
`S3-MAT0-005`
|
||||||
|
`S3-MAT0-006`
|
||||||
|
`S3-MSH-001`
|
||||||
|
`S3-MSH-002`
|
||||||
|
`S3-MSH-003`
|
||||||
|
`S3-MSH-004`
|
||||||
|
`S3-MSH-005`
|
||||||
|
`S3-MSH-006`
|
||||||
|
`S3-MSH-007`
|
||||||
|
`S3-MSH-008`
|
||||||
|
`S3-MSH-009`
|
||||||
|
`S3-MSH-010`
|
||||||
|
`S3-MSH-011`
|
||||||
|
`S3-MSH-012`
|
||||||
|
`S3-MSH-013`
|
||||||
|
`S3-MSH-014`
|
||||||
|
`S3-MSH-015`
|
||||||
|
`S3-MSH-016`
|
||||||
|
`S3-MSH-017`
|
||||||
|
`S3-MSH-PROP-001`
|
||||||
|
`S3-MSH-FUZZ-001`
|
||||||
|
`S3-TEXM-001`
|
||||||
|
`S3-TEXM-002`
|
||||||
|
`S3-TEXM-003`
|
||||||
|
`S3-TEXM-004`
|
||||||
|
`S3-TEXM-005`
|
||||||
|
`S3-TEXM-006`
|
||||||
|
`S3-TEXM-007`
|
||||||
|
`S3-TEXM-008`
|
||||||
|
`S3-TEXM-009`
|
||||||
|
`S3-TEXM-010`
|
||||||
|
`S3-TEXM-011`
|
||||||
|
`S3-TEXM-012`
|
||||||
|
`S3-TEXM-013`
|
||||||
|
`S3-TEXM-FUZZ-001`
|
||||||
|
`S3-MAT0-007`
|
||||||
|
`S3-MAT-RESOLVE-001`
|
||||||
|
`S3-MAT-RESOLVE-002`
|
||||||
|
`S3-MAT-RESOLVE-003`
|
||||||
|
`S3-MAT-RESOLVE-004`
|
||||||
|
`S3-MAT-RESOLVE-005`
|
||||||
|
`S3-RENDER-001`
|
||||||
|
`S3-RENDER-002`
|
||||||
|
`S3-RENDER-003`
|
||||||
|
`S3-RENDER-004`
|
||||||
|
`S3-RENDER-005`
|
||||||
|
`S3-RENDER-006`
|
||||||
|
`S3-RENDER-007`
|
||||||
|
`S3-RENDER-008`
|
||||||
|
`S3-RENDER-009`
|
||||||
|
`S3-GL-001`
|
||||||
|
`S3-GL-002`
|
||||||
|
`S3-GL-003`
|
||||||
|
`S3-VIEWER-001`
|
||||||
|
`S4-ANIM-001`
|
||||||
|
`S4-ANIM-002`
|
||||||
|
`S4-ANIM-003`
|
||||||
|
`S4-ANIM-004`
|
||||||
|
`S4-ANIM-005`
|
||||||
|
`S4-ANIM-006`
|
||||||
|
`S4-ANIM-007`
|
||||||
|
`S4-ANIM-008`
|
||||||
|
`S4-ANIM-009`
|
||||||
|
`S4-ANIM-010`
|
||||||
|
`S4-ANIM-011`
|
||||||
|
`S4-ANIM-012`
|
||||||
|
`S4-ANIM-013`
|
||||||
|
`S4-ANIM-014`
|
||||||
|
`S4-ANIM-PROP-001`
|
||||||
|
`S4-MAT-001`
|
||||||
|
`S4-MAT-002`
|
||||||
|
`S4-MAT-003`
|
||||||
|
`S4-MAT-004`
|
||||||
|
`S4-MAT-005`
|
||||||
|
`S4-MAT-006`
|
||||||
|
`S4-FX-001`
|
||||||
|
`S4-FX-002`
|
||||||
|
`S4-FX-011`
|
||||||
|
`S4-FX-012`
|
||||||
|
`S4-FX-013`
|
||||||
|
`S4-FX-014`
|
||||||
|
`S4-FX-015`
|
||||||
|
`S4-FX-016`
|
||||||
|
`S4-FX-017`
|
||||||
|
`S4-FX-018`
|
||||||
|
`S4-FX-019`
|
||||||
|
`S4-FX-020`
|
||||||
|
`S4-FX-021`
|
||||||
|
`S4-FX-022`
|
||||||
|
`S4-FX-023`
|
||||||
|
`S4-FX-024`
|
||||||
|
`S4-FX-FUZZ-001`
|
||||||
|
`L4-P1-ANIM-001`
|
||||||
|
`L4-P2-ANIM-001`
|
||||||
|
`L4-P1-CAPTURE-001`
|
||||||
|
`L4-P2-CAPTURE-001`
|
||||||
|
`L4-P1-FX-001`
|
||||||
|
`L4-P2-FX-001`
|
||||||
|
`L4-P1-FX-002`
|
||||||
|
`L4-P2-FX-002`
|
||||||
|
`L4-FX-OP6-001`
|
||||||
|
`L4-P1-EFFECT-001`
|
||||||
|
`L4-P2-EFFECT-001`
|
||||||
|
`S5-LMESH-001`
|
||||||
|
`S5-LMESH-002`
|
||||||
|
`S5-LMESH-003`
|
||||||
|
`S5-LMESH-004`
|
||||||
|
`S5-LMESH-005`
|
||||||
|
`S5-LMESH-006`
|
||||||
|
`S5-LMESH-007`
|
||||||
|
`S5-LMESH-008`
|
||||||
|
`S5-LMESH-009`
|
||||||
|
`S5-LMAP-001`
|
||||||
|
`S5-LMAP-002`
|
||||||
|
`S5-LMAP-003`
|
||||||
|
`S5-LMAP-004`
|
||||||
|
`S5-LMAP-005`
|
||||||
|
`S5-LMAP-006`
|
||||||
|
`S5-LMAP-007`
|
||||||
|
`S5-LMAP-008`
|
||||||
|
`S5-LMAP-009`
|
||||||
|
`S5-LMAP-010`
|
||||||
|
`S5-LMAP-011`
|
||||||
|
`S5-TERRAIN-001`
|
||||||
|
`S5-TERRAIN-002`
|
||||||
|
`S5-TERRAIN-003`
|
||||||
|
`S5-TERRAIN-004`
|
||||||
|
`S5-TMA-001`
|
||||||
|
`S5-TMA-002`
|
||||||
|
`S5-TMA-003`
|
||||||
|
`S5-TMA-004`
|
||||||
|
`S5-TMA-005`
|
||||||
|
`S5-TMA-006`
|
||||||
|
`S5-TMA-007`
|
||||||
|
`S5-TMA-008`
|
||||||
|
`S5-TMA-009`
|
||||||
|
`S5-TMA-010`
|
||||||
|
`S5-TMA-011`
|
||||||
|
`S5-TMA-012`
|
||||||
|
`S5-TMA-013`
|
||||||
|
`S5-TMA-014`
|
||||||
|
`S5-TMA-015`
|
||||||
|
`S5-TMA-016`
|
||||||
|
`S5-TMA-017`
|
||||||
|
`S5-TMA-PROP-001`
|
||||||
|
`S5-TMA-FUZZ-001`
|
||||||
|
`S5-LOAD-001`
|
||||||
|
`S5-LOAD-002`
|
||||||
|
`S5-LOAD-003`
|
||||||
|
`S5-LOAD-004`
|
||||||
|
`S5-LOAD-005`
|
||||||
|
`S5-LOAD-006`
|
||||||
|
`S5-LOAD-007`
|
||||||
|
`S5-LOAD-008`
|
||||||
|
`S5-LOAD-009`
|
||||||
|
`S5-LOAD-010`
|
||||||
|
`S5-WORLD-001`
|
||||||
|
`S5-WORLD-002`
|
||||||
|
`S5-WORLD-003`
|
||||||
|
`S5-WORLD-004`
|
||||||
|
`S5-WORLD-005`
|
||||||
|
`S5-WORLD-006`
|
||||||
|
`S5-WORLD-007`
|
||||||
|
`S5-WORLD-008`
|
||||||
|
`S5-WORLD-009`
|
||||||
|
`S5-WORLD-010`
|
||||||
|
`S5-WORLD-011`
|
||||||
|
`S5-WORLD-012`
|
||||||
|
`S5-WORLD-013`
|
||||||
|
`S5-WORLD-014`
|
||||||
|
`S5-WORLD-015`
|
||||||
|
`S5-WORLD-016`
|
||||||
|
`S5-WORLD-017`
|
||||||
|
`S5-WORLD-018`
|
||||||
|
`S5-WORLD-019`
|
||||||
|
`S5-WORLD-PROP-001`
|
||||||
|
`L5-P1-LMESH-001`
|
||||||
|
`L5-P2-LMESH-001`
|
||||||
|
`L5-P1-LMAP-001`
|
||||||
|
`L5-P2-LMAP-001`
|
||||||
|
`L5-LMAP-POLY-001`
|
||||||
|
`L5-P1-TMA-001`
|
||||||
|
`L5-P2-TMA-001`
|
||||||
|
`L5-P1-MISSION-001`
|
||||||
|
`L5-P2-MISSION-001`
|
||||||
|
`L5-P1-MISSION-002`
|
||||||
|
`L5-P2-MISSION-002`
|
||||||
|
`L5-P1-HEADLESS-001`
|
||||||
|
`L5-P2-HEADLESS-001`
|
||||||
|
`L5-P1-RENDER-001`
|
||||||
|
`L5-P2-RENDER-001`
|
||||||
|
`L3-DEVICE-001`
|
||||||
|
`L5-RG40-001`
|
||||||
@@ -0,0 +1,643 @@
|
|||||||
|
# FParkan — аудит Stage 0 и план полного закрытия
|
||||||
|
|
||||||
|
**Проект:** `valentineus/fparkan`
|
||||||
|
**Проверенная ветка:** `devel` GitHub-зеркала
|
||||||
|
**Дата аудита:** 23 июня 2026 года
|
||||||
|
**Область:** только Stage 0 — Governance, reproducibility и Vulkan foundation
|
||||||
|
**Метод:** статический архитектурный и кодовый аудит
|
||||||
|
**Сборка и исполнение:** не выполнялись; `cargo build`, `cargo test`, Vulkan smoke и validation jobs не запускались
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Итоговый вердикт
|
||||||
|
|
||||||
|
**Stage 0 не закрыт и находится в статусе `BLOCKED`.**
|
||||||
|
|
||||||
|
Главный критерий Stage 0 — воспроизводимый репозиторий и минимальный настоящий Vulkan vertical slice на Windows, Linux и macOS. В проверенном состоянии:
|
||||||
|
|
||||||
|
- отсутствует `fparkan-platform-winit`;
|
||||||
|
- отсутствует `fparkan-render-vulkan`;
|
||||||
|
- отсутствуют Vulkan instance/device/surface/swapchain;
|
||||||
|
- `fparkan-game` использует `RecordingBackend`, а не GPU backend;
|
||||||
|
- workspace по-прежнему содержит SDL/OpenGL stub adapters;
|
||||||
|
- Rust toolchain закреплён только как изменяемый канал `stable`;
|
||||||
|
- `cargo xtask ci` не реализует полный канонический gate;
|
||||||
|
- нет подтверждённых артефактов Windows/Linux/macOS smoke jobs.
|
||||||
|
|
||||||
|
### Сводная оценка
|
||||||
|
|
||||||
|
| Группа требований | Статус | Основной блокер |
|
||||||
|
|---|---|---|
|
||||||
|
| Reproducibility и toolchain | **FAIL** | Toolchain не закреплён точной версией, MSRV не объявлен |
|
||||||
|
| Repository policy и CI | **FAIL** | Неполные fmt/test/clippy/doc/security gates |
|
||||||
|
| Platform abstraction | **FAIL** | Core API содержит OpenGL-specific contract; `winit` adapter отсутствует |
|
||||||
|
| Vulkan backend | **FAIL** | Нет Vulkan loader/device/surface/swapchain/pipeline |
|
||||||
|
| macOS portability | **FAIL** | Нет MoltenVK integration и portability handling |
|
||||||
|
| Offline shaders | **FAIL** | Нет SPIR-V build/validation/hash pipeline |
|
||||||
|
| Legacy cleanup | **FAIL** | SDL/GL stubs остаются workspace members |
|
||||||
|
| Headless isolation | **PASS на manifest-level** | Автоматическое доказательство dependency closure ещё требуется |
|
||||||
|
| Native acceptance | **FAIL / NOT RUNNABLE** | Нет реального backend и platform artifacts |
|
||||||
|
|
||||||
|
Stage 0 можно объявить закрытым только после прохождения реального Vulkan smoke на всех трёх системах и публикации machine-readable артефактов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Область и ограничения аудита
|
||||||
|
|
||||||
|
Канонические требования взяты из документа:
|
||||||
|
|
||||||
|
- «План реализации stage 0–5: Vulkan revision»;
|
||||||
|
- <https://app.notion.com/p/387e79f2db3981778f94cdf34db5f93f>.
|
||||||
|
|
||||||
|
Проверялась ветка:
|
||||||
|
|
||||||
|
- <https://github.com/valentineus/fparkan/tree/devel>.
|
||||||
|
|
||||||
|
Ограничения:
|
||||||
|
|
||||||
|
1. Ветка `devel` является движущейся ссылкой. Следующий formal audit следует выполнять на закреплённом commit SHA или tag.
|
||||||
|
2. README указывает self-hosted repository как primary. Его закрытые CI runners и artifacts не были доступны.
|
||||||
|
3. Код не собирался и не запускался по условию аудита.
|
||||||
|
4. Vulkan runtime, validation layers, MoltenVK и native window creation не проверялись динамически.
|
||||||
|
5. Статический анализ достаточен для определения текущих архитектурных блокеров: требуемых adapters и зависимостей в workspace нет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Матрица требований Stage 0
|
||||||
|
|
||||||
|
| Требование | Статус | Текущее состояние | Необходимо для закрытия |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Exact stable Rust toolchain | **FAIL** | `rust-toolchain.toml`: `channel = "stable"` | Закрепить точную версию, например `1.xx.y` |
|
||||||
|
| Объявленный MSRV | **FAIL** | `workspace.package.rust-version` отсутствует | Добавить `rust-version` и отдельный MSRV job |
|
||||||
|
| Полный `cargo xtask ci` | **FAIL** | Есть custom rustfmt, policy, workspace test и clippy | Добавить канонические fmt/test/clippy/doc/security gates |
|
||||||
|
| `--all-targets --all-features` | **FAIL** | Не используются текущим `ci` | Добавить к test/clippy/doc gates |
|
||||||
|
| Clippy `-D warnings` | **FAIL** | Явно не передаётся | Сделать предупреждения blocking |
|
||||||
|
| Rustdoc broken-link gate | **FAIL** | Отсутствует | Добавить `RUSTDOCFLAGS=-D warnings -D rustdoc::broken_intra_doc_links` |
|
||||||
|
| License/advisory/source policy | **PARTIAL / UNVERIFIED** | Есть custom policy и GPL workspace license | Подключить `cargo-deny` или эквивалент и хранить versioned policy |
|
||||||
|
| Typed TOML parsing | **FAIL** | Licensed manifest разбирается вручную построчно | `serde` + TOML schema + `deny_unknown_fields` |
|
||||||
|
| `cargo_metadata` policy | **FAIL** | Dependency rules не опираются на typed Cargo graph | Добавить `cargo_metadata` и package-ID based checks |
|
||||||
|
| CI matrix Windows/Linux/macOS | **UNVERIFIED / BLOCKER** | Доступных platform artifacts нет | Создать native matrix и сохранять reports |
|
||||||
|
| Backend-neutral platform API | **FAIL** | В core есть `GraphicsProfile`, GL/GLES versions и `WindowPort::present()` | Удалить GL context concepts; present перенести в renderer |
|
||||||
|
| `fparkan-platform-winit` | **FAIL** | В workspace только SDL-named stub | Реализовать настоящий event loop/window adapter |
|
||||||
|
| `fparkan-render-vulkan` | **FAIL** | В workspace только GL-named recording stub | Реализовать настоящий Vulkan backend |
|
||||||
|
| Vulkan loader/instance/device | **FAIL** | Vulkan bindings отсутствуют | Добавить `ash`, instance, device selection, queues |
|
||||||
|
| Surface/swapchain/present | **FAIL** | Отсутствуют | Реализовать platform surface и swapchain lifecycle |
|
||||||
|
| Indexed triangle | **FAIL** | Есть только command capture | Нарисовать реальный indexed triangle |
|
||||||
|
| Resize/out-of-date/suboptimal | **FAIL** | Swapchain отсутствует | Реализовать полную recreation policy |
|
||||||
|
| Deterministic capability report | **FAIL** | Device discovery отсутствует | Pure scoring policy + JSON capability report |
|
||||||
|
| macOS portability | **FAIL** | MoltenVK integration отсутствует | Portability enumeration, subset и packaged MoltenVK |
|
||||||
|
| Offline SPIR-V pipeline | **FAIL** | GL stub проверяет только synthetic markers | Pinned compiler, validator, descriptor manifest и hashes |
|
||||||
|
| Legacy adapter removal | **FAIL** | SDL/GL crates входят в workspace | Удалить crates и все references после замены |
|
||||||
|
| Game/viewer composition | **FAIL** | Game использует `RecordingBackend`; viewer — CLI inspector | Подключить winit + Vulkan только в composition roots |
|
||||||
|
| Headless isolation | **PASS на manifest-level** | Нет window/Vulkan dependency | Добавить automated Cargo metadata assertion |
|
||||||
|
| 300 frames + resize + validation=0 | **FAIL** | Невозможно выполнить без backend | Native smoke jobs на трёх OS |
|
||||||
|
| Negative Vulkan tests | **FAIL** | Нет Vulkan error model | Loader/device/queue/format failure fixtures |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Замечания
|
||||||
|
|
||||||
|
### S0-B01 — Workspace содержит удаляемые SDL/OpenGL stub crates
|
||||||
|
|
||||||
|
**Приоритет:** BLOCKER
|
||||||
|
**Файлы:** `Cargo.toml`, `adapters/fparkan-platform-sdl`, `adapters/fparkan-render-gl`
|
||||||
|
|
||||||
|
Root workspace включает оба прежних adapter crate. При этом:
|
||||||
|
|
||||||
|
- SDL adapter не зависит от SDL и содержит in-memory stubs;
|
||||||
|
- GL adapter не зависит от OpenGL и только сохраняет canonical command captures;
|
||||||
|
- их tests доказывают deterministic stub behavior, а не platform/GPU integration.
|
||||||
|
|
||||||
|
Это создаёт ложноположительный сигнал готовности backend-а.
|
||||||
|
|
||||||
|
**Рекомендация:**
|
||||||
|
|
||||||
|
1. До появления замены пометить crates как `legacy-proof` и исключить из default production composition.
|
||||||
|
2. Добавить policy, запрещающий приложениям зависеть от них.
|
||||||
|
3. После подключения `platform-winit` и `render-vulkan` удалить crates, lockfile references, docs и tests.
|
||||||
|
|
||||||
|
### S0-B02 — Core platform contract остаётся OpenGL-specific
|
||||||
|
|
||||||
|
**Приоритет:** BLOCKER
|
||||||
|
**Файл:** `crates/fparkan-platform/src/lib.rs`
|
||||||
|
|
||||||
|
Проблемы:
|
||||||
|
|
||||||
|
- `GraphicsProfile::DesktopCore/Embedded` описывает GL/GLES profile;
|
||||||
|
- `GraphicsContextRequest` описывает создание GL context;
|
||||||
|
- `WindowPort::present()` ошибочно закрепляет presentation за window abstraction;
|
||||||
|
- `PlatformEvent` содержит только `Quit`;
|
||||||
|
- отсутствуют resize, scale factor, focus, keyboard, mouse, suspend/resume и raw handles;
|
||||||
|
- `PlatformError::Backend` не содержит source/context.
|
||||||
|
|
||||||
|
Для Vulkan окно не выполняет present. Surface, swapchain, image acquisition и queue presentation принадлежат render adapter.
|
||||||
|
|
||||||
|
**Рекомендация:** platform crate должен предоставлять только:
|
||||||
|
|
||||||
|
- event/lifecycle model;
|
||||||
|
- physical и logical size;
|
||||||
|
- scale factor;
|
||||||
|
- normalized input;
|
||||||
|
- raw window/display handles;
|
||||||
|
- structured platform errors.
|
||||||
|
|
||||||
|
### S0-B03 — Реального Vulkan code path нет
|
||||||
|
|
||||||
|
**Приоритет:** BLOCKER
|
||||||
|
|
||||||
|
В inspected manifests отсутствуют `ash`, `ash-window`, `winit` и `raw-window-handle`. Следовательно, текущий код не может создать Vulkan instance/device/surface/swapchain.
|
||||||
|
|
||||||
|
`fparkan-game` выполняет backend-neutral capture через `RecordingBackend`. Это полезный CPU oracle, но не Vulkan renderer.
|
||||||
|
|
||||||
|
**Definition of fixed:** отдельный smoke executable открывает окно, создаёт Vulkan swapchain, рисует indexed triangle, обрабатывает resize и корректно завершается.
|
||||||
|
|
||||||
|
### S0-B04 — `cargo xtask ci` не соответствует exit gate
|
||||||
|
|
||||||
|
**Приоритет:** BLOCKER
|
||||||
|
**Файл:** `xtask/src/main.rs`
|
||||||
|
|
||||||
|
Текущий gate не подтверждает:
|
||||||
|
|
||||||
|
- все targets и features;
|
||||||
|
- clippy с `-D warnings`;
|
||||||
|
- rustdoc warnings и broken links;
|
||||||
|
- advisory/source policy;
|
||||||
|
- dependency denylist;
|
||||||
|
- отсутствие project-owned unsafe вне разрешённого Vulkan boundary;
|
||||||
|
- корректность typed acceptance manifests;
|
||||||
|
- platform-native smoke jobs.
|
||||||
|
|
||||||
|
Custom recursive rustfmt также может расходиться с canonical `cargo fmt --all -- --check`.
|
||||||
|
|
||||||
|
### S0-B05 — Toolchain не воспроизводим
|
||||||
|
|
||||||
|
**Приоритет:** BLOCKER
|
||||||
|
**Файл:** `rust-toolchain.toml`
|
||||||
|
|
||||||
|
Канал `stable` изменяется. Один и тот же commit может использовать разные компиляторы в разные дни. MSRV также не объявлен.
|
||||||
|
|
||||||
|
**Рекомендация:**
|
||||||
|
|
||||||
|
- закрепить точный Rust release;
|
||||||
|
- указать `rust-version`;
|
||||||
|
- обновлять toolchain отдельным reviewed PR;
|
||||||
|
- сохранять toolchain и SDK versions в acceptance report.
|
||||||
|
|
||||||
|
### S0-H01 — Нужен изолированный audited unsafe boundary
|
||||||
|
|
||||||
|
**Приоритет:** HIGH
|
||||||
|
|
||||||
|
`unsafe_code = "forbid"` правильно сохранять для backend-neutral crates. Однако Vulkan FFI требует локальных unsafe calls.
|
||||||
|
|
||||||
|
Нельзя ослаблять policy всему workspace.
|
||||||
|
|
||||||
|
**Целевая схема:**
|
||||||
|
|
||||||
|
- unsafe разрешён только в `fparkan-render-vulkan` low-level modules;
|
||||||
|
- `unsafe_op_in_unsafe_fn = deny`;
|
||||||
|
- каждый block имеет `// SAFETY:` comment;
|
||||||
|
- ownership/lifetime rules документированы;
|
||||||
|
- raw Vulkan handles не выходят в public neutral API;
|
||||||
|
- custom policy scanner проверяет allowlist.
|
||||||
|
|
||||||
|
### S0-H02 — Neutral render IDs не должны быть GPU allocation IDs
|
||||||
|
|
||||||
|
**Приоритет:** HIGH, не блокирует первый hardcoded triangle
|
||||||
|
**Файл:** `crates/fparkan-render/src/lib.rs`
|
||||||
|
|
||||||
|
`GpuMeshId` и `GpuMaterialId` появляются до существования GPU registry. Это смешивает CPU asset identity и backend-local allocation identity.
|
||||||
|
|
||||||
|
**Рекомендация:** использовать neutral `MeshAssetId`/`MaterialAssetId`; Vulkan adapter должен самостоятельно отображать их на buffers, images и descriptors.
|
||||||
|
|
||||||
|
### S0-M01 — Документация рассогласована с Vulkan revision
|
||||||
|
|
||||||
|
**Приоритет:** MEDIUM
|
||||||
|
|
||||||
|
`docs/tomes/07-implementation.md` сохраняет старую последовательность и multi-backend формулировки. Parity documentation ссылается на отсутствующий workspace crate, а active parity cases не определены.
|
||||||
|
|
||||||
|
**Рекомендация:** один versioned source of truth для stages и автоматическая проверка упомянутых crates, commands и backend names.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Сильные стороны, которые следует сохранить
|
||||||
|
|
||||||
|
- Workspace lint policy строгая и подходит для backend-neutral crates.
|
||||||
|
- `Cargo.lock` присутствует, а команды используют `--locked`.
|
||||||
|
- Synthetic и licensed corpus paths концептуально разделены.
|
||||||
|
- `fparkan-headless` не зависит от platform/render adapters на manifest-level.
|
||||||
|
- `fparkan-render` уже предоставляет deterministic command ordering, validation и canonical capture.
|
||||||
|
- Composition roots отделены от большинства core crates.
|
||||||
|
|
||||||
|
Эти элементы позволяют построить Vulkan foundation без переписывания CPU/data foundation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Целевая архитектура Stage 0
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/fparkan-game, apps/fparkan-viewer
|
||||||
|
│
|
||||||
|
├── fparkan-platform-winit
|
||||||
|
│ └── winit + raw-window-handle
|
||||||
|
│
|
||||||
|
└── fparkan-render-vulkan
|
||||||
|
├── ash-window
|
||||||
|
├── ash
|
||||||
|
├── surface / swapchain
|
||||||
|
├── device / queues
|
||||||
|
├── shaders / pipelines
|
||||||
|
└── synchronization / presentation
|
||||||
|
|
||||||
|
apps/fparkan-headless
|
||||||
|
└── runtime/core only
|
||||||
|
no winit, ash, MoltenVK or window dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
Разделение ответственности:
|
||||||
|
|
||||||
|
- `fparkan-platform`: события, input, lifecycle, sizes и handle access;
|
||||||
|
- `fparkan-platform-winit`: concrete window/event-loop implementation;
|
||||||
|
- `fparkan-render`: backend-neutral command/snapshot contracts;
|
||||||
|
- `fparkan-render-vulkan`: Vulkan resources, synchronization и present;
|
||||||
|
- game/viewer: composition root;
|
||||||
|
- headless: полностью изолированный путь.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. План полного закрытия Stage 0
|
||||||
|
|
||||||
|
Порядок PR важен. Vulkan adapter не следует строить поверх текущего GL-oriented platform contract.
|
||||||
|
|
||||||
|
### PR S0-01 — Reproducible toolchain и metadata
|
||||||
|
|
||||||
|
**Изменения**
|
||||||
|
|
||||||
|
- закрепить exact Rust toolchain;
|
||||||
|
- добавить `workspace.package.rust-version`;
|
||||||
|
- зафиксировать supported triples;
|
||||||
|
- добавить `cargo xtask doctor`;
|
||||||
|
- включать commit SHA, Rust version и platform SDK versions в reports.
|
||||||
|
|
||||||
|
**Acceptance**
|
||||||
|
|
||||||
|
- clean checkout формирует одинаковый metadata report;
|
||||||
|
- MSRV job собирает backend-neutral crates;
|
||||||
|
- pinned toolchain проходит полный synthetic gate.
|
||||||
|
|
||||||
|
### PR S0-02 — Typed xtask configuration
|
||||||
|
|
||||||
|
**Изменения**
|
||||||
|
|
||||||
|
- `serde` + TOML schemas для corpus/acceptance manifests;
|
||||||
|
- `deny_unknown_fields`;
|
||||||
|
- duplicate/missing/unknown-field validation;
|
||||||
|
- absolute canonical paths для local licensed manifest;
|
||||||
|
- `cargo_metadata` для dependency и workspace policy;
|
||||||
|
- удалить ручной line parser.
|
||||||
|
|
||||||
|
**Acceptance**
|
||||||
|
|
||||||
|
- malformed manifest всегда даёт non-zero exit;
|
||||||
|
- неизвестные поля не игнорируются;
|
||||||
|
- dependency policy работает по Cargo package IDs, targets и features.
|
||||||
|
|
||||||
|
### PR S0-03 — Полный synthetic CI gate
|
||||||
|
|
||||||
|
Обязательные команды:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo test --workspace --all-targets --all-features --locked
|
||||||
|
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||||
|
RUSTDOCFLAGS="-D warnings -D rustdoc::broken_intra_doc_links" \
|
||||||
|
cargo doc --workspace --no-deps --all-features --locked
|
||||||
|
cargo deny check advisories bans licenses sources
|
||||||
|
cargo xtask policy
|
||||||
|
cargo xtask acceptance audit --strict
|
||||||
|
```
|
||||||
|
|
||||||
|
Добавить reports для каждого gate и запрет silent skip.
|
||||||
|
|
||||||
|
### PR S0-04 — Redesign `fparkan-platform`
|
||||||
|
|
||||||
|
**Изменения**
|
||||||
|
|
||||||
|
- удалить `GraphicsProfile`, `GraphicsContextRequest` и GL version negotiation;
|
||||||
|
- убрать `present()` из window port;
|
||||||
|
- добавить normalized keyboard/mouse events;
|
||||||
|
- physical/logical size и scale factor;
|
||||||
|
- focus, minimize, occlusion, suspend/resume;
|
||||||
|
- deterministic lifecycle state machine;
|
||||||
|
- structured errors с source chain.
|
||||||
|
|
||||||
|
**Synthetic tests**
|
||||||
|
|
||||||
|
- resize coalescing;
|
||||||
|
- zero-size/minimized window;
|
||||||
|
- scale-factor changes;
|
||||||
|
- focus loss clears held input;
|
||||||
|
- key repeat и modifiers;
|
||||||
|
- suspend/resume;
|
||||||
|
- deterministic event ordering.
|
||||||
|
|
||||||
|
### PR S0-05 — `fparkan-platform-winit`
|
||||||
|
|
||||||
|
**Изменения**
|
||||||
|
|
||||||
|
- winit event loop;
|
||||||
|
- native window lifecycle;
|
||||||
|
- raw window/display handles;
|
||||||
|
- platform-specific event normalization;
|
||||||
|
- отсутствие GPU ownership.
|
||||||
|
|
||||||
|
**Acceptance**
|
||||||
|
|
||||||
|
- window-only smoke на Windows, Linux и macOS;
|
||||||
|
- native event trace соответствует synthetic model.
|
||||||
|
|
||||||
|
### PR S0-06 — Vulkan low-level boundary
|
||||||
|
|
||||||
|
**Изменения**
|
||||||
|
|
||||||
|
- `ash` и `ash-window`;
|
||||||
|
- dynamic Vulkan loader;
|
||||||
|
- instance и debug messenger;
|
||||||
|
- physical device capability records;
|
||||||
|
- pure deterministic device scoring;
|
||||||
|
- graphics/present queue selection;
|
||||||
|
- deterministic capability JSON;
|
||||||
|
- audited unsafe allowlist.
|
||||||
|
|
||||||
|
**Negative tests**
|
||||||
|
|
||||||
|
- loader отсутствует;
|
||||||
|
- Vulkan 1.1 недоступен;
|
||||||
|
- graphics queue отсутствует;
|
||||||
|
- present queue отсутствует;
|
||||||
|
- `VK_KHR_swapchain` отсутствует;
|
||||||
|
- required surface format отсутствует.
|
||||||
|
|
||||||
|
### PR S0-07 — Swapchain, triangle и offline shaders
|
||||||
|
|
||||||
|
**Изменения**
|
||||||
|
|
||||||
|
- surface и swapchain;
|
||||||
|
- format/present-mode/image-count policy;
|
||||||
|
- render pass и graphics pipeline;
|
||||||
|
- indexed triangle;
|
||||||
|
- command pools/buffers;
|
||||||
|
- binary semaphores и fences;
|
||||||
|
- frames in flight;
|
||||||
|
- resize/out-of-date/suboptimal/zero extent handling;
|
||||||
|
- pinned offline shader compiler;
|
||||||
|
- SPIR-V validation;
|
||||||
|
- descriptor/push-constant manifest;
|
||||||
|
- shader content hashes.
|
||||||
|
|
||||||
|
### PR S0-08 — macOS portability proof
|
||||||
|
|
||||||
|
**Изменения**
|
||||||
|
|
||||||
|
- `VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR`;
|
||||||
|
- portability extension enumeration;
|
||||||
|
- `VK_KHR_portability_subset` enablement, если объявлен device;
|
||||||
|
- MoltenVK packaging strategy;
|
||||||
|
- deterministic portability report;
|
||||||
|
- `.app` bundle smoke.
|
||||||
|
|
||||||
|
### PR S0-09 — Composition roots и legacy removal
|
||||||
|
|
||||||
|
**Изменения**
|
||||||
|
|
||||||
|
- game/viewer подключают winit + Vulkan adapters;
|
||||||
|
- headless остаётся без window/GPU graph;
|
||||||
|
- удалить SDL/GL stub crates;
|
||||||
|
- очистить lockfile, policy и docs;
|
||||||
|
- заменить GPU-named neutral IDs на asset IDs;
|
||||||
|
- запретить stale backend names automated policy check-ом.
|
||||||
|
|
||||||
|
### PR S0-10 — Native acceptance matrix
|
||||||
|
|
||||||
|
**Jobs**
|
||||||
|
|
||||||
|
- Windows MSVC + system Vulkan loader;
|
||||||
|
- Linux X11 или Wayland surface;
|
||||||
|
- macOS Apple Silicon + MoltenVK;
|
||||||
|
- отдельный software-Vulkan Linux PR job допустим как быстрый gate;
|
||||||
|
- native GPU jobs остаются release evidence.
|
||||||
|
|
||||||
|
**Обязательный сценарий**
|
||||||
|
|
||||||
|
1. Создать окно.
|
||||||
|
2. Создать real Vulkan swapchain.
|
||||||
|
3. Показать indexed triangle.
|
||||||
|
4. Выполнить не менее 300 frames.
|
||||||
|
5. Изменить размер окна.
|
||||||
|
6. Пересоздать swapchain.
|
||||||
|
7. Корректно завершить event loop.
|
||||||
|
8. Получить `validation_error_count = 0`.
|
||||||
|
9. Сохранить capability, shader и validation reports как artifacts.
|
||||||
|
|
||||||
|
**Stage 0 закрывается только после merge S0-01…S0-10 и зелёных native artifacts.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Требуемая CI/acceptance модель
|
||||||
|
|
||||||
|
### 8.1 Synthetic PR gate
|
||||||
|
|
||||||
|
Должен работать без игровых каталогов и без silent skip:
|
||||||
|
|
||||||
|
1. fmt, clippy, docs, security и policy;
|
||||||
|
2. все unit/integration tests;
|
||||||
|
3. platform lifecycle state-machine tests;
|
||||||
|
4. device scoring tests на synthetic capability records;
|
||||||
|
5. swapchain policy tests;
|
||||||
|
6. shader manifest/hash tests;
|
||||||
|
7. Vulkan negative-path tests без обязательного GPU;
|
||||||
|
8. headless dependency assertion;
|
||||||
|
9. report schema validation.
|
||||||
|
|
||||||
|
Tests, требующие native GPU или licensed data, должны иметь отдельные suites и machine-readable ownership/reason, а не оставаться обычными `#[ignore]` без evidence trail.
|
||||||
|
|
||||||
|
### 8.2 Native platform gate
|
||||||
|
|
||||||
|
| Platform | Минимальный gate | Дополнительное evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| Windows | system loader, swapchain, triangle, resize, 300 frames, validation=0 | Периодическая NVIDIA/AMD/Intel coverage |
|
||||||
|
| Linux | X11 или Wayland surface, swapchain, resize, validation=0 | Software Vulkan PR job + Mesa/NVIDIA native release jobs |
|
||||||
|
| macOS | MoltenVK, portability enumeration/subset, CAMetalLayer surface, resize, validation=0 | Apple Silicon как primary target |
|
||||||
|
|
||||||
|
### 8.3 Формат machine-readable отчёта
|
||||||
|
|
||||||
|
Минимальные поля:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"commit": "<sha>",
|
||||||
|
"target": "x86_64-pc-windows-msvc",
|
||||||
|
"rustc": "1.xx.y",
|
||||||
|
"vulkan_api": "1.1",
|
||||||
|
"device_name": "...",
|
||||||
|
"driver": "...",
|
||||||
|
"portability_subset": false,
|
||||||
|
"frames": 300,
|
||||||
|
"resize_count": 1,
|
||||||
|
"swapchain_recreate_count": 1,
|
||||||
|
"validation_error_count": 0,
|
||||||
|
"shader_manifest_hash": "...",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Definition of Done
|
||||||
|
|
||||||
|
Stage 0 считается закрытым, когда выполнены **все** пункты:
|
||||||
|
|
||||||
|
- [ ] Exact Rust toolchain закреплён.
|
||||||
|
- [ ] MSRV объявлен и проверяется.
|
||||||
|
- [ ] Full fmt/test/clippy/doc/security/source/license gate проходит.
|
||||||
|
- [ ] Typed TOML manifests используются.
|
||||||
|
- [ ] Dependency policy работает через `cargo_metadata`.
|
||||||
|
- [ ] Windows/Linux/macOS matrix сохраняет artifacts.
|
||||||
|
- [ ] `fparkan-platform` больше не содержит GL-specific context concepts.
|
||||||
|
- [ ] `fparkan-platform-winit` реализован.
|
||||||
|
- [ ] `fparkan-render-vulkan` реализован.
|
||||||
|
- [ ] Vulkan 1.1 instance/device/queues/surface/swapchain реализованы.
|
||||||
|
- [ ] Deterministic device scoring и capability report реализованы.
|
||||||
|
- [ ] Indexed triangle рисуется настоящим Vulkan backend.
|
||||||
|
- [ ] Resize, zero extent, out-of-date и suboptimal обработаны.
|
||||||
|
- [ ] MoltenVK portability path реализован.
|
||||||
|
- [ ] Offline SPIR-V validation и hash manifest реализованы.
|
||||||
|
- [ ] Unsafe разрешён только в audited Vulkan/FFI modules.
|
||||||
|
- [ ] Legacy SDL/GL adapters и references удалены.
|
||||||
|
- [ ] Game/viewer используют новые composition adapters.
|
||||||
|
- [ ] Headless dependency graph не содержит winit/Vulkan/MoltenVK.
|
||||||
|
- [ ] 300-frame + resize smoke проходит на трёх OS.
|
||||||
|
- [ ] Validation error count равен нулю на трёх OS.
|
||||||
|
- [ ] Acceptance reports включают commit SHA и сохраняются как artifacts.
|
||||||
|
|
||||||
|
Наличие crates или unit tests с соответствующими названиями само по себе не является закрытием Stage 0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Рекомендуемые automated policy checks
|
||||||
|
|
||||||
|
Добавить в `cargo xtask policy`:
|
||||||
|
|
||||||
|
### Workspace denylist
|
||||||
|
|
||||||
|
- запрещены `fparkan-platform-sdl` и `fparkan-render-gl` после миграции;
|
||||||
|
- запрещены stale symbols `GraphicsProfile`, `DesktopCore`, `Embedded`, `Gles2` в canonical platform/render API;
|
||||||
|
- canonical docs не содержат OpenGL как production backend.
|
||||||
|
|
||||||
|
### Dependency rules
|
||||||
|
|
||||||
|
- headless не зависит от `winit`, `raw-window-handle`, `ash`, `ash-window` или Vulkan adapter;
|
||||||
|
- backend-neutral crates не зависят от concrete platform/render adapters;
|
||||||
|
- только composition roots связывают platform и renderer;
|
||||||
|
- raw Vulkan types не экспортируются из adapter public boundary.
|
||||||
|
|
||||||
|
### Unsafe rules
|
||||||
|
|
||||||
|
- project-owned unsafe разрешён только в exact allowlisted files/modules;
|
||||||
|
- каждый block содержит `SAFETY:`;
|
||||||
|
- `unsafe_op_in_unsafe_fn` запрещён;
|
||||||
|
- изменение allowlist требует отдельного reviewed diff.
|
||||||
|
|
||||||
|
### Test и report rules
|
||||||
|
|
||||||
|
- synthetic gate не получает licensed paths;
|
||||||
|
- ignored tests обязаны иметь registered reason и owner;
|
||||||
|
- acceptance IDs уникальны;
|
||||||
|
- reports проходят schema validation;
|
||||||
|
- report всегда содержит commit SHA и target triple.
|
||||||
|
|
||||||
|
### Documentation rules
|
||||||
|
|
||||||
|
- документированные crates и commands существуют;
|
||||||
|
- canonical stage version совпадает с acceptance schema;
|
||||||
|
- старые backend names отсутствуют;
|
||||||
|
- README не объявляет незакрытый Vulkan path реализованным.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Основные риски
|
||||||
|
|
||||||
|
| Риск | Последствие | Снижение |
|
||||||
|
|---|---|---|
|
||||||
|
| Vulkan adapter начнут до redesign platform API | Повторная переделка surface/lifecycle/present | Сначала S0-04, затем S0-05/S0-06 |
|
||||||
|
| `unsafe_code` ослабят всему workspace | Рост FFI и lifetime рисков | Изолированный audited adapter и allowlist scanner |
|
||||||
|
| Stubs будут приняты за production backend | Ложное закрытие Stage 0 | Удаление legacy crates и real native smoke |
|
||||||
|
| Linux software Vulkan будет единственным evidence | Не выявятся vendor-driver проблемы | Native Mesa/NVIDIA jobs перед release |
|
||||||
|
| macOS будет проверен без portability subset report | Скрытая несовместимость MoltenVK | Обязательное capability evidence |
|
||||||
|
| Shader compiler останется неприкреплённым | Невоспроизводимый SPIR-V | Pinned compiler + manifest hashes |
|
||||||
|
| GitHub mirror и primary repository разойдутся | Audit и release относятся к разному коду | Commit SHA, canonical remote и artifact metadata |
|
||||||
|
| Документация останется отдельным source of truth | Повторное рассогласование | Versioned stage schema и automated doc checks |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Реестр доказательств
|
||||||
|
|
||||||
|
### Canonical requirement
|
||||||
|
|
||||||
|
- Vulkan revision: <https://app.notion.com/p/387e79f2db3981778f94cdf34db5f93f>
|
||||||
|
|
||||||
|
### Workspace и governance
|
||||||
|
|
||||||
|
- Root manifest: <https://github.com/valentineus/fparkan/blob/devel/Cargo.toml>
|
||||||
|
- Toolchain: <https://github.com/valentineus/fparkan/blob/devel/rust-toolchain.toml>
|
||||||
|
- Cargo config: <https://github.com/valentineus/fparkan/blob/devel/.cargo/config.toml>
|
||||||
|
- xtask manifest: <https://github.com/valentineus/fparkan/blob/devel/xtask/Cargo.toml>
|
||||||
|
- xtask implementation: <https://github.com/valentineus/fparkan/blob/devel/xtask/src/main.rs>
|
||||||
|
- README: <https://github.com/valentineus/fparkan/blob/devel/README.md>
|
||||||
|
|
||||||
|
### Platform и render
|
||||||
|
|
||||||
|
- Platform core: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-platform/src/lib.rs>
|
||||||
|
- SDL stub adapter: <https://github.com/valentineus/fparkan/blob/devel/adapters/fparkan-platform-sdl/src/lib.rs>
|
||||||
|
- Render core: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-render/src/lib.rs>
|
||||||
|
- GL stub adapter: <https://github.com/valentineus/fparkan/blob/devel/adapters/fparkan-render-gl/src/lib.rs>
|
||||||
|
- Game composition: <https://github.com/valentineus/fparkan/blob/devel/apps/fparkan-game/src/main.rs>
|
||||||
|
- Viewer composition: <https://github.com/valentineus/fparkan/blob/devel/apps/fparkan-viewer/src/main.rs>
|
||||||
|
- Headless manifest: <https://github.com/valentineus/fparkan/blob/devel/apps/fparkan-headless/Cargo.toml>
|
||||||
|
|
||||||
|
### Documentation drift
|
||||||
|
|
||||||
|
- Implementation tome: <https://github.com/valentineus/fparkan/blob/devel/docs/tomes/07-implementation.md>
|
||||||
|
- Parity README: <https://github.com/valentineus/fparkan/blob/devel/parity/README.md>
|
||||||
|
- Parity cases: <https://github.com/valentineus/fparkan/blob/devel/parity/cases.toml>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Финальное заключение
|
||||||
|
|
||||||
|
У проекта уже имеется пригодный backend-neutral фундамент: deterministic render commands, строгие neutral-crate lints, отдельный headless composition root и разделение synthetic/licensed tests. Однако Stage 0 пока представлен интерфейсными proof/stub crates, а не настоящим Vulkan vertical slice.
|
||||||
|
|
||||||
|
Критический путь:
|
||||||
|
|
||||||
|
```text
|
||||||
|
reproducible toolchain
|
||||||
|
→ complete CI/policy gate
|
||||||
|
→ backend-neutral platform redesign
|
||||||
|
→ winit adapter
|
||||||
|
→ Vulkan loader/device/surface/swapchain
|
||||||
|
→ indexed triangle + shaders + synchronization
|
||||||
|
→ MoltenVK portability
|
||||||
|
→ composition integration
|
||||||
|
→ legacy removal
|
||||||
|
→ three-platform acceptance artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
До прохождения этого пути рекомендуемый статус:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Stage 0: IN PROGRESS / BLOCKED
|
||||||
|
```
|
||||||
|
|
||||||
|
Главный критерий закрытия:
|
||||||
|
|
||||||
|
> Stage 0 завершён не тогда, когда существуют crates с названиями `winit` и `vulkan`, а когда один закреплённый commit создаёт настоящий Vulkan swapchain, рисует triangle, переживает resize и завершается без validation errors на Windows, Linux и macOS, сохраняя воспроизводимые machine-readable artifacts.
|
||||||
+11
-8
@@ -1,20 +1,23 @@
|
|||||||
# Render Parity Dataset
|
# Render Parity Dataset
|
||||||
|
|
||||||
This folder stores parity-test input for `crates/render-parity`.
|
This folder stores parity-test input for legacy render comparison workflows.
|
||||||
|
|
||||||
- `cases.toml`: list of deterministic render cases.
|
- `cases.toml`: list of deterministic render cases.
|
||||||
- `reference/*.png`: baseline frames captured from the original renderer.
|
- `reference/*.png`: baseline frames captured from the original renderer.
|
||||||
|
|
||||||
Expected workflow:
|
Expected workflow:
|
||||||
|
|
||||||
1. Capture baseline PNG frames from original game/editor for each case.
|
1. Capture baseline PNG frames for each case.
|
||||||
2. Add entries to `cases.toml`.
|
2. Add entries to `cases.toml`.
|
||||||
3. Run:
|
3. Run the acceptance renderer capture workflow with fixed profiles and compare
|
||||||
|
output captures out-of-tree.
|
||||||
|
|
||||||
```bash
|
```text
|
||||||
cargo run -p render-parity -- \
|
1) Prepare `cases.toml` and baseline captures.
|
||||||
--manifest parity/cases.toml \
|
2) Run `fparkan-game` (or dedicated acceptance runner) with fixed seed.
|
||||||
--output-dir target/render-parity/current
|
3) Compare outputs against baseline in dedicated comparison tooling.
|
||||||
```
|
```
|
||||||
|
|
||||||
On failure, diff images are saved to `target/render-parity/current/diff`.
|
The `render-parity` crate is no longer present as a standalone runner in this
|
||||||
|
workspace snapshot; parity evidence is now produced through the acceptance
|
||||||
|
artifacts and stage audit tooling.
|
||||||
|
|||||||
+8
-2
@@ -1,3 +1,9 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "stable"
|
channel = "1.87.0"
|
||||||
components = ["clippy", "rustfmt"]
|
components = ["clippy", "rustfmt", "rust-docs"]
|
||||||
|
targets = [
|
||||||
|
"x86_64-unknown-linux-gnu",
|
||||||
|
"x86_64-pc-windows-msvc",
|
||||||
|
"aarch64-apple-darwin",
|
||||||
|
"x86_64-apple-darwin",
|
||||||
|
]
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ repository.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-corpus = { path = "../crates/fparkan-corpus" }
|
fparkan-corpus = { path = "../crates/fparkan-corpus" }
|
||||||
|
cargo_metadata = "0.21"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
+1057
-172
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user