15 Commits
Author SHA1 Message Date
Valentin Popov 162de8ccab fix: require manifests for licensed gates
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 1m16s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-22 17:29:33 +04:00
Valentin Popov 0b23cf48e7 fix: use canonical sha256 world hashes 2026-06-22 17:11:21 +04:00
Valentin Popov 7356238ffb fix: harden render command validation 2026-06-22 17:05:45 +04:00
Valentin Popov 42441082f0 fix: cap fixed-step catch-up 2026-06-22 17:02:00 +04:00
Valentin Popov ccd61c05b0 fix: expose configurable rsli read profiles 2026-06-22 16:58:59 +04:00
Valentin Popov 813beec7be fix: preserve nres gaps during edits 2026-06-22 16:55:10 +04:00
Valentin Popov 91c7a8a14e fix: make corpus reports explicit and fallible 2026-06-22 16:49:32 +04:00
Valentin Popov 8b91a0bfbf fix: make core error displays actionable 2026-06-22 16:41:21 +04:00
Valentin Popov fb97405e0c fix: decode payloads outside resource lock 2026-06-22 16:36:50 +04:00
Valentin Popov d579b696e6 fix: cap decoded payload cache bytes 2026-06-22 16:34:14 +04:00
Valentin Popov aa1b809bd8 fix: strengthen resource fingerprints 2026-06-22 16:31:57 +04:00
Valentin Popov f69c893a40 fix: harden path lookup and mark gl backend gap 2026-06-22 16:12:57 +04:00
Valentin Popov 5436727961 docs: mark stage 4 runtime gaps explicit 2026-06-22 16:04:35 +04:00
Valentin Popov be41fa839f fix: harden resource and world state correctness 2026-06-22 16:02:16 +04:00
Valentin Popov 8e5e46b7b3 fix: make ci locked and isolate licensed tests 2026-06-22 15:55:37 +04:00
36 changed files with 3435 additions and 613 deletions
+1 -5
View File
@@ -69,10 +69,6 @@ $RECYCLE.BIN/
debug/ debug/
target/ target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt # These are backup files generated by rustfmt
**/*.rs.bk **/*.rs.bk
@@ -215,4 +211,4 @@ poetry.toml
.ruff_cache/ .ruff_cache/
# LSP config files # LSP config files
pyrightconfig.json pyrightconfig.json
Generated
+315
View File
@@ -0,0 +1,315 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "fparkan-animation"
version = "0.1.0"
[[package]]
name = "fparkan-assets"
version = "0.1.0"
dependencies = [
"fparkan-material",
"fparkan-msh",
"fparkan-nres",
"fparkan-path",
"fparkan-prototype",
"fparkan-resource",
"fparkan-texm",
"fparkan-vfs",
]
[[package]]
name = "fparkan-binary"
version = "0.1.0"
[[package]]
name = "fparkan-cli"
version = "0.1.0"
dependencies = [
"fparkan-corpus",
"fparkan-nres",
"fparkan-prototype",
"fparkan-resource",
"fparkan-rsli",
"fparkan-runtime",
"fparkan-vfs",
]
[[package]]
name = "fparkan-corpus"
version = "0.1.0"
dependencies = [
"fparkan-binary",
"fparkan-nres",
"fparkan-path",
]
[[package]]
name = "fparkan-diagnostics"
version = "0.1.0"
[[package]]
name = "fparkan-fx"
version = "0.1.0"
dependencies = [
"fparkan-binary",
"fparkan-nres",
]
[[package]]
name = "fparkan-game"
version = "0.1.0"
dependencies = [
"fparkan-render",
"fparkan-runtime",
"fparkan-vfs",
"fparkan-world",
]
[[package]]
name = "fparkan-headless"
version = "0.1.0"
dependencies = [
"fparkan-runtime",
"fparkan-vfs",
"fparkan-world",
]
[[package]]
name = "fparkan-material"
version = "0.1.0"
dependencies = [
"encoding_rs",
"fparkan-nres",
"fparkan-path",
"fparkan-resource",
"fparkan-vfs",
]
[[package]]
name = "fparkan-mission-format"
version = "0.1.0"
dependencies = [
"encoding_rs",
"fparkan-binary",
]
[[package]]
name = "fparkan-msh"
version = "0.1.0"
dependencies = [
"encoding_rs",
"fparkan-animation",
"fparkan-nres",
]
[[package]]
name = "fparkan-nres"
version = "0.1.0"
dependencies = [
"fparkan-binary",
"fparkan-path",
]
[[package]]
name = "fparkan-path"
version = "0.1.0"
[[package]]
name = "fparkan-platform"
version = "0.1.0"
[[package]]
name = "fparkan-platform-sdl"
version = "0.1.0"
dependencies = [
"fparkan-platform",
]
[[package]]
name = "fparkan-prototype"
version = "0.1.0"
dependencies = [
"encoding_rs",
"fparkan-binary",
"fparkan-material",
"fparkan-msh",
"fparkan-nres",
"fparkan-path",
"fparkan-resource",
"fparkan-texm",
"fparkan-vfs",
]
[[package]]
name = "fparkan-render"
version = "0.1.0"
dependencies = [
"fparkan-world",
]
[[package]]
name = "fparkan-render-gl"
version = "0.1.0"
dependencies = [
"fparkan-render",
]
[[package]]
name = "fparkan-resource"
version = "0.1.0"
dependencies = [
"fparkan-binary",
"fparkan-nres",
"fparkan-path",
"fparkan-rsli",
"fparkan-vfs",
]
[[package]]
name = "fparkan-rsli"
version = "0.1.0"
dependencies = [
"flate2",
]
[[package]]
name = "fparkan-runtime"
version = "0.1.0"
dependencies = [
"fparkan-mission-format",
"fparkan-nres",
"fparkan-path",
"fparkan-platform",
"fparkan-prototype",
"fparkan-render",
"fparkan-resource",
"fparkan-terrain",
"fparkan-terrain-format",
"fparkan-vfs",
"fparkan-world",
]
[[package]]
name = "fparkan-terrain"
version = "0.1.0"
dependencies = [
"fparkan-nres",
"fparkan-terrain-format",
]
[[package]]
name = "fparkan-terrain-format"
version = "0.1.0"
dependencies = [
"fparkan-binary",
"fparkan-nres",
]
[[package]]
name = "fparkan-test-support"
version = "0.1.0"
dependencies = [
"fparkan-render",
]
[[package]]
name = "fparkan-texm"
version = "0.1.0"
dependencies = [
"fparkan-nres",
]
[[package]]
name = "fparkan-vfs"
version = "0.1.0"
dependencies = [
"fparkan-binary",
"fparkan-path",
]
[[package]]
name = "fparkan-viewer"
version = "0.1.0"
dependencies = [
"fparkan-msh",
"fparkan-nres",
"fparkan-render",
"fparkan-resource",
"fparkan-rsli",
"fparkan-terrain-format",
"fparkan-texm",
"fparkan-vfs",
]
[[package]]
name = "fparkan-world"
version = "0.1.0"
dependencies = [
"fparkan-binary",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "simd-adler32"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "xtask"
version = "0.1.0"
dependencies = [
"fparkan-corpus",
]
+1 -1
View File
@@ -36,7 +36,7 @@ members = [
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
license = "MIT" license = "GPL-2.0-only"
repository = "https://github.com/valentineus/fparkan" repository = "https://github.com/valentineus/fparkan"
[workspace.lints.rust] [workspace.lints.rust]
+19 -2
View File
@@ -40,10 +40,27 @@ cargo xtask ci
- разместите игровые каталоги в [`testdata/`](testdata); - разместите игровые каталоги в [`testdata/`](testdata);
- игровые ресурсы в репозиторий не включаются, так как защищены авторским правом. - игровые ресурсы в репозиторий не включаются, так как защищены авторским правом.
Локальный licensed gate: Локальный licensed gate использует некоммитимый manifest:
```bash ```bash
cargo xtask acceptance report --suite licensed --stage 5 --root testdata cat > /private/tmp/fparkan-corpora.toml <<'EOF'
schema = 1
[[corpus]]
id = "part1-local"
kind = "part1"
root = "/absolute/path/to/IS"
expected_profile = "parkan-is-part1"
[[corpus]]
id = "part2-local"
kind = "part2"
root = "/absolute/path/to/IS2"
expected_profile = "parkan-is-part2"
EOF
FPARKAN_CORPORA_MANIFEST=/private/tmp/fparkan-corpora.toml \
cargo xtask acceptance report --suite licensed --stage 5
``` ```
## Contributing & Support ## Contributing & Support
+17 -17
View File
@@ -1,5 +1,5 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
//! SDL platform adapter proof behind safe `FParkan` ports. //! SDL platform adapter boundary stubs behind safe `FParkan` ports.
use fparkan_platform::{ use fparkan_platform::{
EventSource, GraphicsContextRequest, GraphicsProfile, PhysicalSize, PlatformError, EventSource, GraphicsContextRequest, GraphicsProfile, PhysicalSize, PlatformError,
@@ -33,20 +33,20 @@ impl Default for SdlAdapterCapabilities {
} }
} }
/// Returns adapter readiness status for the safe project-owned layer. /// Returns whether the project-owned adapter boundary avoids `unsafe`.
#[must_use] #[must_use]
pub fn safe_adapter_ready() -> bool { pub fn project_owned_layer_unsafe_free() -> bool {
SdlAdapterCapabilities::default().project_owned_unsafe_free SdlAdapterCapabilities::default().project_owned_unsafe_free
} }
/// In-memory event source used by adapter smoke tests and composition roots /// In-memory event source used by adapter smoke tests before a concrete SDL
/// before a concrete SDL runtime is injected. /// runtime is selected.
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct SdlEventSourceProof { pub struct SdlEventSourceStub {
pending: Vec<PlatformEvent>, pending: Vec<PlatformEvent>,
} }
impl SdlEventSourceProof { impl SdlEventSourceStub {
/// Creates an event source with deterministic pending events. /// Creates an event source with deterministic pending events.
#[must_use] #[must_use]
pub fn new(pending: Vec<PlatformEvent>) -> Self { pub fn new(pending: Vec<PlatformEvent>) -> Self {
@@ -54,22 +54,22 @@ impl SdlEventSourceProof {
} }
} }
impl EventSource for SdlEventSourceProof { impl EventSource for SdlEventSourceStub {
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError> { fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError> {
out.append(&mut self.pending); out.append(&mut self.pending);
Ok(()) Ok(())
} }
} }
/// Safe window-port proof with SDL-compatible drawable-size semantics. /// Safe window-port stub with SDL-compatible drawable-size semantics.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct SdlWindowProof { pub struct SdlWindowStub {
size: PhysicalSize, size: PhysicalSize,
presents: u64, presents: u64,
} }
impl SdlWindowProof { impl SdlWindowStub {
/// Creates a proof window with a fixed drawable size. /// Creates a stub window with a fixed drawable size.
#[must_use] #[must_use]
pub fn new(size: PhysicalSize) -> Self { pub fn new(size: PhysicalSize) -> Self {
Self { size, presents: 0 } Self { size, presents: 0 }
@@ -82,7 +82,7 @@ impl SdlWindowProof {
} }
} }
impl WindowPort for SdlWindowProof { impl WindowPort for SdlWindowStub {
fn drawable_size(&self) -> PhysicalSize { fn drawable_size(&self) -> PhysicalSize {
self.size self.size
} }
@@ -98,20 +98,20 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn adapter_reports_safe_project_layer_ready() { fn adapter_boundary_is_project_owned_unsafe_free() {
assert!(safe_adapter_ready()); assert!(project_owned_layer_unsafe_free());
assert_eq!(SdlAdapterCapabilities::default().graphics.len(), 2); assert_eq!(SdlAdapterCapabilities::default().graphics.len(), 2);
} }
#[test] #[test]
fn event_source_and_window_ports_are_deterministic() -> Result<(), PlatformError> { fn event_source_and_window_ports_are_deterministic() -> Result<(), PlatformError> {
let mut source = SdlEventSourceProof::new(vec![PlatformEvent::Quit]); let mut source = SdlEventSourceStub::new(vec![PlatformEvent::Quit]);
let mut events = Vec::new(); let mut events = Vec::new();
source.poll(&mut events)?; source.poll(&mut events)?;
source.poll(&mut events)?; source.poll(&mut events)?;
assert_eq!(events, vec![PlatformEvent::Quit]); assert_eq!(events, vec![PlatformEvent::Quit]);
let mut window = SdlWindowProof::new(PhysicalSize { let mut window = SdlWindowStub::new(PhysicalSize {
width: 320, width: 320,
height: 240, height: 240,
}); });
+6 -6
View File
@@ -1,5 +1,5 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
//! OpenGL render adapter proof behind safe `FParkan` render ports. //! OpenGL render adapter boundary stubs behind safe `FParkan` render ports.
use fparkan_render::{ use fparkan_render::{
canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError, canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError,
@@ -64,9 +64,9 @@ impl Default for GlAdapterCapabilities {
} }
} }
/// Returns adapter readiness status for the safe project-owned layer. /// Returns whether the project-owned adapter boundary avoids `unsafe`.
#[must_use] #[must_use]
pub fn safe_adapter_ready() -> bool { pub fn project_owned_layer_unsafe_free() -> bool {
GlAdapterCapabilities::default().project_owned_unsafe_free GlAdapterCapabilities::default().project_owned_unsafe_free
} }
@@ -98,7 +98,7 @@ pub fn compile_shader_source(
Ok(()) Ok(())
} }
/// Safe render backend facade used for adapter-level command validation. /// Safe render backend stub used for adapter-level command validation.
/// ///
/// A concrete OpenGL implementation can be injected behind the same /// A concrete OpenGL implementation can be injected behind the same
/// [`RenderBackend`] port once an audited safe GL facade is selected. This type /// [`RenderBackend`] port once an audited safe GL facade is selected. This type
@@ -147,8 +147,8 @@ mod tests {
}; };
#[test] #[test]
fn adapter_reports_safe_project_layer_ready() { fn adapter_boundary_is_project_owned_unsafe_free() {
assert!(safe_adapter_ready()); assert!(project_owned_layer_unsafe_free());
assert_eq!(GlAdapterCapabilities::default().profiles.len(), 2); assert_eq!(GlAdapterCapabilities::default().profiles.len(), 2);
} }
+22 -1
View File
@@ -2,4 +2,25 @@
Status: provisional Status: provisional
Workspace-owned code forbids `unsafe`. SDL/OpenGL adapters must use maintained external crates behind a safe project API. The current repository still contains legacy demo code scheduled for replacement; new adapter crates are placeholders until a fully audited safe facade is selected and proven on all target profiles. Workspace-owned code forbids `unsafe`. SDL/OpenGL adapters must use maintained
external crates behind a safe project API; local Objective-C/CGL/SDL/OpenGL FFI
inside FParkan is not an acceptable implementation strategy.
The current adapter crates are safe boundary stubs. They compile the intended
ports and deterministic command contracts, but they do not create SDL windows,
GL contexts, GPU resources, shaders, draw calls, swapchains, or presents. They
must not be treated as backend readiness evidence.
To close the macOS backend requirement, choose and vendor/lock a maintained
safe facade stack, then implement:
- SDL event source, window creation, GL context lifecycle, drawable size and
present;
- GL shader compile/link, buffer/texture upload, render state, draw calls and
diagnostics;
- game/viewer composition roots using those adapters;
- hidden-window/offscreen macOS smoke tests and licensed local model/terrain
frame captures.
Until those are implemented, Desktop GL evidence may document external probes
only; it does not satisfy the permanent adapter requirement.
+5 -2
View File
@@ -32,7 +32,7 @@ fn run(args: &[String]) -> Result<(), String> {
let root = parse_root(&rest)?; let root = parse_root(&rest)?;
let manifest = let manifest =
discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?; discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
let report = report(&root, &manifest); let report = report(&root, &manifest).map_err(|e| e.to_string())?;
println!("{}", render_report_json(&report)); println!("{}", render_report_json(&report));
Ok(()) Ok(())
} }
@@ -41,10 +41,13 @@ fn run(args: &[String]) -> Result<(), String> {
let root = parse_root(&rest)?; let root = parse_root(&rest)?;
let manifest = let manifest =
discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?; discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
let report = report(&root, &manifest); let report = report(&root, &manifest).map_err(|e| e.to_string())?;
if report.casefold_collisions > 0 { if report.casefold_collisions > 0 {
return Err("casefold collisions found".to_string()); return Err("casefold collisions found".to_string());
} }
if report.failures > 0 {
return Err(format!("corpus report found {} failures", report.failures));
}
println!("{}", render_report_json(&report)); println!("{}", render_report_json(&report));
Ok(()) Ok(())
} }
+19 -9
View File
@@ -262,21 +262,22 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn selected_is_and_is2_missions_produce_approved_render_captures() { fn selected_is_and_is2_missions_produce_approved_render_captures() {
for case in [ for case in [
RenderCase { RenderCase {
root: "IS", root: "IS",
mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma", mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma",
expected: "{\"mission\":\"MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma\",\"objects\":33,\"frames\":1,\"tick\":1,\"draws\":33,\"captures\":1,\"last_capture_bytes\":810,\"hash\":\"8584c4307bc911fc82bf909018662f392f3982bf909018666298bde408fe4242\"}", expected: "{\"mission\":\"MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma\",\"objects\":33,\"frames\":1,\"tick\":1,\"draws\":33,\"captures\":1,\"last_capture_bytes\":810,\"hash\":\"ca17cc76e55c45e83c1c9c1c088e84bf1a698be91a7730943210fe27596af841\"}",
}, },
RenderCase { RenderCase {
root: "IS2", root: "IS2",
mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma", mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
expected: "{\"mission\":\"MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma\",\"objects\":10,\"frames\":1,\"tick\":1,\"draws\":10,\"captures\":1,\"last_capture_bytes\":235,\"hash\":\"c52267cb14f699cb73b958e46c99c23ec23e73b958e46c99b3650afbcce56291\"}", expected: "{\"mission\":\"MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma\",\"objects\":10,\"frames\":1,\"tick\":1,\"draws\":10,\"captures\":1,\"last_capture_bytes\":235,\"hash\":\"5d720b3ab690076a398a79a404850bbeaee2e33811b5bb570ec8a96d4a7a2fc4\"}",
}, },
] { ] {
assert_eq!( assert_eq!(
run(&render_args(&workspace_root().join("testdata").join(case.root), case.mission)), run(&render_args(&licensed_root(case.root), case.mission)),
Ok(case.expected.to_string()) Ok(case.expected.to_string())
); );
} }
@@ -312,11 +313,20 @@ mod tests {
] ]
} }
fn workspace_root() -> PathBuf { fn licensed_root(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")) let variable = match name {
.parent() "IS" => "FPARKAN_CORPUS_PART1_ROOT",
.and_then(Path::parent) "IS2" => "FPARKAN_CORPUS_PART2_ROOT",
.expect("workspace root") _ => panic!("unknown licensed corpus part: {name}"),
.to_path_buf() };
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
} }
} }
+10 -4
View File
@@ -1,15 +1,18 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![allow(clippy::cast_precision_loss)] #![allow(clippy::cast_precision_loss)]
//! Deterministic animation sampling contracts. //! Deterministic animation sampling contracts.
//!
//! The current sampler is a portable reference path. Compatibility profiles
//! that require runtime-captured x87 parity remain explicit evidence gaps.
use std::fmt; use std::fmt;
/// Numeric profile. /// Numeric profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum NumericProfile { pub enum NumericProfile {
/// Portable reference. /// Portable reference sampler implemented by this crate.
PortableReference, PortableReference,
/// X87-compatible compatibility profile for captured parity vectors. /// Reserved profile for future runtime-captured x87 parity vectors.
X87Compatibility, X87Compatibility,
} }
@@ -424,8 +427,11 @@ impl PoseTrack {
&self.keys &self.keys
} }
/// Samples the pose track with linear translation and normalized quaternion /// Samples the pose track with the portable reference path.
/// interpolation. ///
/// `NumericProfile::X87Compatibility` is accepted so callers can keep the
/// compatibility contract explicit, but it does not yet select an
/// independently captured x87 runtime path.
/// ///
/// # Errors /// # Errors
/// ///
+170 -29
View File
@@ -6,7 +6,7 @@ use fparkan_msh::{decode_msh, validate_msh};
use fparkan_nres::{decode as decode_nres, ReadProfile}; use fparkan_nres::{decode as decode_nres, ReadProfile};
use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName}; use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName};
use fparkan_prototype::{EffectivePrototype, PrototypeGeometry, PrototypeGraph}; use fparkan_prototype::{EffectivePrototype, PrototypeGeometry, PrototypeGraph};
use fparkan_resource::{ResourceKey, ResourceRepository}; use fparkan_resource::{ResourceError, ResourceKey, ResourceRepository};
use fparkan_texm::decode_texm; use fparkan_texm::decode_texm;
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::fmt; use std::fmt;
@@ -279,17 +279,13 @@ pub fn prepare_visual_with_repository<R: ResourceRepository>(
material_count += 1; material_count += 1;
for texture in material.document.texture_requests() { for texture in material.document.texture_requests() {
resolve_texm(repository, &texture, &[TEXTURES_ARCHIVE, LIGHTMAP_ARCHIVE])?; resolve_texture(repository, &texture)?;
texture_count += 1; texture_count += 1;
} }
} }
for lightmap in &wear.lightmaps { for lightmap in &wear.lightmaps {
resolve_texm( resolve_lightmap(repository, &lightmap.lightmap)?;
repository,
&lightmap.lightmap,
&[LIGHTMAP_ARCHIVE, TEXTURES_ARCHIVE],
)?;
lightmap_count += 1; lightmap_count += 1;
} }
@@ -325,28 +321,59 @@ fn read_key<R: ResourceRepository>(
Ok(Arc::from(bytes.into_owned())) Ok(Arc::from(bytes.into_owned()))
} }
fn resolve_texture<R: ResourceRepository>(
repository: &R,
name: &ResourceName,
) -> Result<(), AssetError> {
resolve_texm(repository, name, TEXTURES_ARCHIVE, "texture")
}
fn resolve_lightmap<R: ResourceRepository>(
repository: &R,
name: &ResourceName,
) -> Result<(), AssetError> {
resolve_texm(repository, name, LIGHTMAP_ARCHIVE, "lightmap")
}
fn resolve_texm<R: ResourceRepository>( fn resolve_texm<R: ResourceRepository>(
repository: &R, repository: &R,
name: &ResourceName, name: &ResourceName,
archives: &[&str], archive: &str,
label: &'static str,
) -> Result<(), AssetError> { ) -> Result<(), AssetError> {
for archive in archives { let key = ResourceKey {
let key = ResourceKey { archive: parse_path(archive)?,
archive: parse_path(archive)?, name: name.clone(),
name: name.clone(), type_id: None,
type_id: None, };
}; let Some(bytes) = read_optional_key(repository, &key, Some(label))? else {
match read_key(repository, &key, Some("texm")) { return Err(AssetError::MissingDependency(format!("{label} {name:?}")));
Ok(bytes) => { };
decode_texm(bytes).map_err(|err| AssetError::Texture(err.to_string()))?; decode_texm(bytes)
return Ok(()); .map(|_| ())
} .map_err(|err| AssetError::Texture(err.to_string()))
Err(AssetError::MissingDependency(_) | AssetError::Resource(_)) => {} }
Err(err) => return Err(err),
}
}
Err(AssetError::MissingDependency(format!("{name:?}"))) fn read_optional_key<R: ResourceRepository>(
repository: &R,
key: &ResourceKey,
label: Option<&str>,
) -> Result<Option<Arc<[u8]>>, AssetError> {
let archive = match repository.open_archive(&key.archive) {
Ok(archive) => archive,
Err(ResourceError::MissingArchive | ResourceError::MissingEntry) => return Ok(None),
Err(err) => return Err(AssetError::Resource(format!("{label:?} {key:?}: {err}"))),
};
let Some(handle) = repository
.find(archive, &key.name)
.map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))?
else {
return Ok(None);
};
let bytes = repository
.read(handle)
.map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))?;
Ok(Some(Arc::from(bytes.into_owned())))
} }
fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, AssetError> { fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, AssetError> {
@@ -412,7 +439,7 @@ mod tests {
use super::*; use super::*;
use fparkan_prototype::build_prototype_graph; use fparkan_prototype::build_prototype_graph;
use fparkan_resource::{resource_name, CachedResourceRepository}; use fparkan_resource::{resource_name, CachedResourceRepository};
use fparkan_vfs::{DirectoryVfs, Vfs}; use fparkan_vfs::{DirectoryVfs, MemoryVfs, Vfs};
use std::path::PathBuf; use std::path::PathBuf;
#[test] #[test]
@@ -426,6 +453,48 @@ mod tests {
} }
#[test] #[test]
fn texture_resolver_does_not_fallback_to_lightmap_archive() {
let texm = texm_payload();
let repo = repository_with_archives(&[(
LIGHTMAP_ARCHIVE,
&[(b"TEX_ONLY".as_slice(), texm.as_slice())],
)]);
let err = resolve_texture(&repo, &resource_name(b"TEX_ONLY")).expect_err("missing texture");
assert!(matches!(err, AssetError::MissingDependency(_)));
}
#[test]
fn lightmap_resolver_does_not_fallback_to_texture_archive() {
let texm = texm_payload();
let repo = repository_with_archives(&[(
TEXTURES_ARCHIVE,
&[(b"LM_ONLY".as_slice(), texm.as_slice())],
)]);
let err =
resolve_lightmap(&repo, &resource_name(b"LM_ONLY")).expect_err("missing lightmap");
assert!(matches!(err, AssetError::MissingDependency(_)));
}
#[test]
fn texture_resolver_does_not_continue_after_malformed_texture() {
let malformed = b"not texm".as_slice();
let texm = texm_payload();
let repo = repository_with_archives(&[
(TEXTURES_ARCHIVE, &[(b"BAD".as_slice(), malformed)]),
(LIGHTMAP_ARCHIVE, &[(b"BAD".as_slice(), texm.as_slice())]),
]);
let err = resolve_texture(&repo, &resource_name(b"BAD")).expect_err("malformed texture");
assert!(matches!(err, AssetError::Texture(_)));
}
#[test]
#[ignore = "requires licensed corpus"]
fn prepares_real_unit_asset_plan() { fn prepares_real_unit_asset_plan() {
let root = fixture_root("IS"); let root = fixture_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root)); let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
@@ -448,6 +517,7 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn repository_plan_deduplicates_duplicate_visuals_but_graph_preserves_requests() { fn repository_plan_deduplicates_duplicate_visuals_but_graph_preserves_requests() {
let root = fixture_root("IS"); let root = fixture_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root)); let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
@@ -473,9 +543,80 @@ mod tests {
} }
fn fixture_root(part: &str) -> PathBuf { fn fixture_root(part: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")) let variable = match part {
.join("../..") "IS" => "FPARKAN_CORPUS_PART1_ROOT",
.join("testdata") "IS2" => "FPARKAN_CORPUS_PART2_ROOT",
.join(part) _ => panic!("unknown licensed corpus part: {part}"),
};
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
}
fn repository_with_archives(
archives: &[(&str, &[(&[u8], &[u8])])],
) -> CachedResourceRepository {
let mut vfs = MemoryVfs::default();
for (archive, entries) in archives {
let path = parse_path(archive).expect("archive path");
vfs.insert(path, Arc::from(build_nres(entries).into_boxed_slice()));
}
CachedResourceRepository::new(Arc::new(vfs))
}
fn texm_payload() -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&0x6d78_6554_u32.to_le_bytes());
out.extend_from_slice(&1_u32.to_le_bytes());
out.extend_from_slice(&1_u32.to_le_bytes());
out.extend_from_slice(&1_u32.to_le_bytes());
out.extend_from_slice(&0_u32.to_le_bytes());
out.extend_from_slice(&0_u32.to_le_bytes());
out.extend_from_slice(&0_u32.to_le_bytes());
out.extend_from_slice(&565_u32.to_le_bytes());
out.extend_from_slice(&0xffff_u16.to_le_bytes());
out
}
fn build_nres(entries: &[(&[u8], &[u8])]) -> Vec<u8> {
let mut out = vec![0; 16];
let mut offsets = Vec::with_capacity(entries.len());
for (_, payload) in entries {
offsets.push(u32::try_from(out.len()).expect("offset"));
out.extend_from_slice(payload);
let padding = (8 - (out.len() % 8)) % 8;
out.resize(out.len() + padding, 0);
}
let mut order: Vec<usize> = (0..entries.len()).collect();
order.sort_by(|left, right| entries[*left].0.cmp(entries[*right].0));
for (idx, (name, payload)) in entries.iter().enumerate() {
push_u32(&mut out, 0);
push_u32(&mut out, 0);
push_u32(&mut out, 0);
push_u32(&mut out, u32::try_from(payload.len()).expect("payload"));
push_u32(&mut out, 0);
let mut name_raw = [0; 36];
let len = name_raw.len().saturating_sub(1).min(name.len());
name_raw[..len].copy_from_slice(&name[..len]);
out.extend_from_slice(&name_raw);
push_u32(&mut out, offsets[idx]);
push_u32(&mut out, u32::try_from(order[idx]).expect("sort index"));
}
out[0..4].copy_from_slice(b"NRes");
out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
out[8..12].copy_from_slice(&u32::try_from(entries.len()).expect("count").to_le_bytes());
let total_size = u32::try_from(out.len()).expect("total size");
out[12..16].copy_from_slice(&total_size.to_le_bytes());
out
}
fn push_u32(out: &mut Vec<u8>, value: u32) {
out.extend_from_slice(&value.to_le_bytes());
} }
} }
+192
View File
@@ -3,6 +3,9 @@
use std::fmt; use std::fmt;
/// SHA-256 digest bytes.
pub type Sha256Digest = [u8; 32];
/// Parser limits shared by binary formats. /// Parser limits shared by binary formats.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub struct Limits { pub struct Limits {
@@ -262,6 +265,183 @@ pub fn read_lp_bytes(cursor: &mut Cursor<'_>, max: u32) -> Result<Vec<u8>, Decod
Ok(cursor.read_exact(len)?.to_vec()) Ok(cursor.read_exact(len)?.to_vec())
} }
/// Computes a SHA-256 content digest without external dependencies.
#[must_use]
pub fn sha256(bytes: &[u8]) -> Sha256Digest {
const K: [u32; 64] = [
0x428a_2f98,
0x7137_4491,
0xb5c0_fbcf,
0xe9b5_dba5,
0x3956_c25b,
0x59f1_11f1,
0x923f_82a4,
0xab1c_5ed5,
0xd807_aa98,
0x1283_5b01,
0x2431_85be,
0x550c_7dc3,
0x72be_5d74,
0x80de_b1fe,
0x9bdc_06a7,
0xc19b_f174,
0xe49b_69c1,
0xefbe_4786,
0x0fc1_9dc6,
0x240c_a1cc,
0x2de9_2c6f,
0x4a74_84aa,
0x5cb0_a9dc,
0x76f9_88da,
0x983e_5152,
0xa831_c66d,
0xb003_27c8,
0xbf59_7fc7,
0xc6e0_0bf3,
0xd5a7_9147,
0x06ca_6351,
0x1429_2967,
0x27b7_0a85,
0x2e1b_2138,
0x4d2c_6dfc,
0x5338_0d13,
0x650a_7354,
0x766a_0abb,
0x81c2_c92e,
0x9272_2c85,
0xa2bf_e8a1,
0xa81a_664b,
0xc24b_8b70,
0xc76c_51a3,
0xd192_e819,
0xd699_0624,
0xf40e_3585,
0x106a_a070,
0x19a4_c116,
0x1e37_6c08,
0x2748_774c,
0x34b0_bcb5,
0x391c_0cb3,
0x4ed8_aa4a,
0x5b9c_ca4f,
0x682e_6ff3,
0x748f_82ee,
0x78a5_636f,
0x84c8_7814,
0x8cc7_0208,
0x90be_fffa,
0xa450_6ceb,
0xbef9_a3f7,
0xc671_78f2,
];
let mut h = [
0x6a09_e667,
0xbb67_ae85,
0x3c6e_f372,
0xa54f_f53a,
0x510e_527f,
0x9b05_688c,
0x1f83_d9ab,
0x5be0_cd19,
];
let bit_len = (bytes.len() as u64).wrapping_mul(8);
let mut chunks = bytes.chunks_exact(64);
for chunk in &mut chunks {
compress_sha256_chunk(&mut h, chunk, &K);
}
let tail = chunks.remainder();
let mut block = [0u8; 128];
block[..tail.len()].copy_from_slice(tail);
block[tail.len()] = 0x80;
let padded_len = if tail.len() < 56 { 64 } else { 128 };
block[padded_len - 8..padded_len].copy_from_slice(&bit_len.to_be_bytes());
for chunk in block[..padded_len].chunks_exact(64) {
compress_sha256_chunk(&mut h, chunk, &K);
}
let mut out = [0u8; 32];
for (idx, word) in h.iter().enumerate() {
out[idx * 4..idx * 4 + 4].copy_from_slice(&word.to_be_bytes());
}
out
}
/// Renders a SHA-256 digest as lowercase hexadecimal.
#[must_use]
pub fn sha256_hex(digest: &Sha256Digest) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(64);
for byte in digest {
out.push(char::from(HEX[usize::from(byte >> 4)]));
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
out
}
#[allow(clippy::many_single_char_names)]
fn compress_sha256_chunk(h: &mut [u32; 8], chunk: &[u8], k: &[u32; 64]) {
let mut w = [0u32; 64];
for (idx, word) in w.iter_mut().take(16).enumerate() {
let base = idx * 4;
*word = u32::from_be_bytes([
chunk[base],
chunk[base + 1],
chunk[base + 2],
chunk[base + 3],
]);
}
for idx in 16..64 {
let s0 = w[idx - 15].rotate_right(7) ^ w[idx - 15].rotate_right(18) ^ (w[idx - 15] >> 3);
let s1 = w[idx - 2].rotate_right(17) ^ w[idx - 2].rotate_right(19) ^ (w[idx - 2] >> 10);
w[idx] = w[idx - 16]
.wrapping_add(s0)
.wrapping_add(w[idx - 7])
.wrapping_add(s1);
}
let mut a = h[0];
let mut b = h[1];
let mut c = h[2];
let mut d = h[3];
let mut e = h[4];
let mut f = h[5];
let mut g = h[6];
let mut hh = h[7];
for idx in 0..64 {
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
let ch = (e & f) ^ ((!e) & g);
let temp1 = hh
.wrapping_add(s1)
.wrapping_add(ch)
.wrapping_add(k[idx])
.wrapping_add(w[idx]);
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
let maj = (a & b) ^ (a & c) ^ (b & c);
let temp2 = s0.wrapping_add(maj);
hh = g;
g = f;
f = e;
e = d.wrapping_add(temp1);
d = c;
c = b;
b = a;
a = temp1.wrapping_add(temp2);
}
h[0] = h[0].wrapping_add(a);
h[1] = h[1].wrapping_add(b);
h[2] = h[2].wrapping_add(c);
h[3] = h[3].wrapping_add(d);
h[4] = h[4].wrapping_add(e);
h[5] = h[5].wrapping_add(f);
h[6] = h[6].wrapping_add(g);
h[7] = h[7].wrapping_add(hh);
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -305,4 +485,16 @@ mod tests {
); );
assert_eq!(cursor.offset(), 4); assert_eq!(cursor.offset(), 4);
} }
#[test]
fn sha256_matches_known_vectors() {
assert_eq!(
sha256_hex(&sha256(b"")),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
sha256_hex(&sha256(b"abc")),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
} }
+2
View File
@@ -6,6 +6,8 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-binary = { path = "../fparkan-binary" }
fparkan-nres = { path = "../fparkan-nres" }
fparkan-path = { path = "../fparkan-path" } fparkan-path = { path = "../fparkan-path" }
[lints] [lints]
+361 -132
View File
@@ -1,12 +1,14 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
//! Licensed corpus discovery and aggregate reports. //! Licensed corpus discovery and aggregate reports.
use fparkan_binary::{sha256, sha256_hex, Sha256Digest};
use fparkan_path::{ascii_lookup_key, normalize_relative, PathPolicy}; use fparkan_path::{ascii_lookup_key, normalize_relative, PathPolicy};
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::fmt; use std::fmt;
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Corpus kind. /// Corpus kind.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -39,8 +41,8 @@ pub struct ManifestEntry {
pub path: String, pub path: String,
/// File size in bytes. /// File size in bytes.
pub size: u64, pub size: u64,
/// Stable content fingerprint. /// SHA-256 content fingerprint.
pub hash: u64, pub hash: Sha256Digest,
} }
/// Corpus manifest. /// Corpus manifest.
@@ -70,7 +72,35 @@ pub struct CorpusReport {
/// Casefold collision count. /// Casefold collision count.
pub casefold_collisions: usize, pub casefold_collisions: usize,
/// Manifest fingerprint. /// Manifest fingerprint.
pub fingerprint: u64, pub fingerprint: Sha256Digest,
/// Per-file status records.
pub records: Vec<CorpusFileRecord>,
/// Number of files with report errors.
pub failures: usize,
}
/// Per-file report status.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CorpusFileStatus {
/// File was inspected successfully.
Ok,
/// File was inspected but produced a non-fatal warning.
Warning,
/// File could not be inspected.
Error,
}
/// Per-file report record.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CorpusFileRecord {
/// Normalized relative path.
pub path: String,
/// Inspection status.
pub status: CorpusFileStatus,
/// Detected file variant.
pub variant: String,
/// Optional status message.
pub message: Option<String>,
} }
/// Corpus error. /// Corpus error.
@@ -87,6 +117,13 @@ pub enum CorpusError {
InvalidRoot(PathBuf), InvalidRoot(PathBuf),
/// Invalid path. /// Invalid path.
InvalidPath(String), InvalidPath(String),
/// Aggregate report failure.
Report {
/// Path where reporting failed.
path: String,
/// Failure message.
message: String,
},
} }
impl fmt::Display for CorpusError { impl fmt::Display for CorpusError {
@@ -95,6 +132,7 @@ impl fmt::Display for CorpusError {
Self::Io { path, source } => write!(f, "{}: {source}", path.display()), Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
Self::InvalidRoot(path) => write!(f, "invalid corpus root: {}", path.display()), Self::InvalidRoot(path) => write!(f, "invalid corpus root: {}", path.display()),
Self::InvalidPath(path) => write!(f, "invalid corpus path: {path}"), Self::InvalidPath(path) => write!(f, "invalid corpus path: {path}"),
Self::Report { path, message } => write!(f, "{path}: {message}"),
} }
} }
} }
@@ -103,7 +141,7 @@ impl std::error::Error for CorpusError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self { match self {
Self::Io { source, .. } => Some(source), Self::Io { source, .. } => Some(source),
Self::InvalidRoot(_) | Self::InvalidPath(_) => None, Self::InvalidRoot(_) | Self::InvalidPath(_) | Self::Report { .. } => None,
} }
} }
} }
@@ -187,7 +225,7 @@ fn walk(
out.push(ManifestEntry { out.push(ManifestEntry {
path: normalized.as_str().to_string(), path: normalized.as_str().to_string(),
size: metadata.len(), size: metadata.len(),
hash: stable_hash(&bytes), hash: sha256(&bytes),
}); });
} }
Ok(()) Ok(())
@@ -229,8 +267,39 @@ fn detect_casefold_collisions(files: &[ManifestEntry]) -> Vec<Vec<String>> {
} }
/// Builds aggregate report. /// Builds aggregate report.
#[must_use] ///
pub fn report(root: &Path, manifest: &CorpusManifest) -> CorpusReport { /// # Errors
///
/// Returns [`CorpusError`] when the aggregate report cannot be constructed.
/// Per-file inspection failures are represented in [`CorpusReport::records`]
/// and counted in [`CorpusReport::failures`].
pub fn report(root: &Path, manifest: &CorpusManifest) -> Result<CorpusReport, CorpusError> {
let mut metrics = empty_report_metrics();
let mut records = Vec::with_capacity(manifest.files.len());
let mut failures = 0usize;
for entry in &manifest.files {
let record = inspect_report_file(root, entry, &mut metrics);
if record.status == CorpusFileStatus::Error {
failures = failures.saturating_add(1);
}
records.push(record);
}
Ok(CorpusReport {
schema: 1,
kind: manifest.kind,
files: manifest.files.len(),
bytes: manifest.files.iter().map(|f| f.size).sum(),
metrics,
casefold_collisions: manifest.casefold_collisions.len(),
fingerprint: fingerprint(manifest),
records,
failures,
})
}
fn empty_report_metrics() -> BTreeMap<String, u64> {
let mut metrics = BTreeMap::new(); let mut metrics = BTreeMap::new();
metrics.insert("nres_files".to_string(), 0); metrics.insert("nres_files".to_string(), 0);
metrics.insert("nres_entries".to_string(), 0); metrics.insert("nres_entries".to_string(), 0);
@@ -244,67 +313,97 @@ pub fn report(root: &Path, manifest: &CorpusManifest) -> CorpusReport {
metrics.insert("texm_entries".to_string(), 0); metrics.insert("texm_entries".to_string(), 0);
metrics.insert("fxid_entries".to_string(), 0); metrics.insert("fxid_entries".to_string(), 0);
metrics.insert("wear_entries".to_string(), 0); metrics.insert("wear_entries".to_string(), 0);
metrics
}
for entry in &manifest.files { fn inspect_report_file(
let lower = entry.path.to_ascii_lowercase(); root: &Path,
if lower.ends_with("data.tma") { entry: &ManifestEntry,
bump(&mut metrics, "tma_files", 1); metrics: &mut BTreeMap<String, u64>,
) -> CorpusFileRecord {
let lower = entry.path.to_ascii_lowercase();
let mut variant = inspect_path_metrics(&lower, metrics);
let path = root.join(&entry.path);
let bytes = match fs::read(&path) {
Ok(bytes) => bytes,
Err(source) => {
return CorpusFileRecord {
path: entry.path.clone(),
status: CorpusFileStatus::Error,
variant,
message: Some(source.to_string()),
};
} }
if lower.ends_with("land.msh") { };
bump(&mut metrics, "land_msh_files", 1); if bytes.starts_with(b"NRes") {
} variant = "nres".to_string();
if lower.ends_with("land.map") { bump(metrics, "nres_files", 1);
bump(&mut metrics, "land_map_files", 1); if let Err(message) = inspect_nres_metrics(bytes, metrics) {
} return CorpusFileRecord {
if has_extension(&lower, "dat") path: entry.path.clone(),
&& (lower.starts_with("units/") || lower.contains("/units/")) status: CorpusFileStatus::Error,
{ variant,
bump(&mut metrics, "unit_dat_files", 1); message: Some(message),
};
} }
} else if bytes.starts_with(b"NL") {
variant = "rsli".to_string();
bump(metrics, "rsli_files", 1);
}
CorpusFileRecord {
path: entry.path.clone(),
status: CorpusFileStatus::Ok,
variant,
message: None,
}
}
let path = root.join(&entry.path); fn inspect_path_metrics(lower: &str, metrics: &mut BTreeMap<String, u64>) -> String {
if let Ok(bytes) = fs::read(path) { let mut variant = "file";
if bytes.starts_with(b"NRes") { if lower.ends_with("data.tma") {
bump(&mut metrics, "nres_files", 1); bump(metrics, "tma_files", 1);
if let Some(entries) = inspect_nres_entries(&bytes) { variant = "tma";
bump(&mut metrics, "nres_entries", entries.len() as u64); }
for entry in entries { if lower.ends_with("land.msh") {
let name = entry.name.to_ascii_lowercase(); bump(metrics, "land_msh_files", 1);
if has_extension(&name, "msh") { variant = "land_msh";
bump(&mut metrics, "msh_entries", 1); }
} if lower.ends_with("land.map") {
match entry.kind { bump(metrics, "land_map_files", 1);
0x3054_414D => { variant = "land_map";
bump(&mut metrics, "mat0_entries", 1); }
} if has_extension(lower, "dat") && (lower.starts_with("units/") || lower.contains("/units/")) {
0x6D78_6554 => { bump(metrics, "unit_dat_files", 1);
bump(&mut metrics, "texm_entries", 1); variant = "unit_dat";
} }
0x4449_5846 => { variant.to_string()
bump(&mut metrics, "fxid_entries", 1); }
}
0x5241_4557 => { fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) -> Result<(), String> {
bump(&mut metrics, "wear_entries", 1); let entries = inspect_nres_entries(bytes)?;
} bump(metrics, "nres_entries", entries.len() as u64);
_ => {} for entry in entries {
} let name = String::from_utf8_lossy(entry.name_bytes()).to_ascii_lowercase();
} if has_extension(&name, "msh") {
} bump(metrics, "msh_entries", 1);
} else if bytes.starts_with(b"NL") { }
bump(&mut metrics, "rsli_files", 1); match entry.meta().type_id {
0x3054_414D => {
bump(metrics, "mat0_entries", 1);
} }
0x6D78_6554 => {
bump(metrics, "texm_entries", 1);
}
0x4449_5846 => {
bump(metrics, "fxid_entries", 1);
}
0x5241_4557 => {
bump(metrics, "wear_entries", 1);
}
_ => {}
} }
} }
Ok(())
CorpusReport {
schema: 1,
kind: manifest.kind,
files: manifest.files.len(),
bytes: manifest.files.iter().map(|f| f.size).sum(),
metrics,
casefold_collisions: manifest.casefold_collisions.len(),
fingerprint: fingerprint(manifest),
}
} }
fn bump(metrics: &mut BTreeMap<String, u64>, key: &str, delta: u64) { fn bump(metrics: &mut BTreeMap<String, u64>, key: &str, delta: u64) {
@@ -319,60 +418,26 @@ 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))
} }
#[derive(Clone, Debug)] fn inspect_nres_entries(bytes: Vec<u8>) -> Result<Vec<fparkan_nres::NresEntry>, String> {
struct NresEntryBrief { let document = fparkan_nres::decode(
kind: u32, Arc::from(bytes.into_boxed_slice()),
name: String, fparkan_nres::ReadProfile::Compatible,
} )
.map_err(|err| err.to_string())?;
fn inspect_nres_entries(bytes: &[u8]) -> Option<Vec<NresEntryBrief>> { Ok(document.entries().to_vec())
if bytes.len() < 16 || !bytes.starts_with(b"NRes") {
return None;
}
let count = i32::from_le_bytes(bytes.get(8..12)?.try_into().ok()?);
if count < 0 {
return None;
}
let count = usize::try_from(count).ok()?;
let directory_len = count.checked_mul(64)?;
let directory_offset = bytes.len().checked_sub(directory_len)?;
let mut names = Vec::with_capacity(count);
for index in 0..count {
let base = directory_offset.checked_add(index.checked_mul(64)?)?;
let kind = u32::from_le_bytes(bytes.get(base..base + 4)?.try_into().ok()?);
let raw = bytes.get(base + 20..base + 56)?;
let len = raw.iter().position(|b| *b == 0).unwrap_or(raw.len());
names.push(NresEntryBrief {
kind,
name: String::from_utf8_lossy(&raw[..len]).to_string(),
});
}
Some(names)
} }
/// Computes stable manifest fingerprint. /// Computes stable manifest fingerprint.
#[must_use] #[must_use]
pub fn fingerprint(manifest: &CorpusManifest) -> u64 { pub fn fingerprint(manifest: &CorpusManifest) -> Sha256Digest {
let mut state = 0xcbf2_9ce4_8422_2325; let mut bytes = Vec::new();
for file in &manifest.files { for file in &manifest.files {
hash_into(&mut state, file.path.as_bytes()); bytes.extend_from_slice(file.path.as_bytes());
hash_into(&mut state, &file.size.to_le_bytes()); bytes.push(0);
hash_into(&mut state, &file.hash.to_le_bytes()); bytes.extend_from_slice(&file.size.to_le_bytes());
} bytes.extend_from_slice(&file.hash);
state
}
fn stable_hash(bytes: &[u8]) -> u64 {
let mut state = 0xcbf2_9ce4_8422_2325;
hash_into(&mut state, bytes);
state
}
fn hash_into(state: &mut u64, bytes: &[u8]) {
for byte in bytes {
*state ^= u64::from(*byte);
*state = state.wrapping_mul(0x0000_0100_0000_01b3);
} }
sha256(&bytes)
} }
/// Writes report atomically. /// Writes report atomically.
@@ -413,13 +478,15 @@ pub fn write_report_atomic(path: &Path, report: &CorpusReport) -> Result<(), Cor
#[must_use] #[must_use]
pub fn render_report_json(report: &CorpusReport) -> String { pub fn render_report_json(report: &CorpusReport) -> String {
let mut out = format!( let mut out = format!(
"{{\"schema_version\":\"fparkan-corpus-report-v1\",\"schema\":{},\"kind\":\"{:?}\",\"files\":{},\"bytes\":{},\"casefold_collisions\":{},\"fingerprint\":\"{:016x}\",\"metrics\":{{", "{{\"schema_version\":\"fparkan-corpus-report-v1\",\"schema\":{},\"kind\":\"{:?}\",\"files\":{},\"bytes\":{},\"casefold_collisions\":{},\"fingerprint\":\"{}\",\"failures\":{},\"record_count\":{},\"metrics\":{{",
report.schema, report.schema,
report.kind, report.kind,
report.files, report.files,
report.bytes, report.bytes,
report.casefold_collisions, report.casefold_collisions,
report.fingerprint sha256_hex(&report.fingerprint),
report.failures,
report.records.len()
); );
for (idx, (key, value)) in report.metrics.iter().enumerate() { for (idx, (key, value)) in report.metrics.iter().enumerate() {
if idx > 0 { if idx > 0 {
@@ -442,25 +509,21 @@ mod tests {
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn report_for_testdata_roots() { fn report_for_testdata_roots() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")) let root = licensed_root("IS");
.join("../..")
.join("testdata")
.join("IS");
if !root.is_dir() {
return;
}
let manifest = discover(&root, DiscoverOptions::default()).expect("manifest"); let manifest = discover(&root, DiscoverOptions::default()).expect("manifest");
let report = report(&root, &manifest); let report = report(&root, &manifest).expect("report");
assert!(report.files > 0); assert!(report.files > 0);
assert!(report.metrics["nres_files"] > 0); assert!(report.metrics["nres_files"] > 0);
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_part1_manifest_profile_and_counts_match_baseline() { fn licensed_part1_manifest_profile_and_counts_match_baseline() {
let root = testdata_root("IS"); let root = testdata_root("IS");
let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest"); let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest");
let report = report(&root, &manifest); let report = report(&root, &manifest).expect("report");
assert_eq!(manifest.kind, CorpusKind::Part1); assert_eq!(manifest.kind, CorpusKind::Part1);
assert_eq!(report.files, 1_017); assert_eq!(report.files, 1_017);
@@ -473,10 +536,11 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_part2_manifest_profile_and_counts_match_baseline() { fn licensed_part2_manifest_profile_and_counts_match_baseline() {
let root = testdata_root("IS2"); let root = testdata_root("IS2");
let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest"); let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest");
let report = report(&root, &manifest); let report = report(&root, &manifest).expect("report");
assert_eq!(manifest.kind, CorpusKind::Part2); assert_eq!(manifest.kind, CorpusKind::Part2);
assert_eq!(report.files, 1_302); assert_eq!(report.files, 1_302);
@@ -489,6 +553,7 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_part1_has_no_casefold_relative_path_collisions() { fn licensed_part1_has_no_casefold_relative_path_collisions() {
let root = testdata_root("IS"); let root = testdata_root("IS");
let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest"); let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest");
@@ -497,6 +562,7 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_part2_has_no_casefold_relative_path_collisions() { fn licensed_part2_has_no_casefold_relative_path_collisions() {
let root = testdata_root("IS2"); let root = testdata_root("IS2");
let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest"); let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest");
@@ -505,11 +571,13 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_part1_paths_stay_under_root() { fn licensed_part1_paths_stay_under_root() {
assert_discovered_paths_stay_under_root("IS"); assert_discovered_paths_stay_under_root("IS");
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_part2_paths_stay_under_root() { fn licensed_part2_paths_stay_under_root() {
assert_discovered_paths_stay_under_root("IS2"); assert_discovered_paths_stay_under_root("IS2");
} }
@@ -521,20 +589,115 @@ mod tests {
files: vec![ManifestEntry { files: vec![ManifestEntry {
path: "secret/payload.bin".to_string(), path: "secret/payload.bin".to_string(),
size: 4, size: 4,
hash: stable_hash(b"DATA"), hash: sha256(b"DATA"),
}], }],
casefold_collisions: Vec::new(), casefold_collisions: Vec::new(),
}; };
let report = report(Path::new("."), &manifest); let report = report(Path::new("."), &manifest).expect("report");
let json = render_report_json(&report); let json = render_report_json(&report);
assert!(json.contains("\"schema_version\":\"fparkan-corpus-report-v1\"")); assert!(json.contains("\"schema_version\":\"fparkan-corpus-report-v1\""));
assert!(json.contains("\"fingerprint\":")); assert!(json.contains("\"fingerprint\":"));
assert!(json.contains("\"failures\":1"));
assert!(json.contains("\"record_count\":1"));
assert!(json.contains("\"metrics\":")); assert!(json.contains("\"metrics\":"));
assert!(!json.contains("secret/payload.bin")); assert!(!json.contains("secret/payload.bin"));
assert!(!json.contains("DATA")); assert!(!json.contains("DATA"));
} }
#[test]
fn report_records_missing_manifest_files_as_failures() {
let root = temp_dir("report-missing");
let manifest = CorpusManifest {
kind: CorpusKind::Unknown,
files: vec![ManifestEntry {
path: "missing.lib".to_string(),
size: 1,
hash: sha256(b"missing"),
}],
casefold_collisions: Vec::new(),
};
let report = report(&root, &manifest).expect("report");
assert_eq!(report.failures, 1);
assert_eq!(report.records.len(), 1);
assert_eq!(report.records[0].path, "missing.lib");
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
let _ = fs::remove_dir_all(root);
}
#[test]
fn report_records_malformed_nres_as_failure() {
let root = temp_dir("report-bad-nres");
fs::write(root.join("bad.lib"), b"NRes").expect("bad nres");
let manifest = CorpusManifest {
kind: CorpusKind::Unknown,
files: vec![ManifestEntry {
path: "bad.lib".to_string(),
size: 4,
hash: sha256(b"NRes"),
}],
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, "nres");
assert!(report.records[0]
.message
.as_deref()
.is_some_and(|message| message.contains("NRes")));
let _ = fs::remove_dir_all(root);
}
#[test]
fn report_uses_production_nres_parser_for_entry_metrics() {
let root = temp_dir("report-nres");
let archive = build_nres(&[
TestNresEntry {
name: "mesh.msh",
type_id: 0,
payload: b"mesh",
},
TestNresEntry {
name: "mat.bin",
type_id: 0x3054_414D,
payload: b"mat0",
},
TestNresEntry {
name: "texture.bin",
type_id: 0x6D78_6554,
payload: b"texm",
},
]);
fs::write(root.join("archive.lib"), &archive).expect("archive");
let manifest = CorpusManifest {
kind: CorpusKind::Unknown,
files: vec![ManifestEntry {
path: "archive.lib".to_string(),
size: u64::try_from(archive.len()).expect("archive size"),
hash: sha256(&archive),
}],
casefold_collisions: Vec::new(),
};
let report = report(&root, &manifest).expect("report");
assert_eq!(report.failures, 0);
assert_eq!(report.records.len(), 1);
assert_eq!(report.records[0].status, CorpusFileStatus::Ok);
assert_eq!(report.records[0].variant, "nres");
assert_eq!(report.metrics["nres_files"], 1);
assert_eq!(report.metrics["nres_entries"], 3);
assert_eq!(report.metrics["msh_entries"], 1);
assert_eq!(report.metrics["mat0_entries"], 1);
assert_eq!(report.metrics["texm_entries"], 1);
let _ = fs::remove_dir_all(root);
}
#[test] #[test]
fn deterministic_traversal_is_creation_order_independent() { fn deterministic_traversal_is_creation_order_independent() {
let first = temp_dir("order-first"); let first = temp_dir("order-first");
@@ -597,12 +760,12 @@ mod tests {
ManifestEntry { ManifestEntry {
path: "Textures/Foo.TEX".to_string(), path: "Textures/Foo.TEX".to_string(),
size: 1, size: 1,
hash: 1, hash: sha256(b"first"),
}, },
ManifestEntry { ManifestEntry {
path: "textures/foo.tex".to_string(), path: "textures/foo.tex".to_string(),
size: 1, size: 1,
hash: 2, hash: sha256(b"second"),
}, },
], ],
casefold_collisions: Vec::new(), casefold_collisions: Vec::new(),
@@ -626,12 +789,12 @@ mod tests {
files: vec![ManifestEntry { files: vec![ManifestEntry {
path: "a".to_string(), path: "a".to_string(),
size: 1, size: 1,
hash: 1, hash: sha256(b"before"),
}], }],
casefold_collisions: Vec::new(), casefold_collisions: Vec::new(),
}; };
let a = fingerprint(&manifest); let a = fingerprint(&manifest);
manifest.files[0].hash = 2; manifest.files[0].hash = sha256(b"after");
assert_ne!(a, fingerprint(&manifest)); assert_ne!(a, fingerprint(&manifest));
} }
@@ -651,13 +814,65 @@ mod tests {
bytes: 0, bytes: 0,
metrics: BTreeMap::new(), metrics: BTreeMap::new(),
casefold_collisions: 0, casefold_collisions: 0,
fingerprint: 0, fingerprint: sha256(b"empty-report"),
records: Vec::new(),
failures: 0,
}; };
write_report_atomic(&tmp, &report).expect("write"); write_report_atomic(&tmp, &report).expect("write");
assert!(tmp.is_file()); assert!(tmp.is_file());
let _ = fs::remove_file(tmp); let _ = fs::remove_file(tmp);
} }
struct TestNresEntry<'a> {
name: &'a str,
type_id: u32,
payload: &'a [u8],
}
fn build_nres(entries: &[TestNresEntry<'_>]) -> Vec<u8> {
let mut out = vec![0; 16];
let mut offsets = Vec::with_capacity(entries.len());
for entry in entries {
offsets.push(u32::try_from(out.len()).expect("offset"));
out.extend_from_slice(entry.payload);
let padding = (8 - (out.len() % 8)) % 8;
out.resize(out.len() + padding, 0);
}
let mut order: Vec<usize> = (0..entries.len()).collect();
order.sort_by(|left, right| {
entries[*left]
.name
.as_bytes()
.cmp(entries[*right].name.as_bytes())
});
for (index, entry) in entries.iter().enumerate() {
push_u32(&mut out, entry.type_id);
push_u32(&mut out, 0);
push_u32(&mut out, 0);
push_u32(
&mut out,
u32::try_from(entry.payload.len()).expect("payload size"),
);
push_u32(&mut out, 0);
let mut name = [0; 36];
let name_bytes = entry.name.as_bytes();
name[..name_bytes.len()].copy_from_slice(name_bytes);
out.extend_from_slice(&name);
push_u32(&mut out, offsets[index]);
push_u32(&mut out, u32::try_from(order[index]).expect("sort index"));
}
out[0..4].copy_from_slice(b"NRes");
out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
out[8..12].copy_from_slice(&u32::try_from(entries.len()).expect("count").to_le_bytes());
let total_size = u32::try_from(out.len()).expect("total size");
out[12..16].copy_from_slice(&total_size.to_le_bytes());
out
}
fn push_u32(out: &mut Vec<u8>, value: u32) {
out.extend_from_slice(&value.to_le_bytes());
}
fn temp_dir(name: &str) -> PathBuf { fn temp_dir(name: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!( let path = std::env::temp_dir().join(format!(
"fparkan-corpus-{name}-{}", "fparkan-corpus-{name}-{}",
@@ -671,10 +886,24 @@ mod tests {
} }
fn testdata_root(part: &str) -> PathBuf { fn testdata_root(part: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")) licensed_root(part)
.join("../..") }
.join("testdata")
.join(part) fn licensed_root(part: &str) -> PathBuf {
let variable = match part {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {part}"),
};
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
} }
fn assert_discovered_paths_stay_under_root(part: &str) { fn assert_discovered_paths_stay_under_root(part: &str) {
+31 -19
View File
@@ -1,5 +1,10 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
//! FXID effect contracts. //! FXID effect contracts.
//!
//! FXID decoding and command framing are implemented as compatibility
//! contracts. The create/update/emit lifecycle below is a deterministic
//! reference stub until opcode timing, gates, RNG, and command-body semantics
//! are backed by runtime-captured evidence.
use fparkan_binary::{Cursor, DecodeError}; use fparkan_binary::{Cursor, DecodeError};
use std::sync::Arc; use std::sync::Arc;
@@ -121,7 +126,7 @@ impl Default for Transform {
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct GameTime(pub u64); pub struct GameTime(pub u64);
/// FX runtime state. /// FX reference-stub runtime state.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct FxState { pub struct FxState {
/// Instance id. /// Instance id.
@@ -322,12 +327,12 @@ pub fn decode_fxid(bytes: Arc<[u8]>) -> Result<FxDocument, FxError> {
}) })
} }
/// Creates an FX instance. /// Creates a deterministic FX reference-stub instance.
/// ///
/// # Errors /// # Errors
/// ///
/// Currently returns [`FxError`] only for future resource/lifecycle validation /// Currently returns [`FxError`] only for future resource/lifecycle validation
/// hooks; creation is deterministic for a decoded document. /// hooks. This function does not claim original runtime parity.
pub fn create_instance( pub fn create_instance(
document: Arc<FxDocument>, document: Arc<FxDocument>,
seed: FxSeed, seed: FxSeed,
@@ -344,21 +349,21 @@ pub fn create_instance(
}) })
} }
/// Updates FX simulation time without emitting side effects. /// Updates reference-stub FX simulation time without emitting side effects.
/// ///
/// # Errors /// # Errors
/// ///
/// Reserved for future runtime validation. /// Reserved for future runtime-captured compatibility validation.
pub fn update(state: &mut FxState, time: GameTime) -> Result<(), FxError> { pub fn update(state: &mut FxState, time: GameTime) -> Result<(), FxError> {
state.time = time; state.time = time;
Ok(()) Ok(())
} }
/// Emits active commands without advancing state. /// Emits reference-stub active commands without advancing state.
/// ///
/// # Errors /// # Errors
/// ///
/// Reserved for future resource/runtime validation. /// Reserved for future resource/runtime-captured compatibility validation.
pub fn emit(state: &FxState, out: &mut Vec<FxEmission>) -> Result<(), FxError> { pub fn emit(state: &FxState, out: &mut Vec<FxEmission>) -> Result<(), FxError> {
if state.lifecycle != FxLifecycle::Running { if state.lifecycle != FxLifecycle::Running {
return Ok(()); return Ok(());
@@ -838,11 +843,10 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_fxid_exact_eof_and_distribution() { fn licensed_corpus_fxid_exact_eof_and_distribution() {
for (corpus, expected_count) in [("IS", 923_usize), ("IS2", 1065_usize)] { for (corpus, expected_count) in [("IS", 923_usize), ("IS2", 1065_usize)] {
let Some(root) = corpus_root(corpus) else { let root = corpus_root(corpus);
continue;
};
let mut count = 0usize; let mut count = 0usize;
let mut opcodes = BTreeMap::<FxOpcode, usize>::new(); let mut opcodes = BTreeMap::<FxOpcode, usize>::new();
let mut time_modes = BTreeMap::<u32, usize>::new(); let mut time_modes = BTreeMap::<u32, usize>::new();
@@ -886,14 +890,13 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_fxid_emission_captures_are_approved() { fn licensed_corpus_fxid_emission_captures_are_approved() {
for (corpus, expected_count, expected_emitting, expected_hash) in [ for (corpus, expected_count, expected_emitting, expected_hash) in [
("IS", 923_usize, 467_usize, 10_553_431_922_547_057_702_u64), ("IS", 923_usize, 467_usize, 10_553_431_922_547_057_702_u64),
("IS2", 1065_usize, 532_usize, 9_217_284_592_334_143_531_u64), ("IS2", 1065_usize, 532_usize, 9_217_284_592_334_143_531_u64),
] { ] {
let Some(root) = corpus_root(corpus) else { let root = corpus_root(corpus);
continue;
};
let mut count = 0usize; let mut count = 0usize;
let mut emitting = 0usize; let mut emitting = 0usize;
let mut hash = FNV_OFFSET; let mut hash = FNV_OFFSET;
@@ -985,12 +988,21 @@ mod tests {
dst[..len].copy_from_slice(&src[..len]); dst[..len].copy_from_slice(&src[..len]);
} }
fn corpus_root(name: &str) -> Option<PathBuf> { fn corpus_root(name: &str) -> PathBuf {
let root = Path::new(env!("CARGO_MANIFEST_DIR")) let variable = match name {
.join("../..") "IS" => "FPARKAN_CORPUS_PART1_ROOT",
.join("testdata") "IS2" => "FPARKAN_CORPUS_PART2_ROOT",
.join(name); _ => panic!("unknown licensed corpus part: {name}"),
root.is_dir().then_some(root) };
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
} }
fn files_under(root: &Path) -> Vec<PathBuf> { fn files_under(root: &Path) -> Vec<PathBuf> {
+57 -18
View File
@@ -417,15 +417,8 @@ pub fn resolve_material(
{ {
return Ok(resolved); return Ok(resolved);
} }
if let Some(first) = table.entries.first() { if let Some(resolved) = load_first_material_entry(repository, archive)? {
if let Some(resolved) = load_material_entry( return Ok(resolved);
repository,
archive,
&first.material,
MaterialFallback::FirstEntry,
)? {
return Ok(resolved);
}
} }
Err(MaterialError::MissingMaterial( Err(MaterialError::MissingMaterial(
String::from_utf8_lossy(&entry.material.0).into_owned(), String::from_utf8_lossy(&entry.material.0).into_owned(),
@@ -610,6 +603,26 @@ fn load_material_entry(
})) }))
} }
fn load_first_material_entry(
repository: &dyn ResourceRepository,
archive: fparkan_resource::ArchiveId,
) -> Result<Option<ResolvedMaterial>, MaterialError> {
let Some(handle) = repository.first_entry(archive)? else {
return Ok(None);
};
let info = repository.entry_info(handle)?;
if info.key.type_id != Some(MAT0_KIND) {
return Ok(None);
}
let bytes = repository.read(handle)?.into_owned();
let document = decode_mat0(&bytes, info.attr2)?;
Ok(Some(ResolvedMaterial {
name: info.key.name,
fallback: MaterialFallback::FirstEntry,
document,
}))
}
fn parse_lightmaps(lines: &[&str]) -> Result<Vec<LightmapEntry>, MaterialError> { fn parse_lightmaps(lines: &[&str]) -> Result<Vec<LightmapEntry>, MaterialError> {
if lines.is_empty() || lines.iter().all(|line| line.trim().is_empty()) { if lines.is_empty() || lines.iter().all(|line| line.trim().is_empty()) {
return Ok(Vec::new()); return Ok(Vec::new());
@@ -926,6 +939,24 @@ mod tests {
assert_eq!(resolved.fallback, MaterialFallback::FirstEntry); assert_eq!(resolved.fallback, MaterialFallback::FirstEntry);
} }
#[test]
fn resolve_material_first_entry_uses_material_archive_not_wear_row_zero() {
let repo = material_repo(&[
material_entry(b"MAT_ARCHIVE_FIRST", &mat0_with_texture(b"TEX_ARCHIVE")),
material_entry(b"MAT_WEAR_FIRST", &mat0_with_texture(b"TEX_WEAR")),
]);
let table = decode_wear(b"2\n0 MAT_WEAR_FIRST\n1 MISSING\n").expect("wear");
let resolved = resolve_material(&repo, &table, 1).expect("resolved");
assert_eq!(resolved.name.0, b"MAT_ARCHIVE_FIRST");
assert_eq!(resolved.fallback, MaterialFallback::FirstEntry);
assert_eq!(
resolved.document.primary_texture().expect("texture").0,
b"TEX_ARCHIVE"
);
}
#[test] #[test]
fn resolve_material_empty_texture_means_untextured() { fn resolve_material_empty_texture_means_untextured() {
let repo = material_repo(&[material_entry(b"MAT_EMPTY", &mat0_with_texture(b""))]); let repo = material_repo(&[material_entry(b"MAT_EMPTY", &mat0_with_texture(b""))]);
@@ -1092,14 +1123,13 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_mat0_and_wear_parse() { fn licensed_corpus_mat0_and_wear_parse() {
for (corpus, expected_mat0, expected_archive_wear, expected_standalone_wear) in [ for (corpus, expected_mat0, expected_archive_wear, expected_standalone_wear) in [
("IS", 905_usize, 439_usize, 95_usize), ("IS", 905_usize, 439_usize, 95_usize),
("IS2", 1127_usize, 515_usize, 95_usize), ("IS2", 1127_usize, 515_usize, 95_usize),
] { ] {
let Some(root) = corpus_root(corpus) else { let root = corpus_root(corpus);
continue;
};
let mut mat0_count = 0usize; let mut mat0_count = 0usize;
let mut archive_wear_count = 0usize; let mut archive_wear_count = 0usize;
let mut standalone_wear_count = 0usize; let mut standalone_wear_count = 0usize;
@@ -1153,12 +1183,21 @@ mod tests {
} }
} }
fn corpus_root(name: &str) -> Option<PathBuf> { fn corpus_root(name: &str) -> PathBuf {
let root = Path::new(env!("CARGO_MANIFEST_DIR")) let variable = match name {
.join("../..") "IS" => "FPARKAN_CORPUS_PART1_ROOT",
.join("testdata") "IS2" => "FPARKAN_CORPUS_PART2_ROOT",
.join(name); _ => panic!("unknown licensed corpus part: {name}"),
root.is_dir().then_some(root) };
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
} }
fn files_under(root: &Path) -> Vec<PathBuf> { fn files_under(root: &Path) -> Vec<PathBuf> {
+17 -9
View File
@@ -979,6 +979,7 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_tma_validate() { fn licensed_corpus_tma_validate() {
for ( for (
corpus, corpus,
@@ -991,9 +992,7 @@ mod tests {
("IS", 29_usize, 34_usize, 101_usize, 864_usize, 28_usize), ("IS", 29_usize, 34_usize, 101_usize, 864_usize, 28_usize),
("IS2", 31_usize, 61_usize, 91_usize, 885_usize, 41_usize), ("IS2", 31_usize, 61_usize, 91_usize, 885_usize, 41_usize),
] { ] {
let Some(root) = corpus_root(corpus) else { let root = corpus_root(corpus);
continue;
};
let mut files = 0usize; let mut files = 0usize;
let mut paths = 0usize; let mut paths = 0usize;
let mut clans = 0usize; let mut clans = 0usize;
@@ -1142,12 +1141,21 @@ mod tests {
} }
} }
fn corpus_root(name: &str) -> Option<PathBuf> { fn corpus_root(name: &str) -> PathBuf {
let root = Path::new(env!("CARGO_MANIFEST_DIR")) let variable = match name {
.join("../..") "IS" => "FPARKAN_CORPUS_PART1_ROOT",
.join("testdata") "IS2" => "FPARKAN_CORPUS_PART2_ROOT",
.join(name); _ => panic!("unknown licensed corpus part: {name}"),
root.is_dir().then_some(root) };
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
} }
fn files_under(root: &Path) -> Vec<PathBuf> { fn files_under(root: &Path) -> Vec<PathBuf> {
+19 -12
View File
@@ -1236,11 +1236,10 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_msh_assets_validate() { fn licensed_corpus_msh_assets_validate() {
for (corpus, expected) in [("IS", 435_usize), ("IS2", 511_usize)] { for (corpus, expected) in [("IS", 435_usize), ("IS2", 511_usize)] {
let Some(root) = corpus_root(corpus) else { let root = corpus_root(corpus);
continue;
};
let mut count = 0usize; let mut count = 0usize;
for path in files_under(&root) { for path in files_under(&root) {
let Ok(bytes) = std::fs::read(&path) else { let Ok(bytes) = std::fs::read(&path) else {
@@ -1279,6 +1278,7 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_animation_streams_sample_approved_pose_captures() { fn licensed_corpus_animation_streams_sample_approved_pose_captures() {
for ( for (
corpus, corpus,
@@ -1302,9 +1302,7 @@ mod tests {
13_040_438_305_408_523_893_u64, 13_040_438_305_408_523_893_u64,
), ),
] { ] {
let Some(root) = corpus_root(corpus) else { let root = corpus_root(corpus);
continue;
};
let mut models = 0usize; let mut models = 0usize;
let mut animated_models = 0usize; let mut animated_models = 0usize;
let mut node_samples = 0usize; let mut node_samples = 0usize;
@@ -1723,12 +1721,21 @@ mod tests {
name.len() >= 4 && name[name.len() - 4..].eq_ignore_ascii_case(b".msh") name.len() >= 4 && name[name.len() - 4..].eq_ignore_ascii_case(b".msh")
} }
fn corpus_root(name: &str) -> Option<PathBuf> { fn corpus_root(name: &str) -> PathBuf {
let root = Path::new(env!("CARGO_MANIFEST_DIR")) let variable = match name {
.join("../..") "IS" => "FPARKAN_CORPUS_PART1_ROOT",
.join("testdata") "IS2" => "FPARKAN_CORPUS_PART2_ROOT",
.join(name); _ => panic!("unknown licensed corpus part: {name}"),
root.is_dir().then_some(root) };
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
} }
fn files_under(root: &Path) -> Vec<PathBuf> { fn files_under(root: &Path) -> Vec<PathBuf> {
+217 -50
View File
@@ -26,7 +26,7 @@ pub enum ReadProfile {
/// Write profile. /// Write profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WriteProfile { pub enum WriteProfile {
/// Return the original byte image when no edit model is active. /// Preserve the original byte image or unindexed data-region bytes.
Lossless, Lossless,
/// Repack active payloads and rebuild the lookup table. /// Repack active payloads and rebuild the lookup table.
CanonicalCompact, CanonicalCompact,
@@ -102,6 +102,7 @@ pub struct NresDocument {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct NresEditor { pub struct NresEditor {
entries: Vec<EditableEntry>, entries: Vec<EditableEntry>,
layout: Vec<EditableSegment>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -114,6 +115,12 @@ struct EditableEntry {
payload: Vec<u8>, payload: Vec<u8>,
} }
#[derive(Clone, Debug)]
enum EditableSegment {
Entry(usize),
Preserved(Vec<u8>),
}
/// `NRes` parse or write error. /// `NRes` parse or write error.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum NresError { pub enum NresError {
@@ -506,7 +513,8 @@ impl NresEditor {
payload: document.payload(entry.id())?.to_vec(), payload: document.payload(entry.id())?.to_vec(),
}); });
} }
Ok(Self { entries }) let layout = build_edit_layout(document)?;
Ok(Self { entries, layout })
} }
/// Replaces an entry payload. /// Replaces an entry payload.
@@ -537,13 +545,57 @@ impl NresEditor {
Ok(()) Ok(())
} }
/// Encodes the edited document in canonical compact form. /// Encodes the edited document while preserving unindexed bytes.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns [`NresError`] when offsets or sizes exceed the on-disk `u32` /// Returns [`NresError`] when offsets or sizes exceed the on-disk `u32`
/// representation. /// representation.
pub fn encode(&self) -> Result<Vec<u8>, NresError> { pub fn encode(&self) -> Result<Vec<u8>, NresError> {
self.encode_with_profile(WriteProfile::Lossless)
}
/// Encodes the edited document with an explicit write profile.
///
/// # Errors
///
/// Returns [`NresError`] when offsets or sizes exceed the on-disk `u32`
/// representation.
pub fn encode_with_profile(&self, profile: WriteProfile) -> Result<Vec<u8>, NresError> {
match profile {
WriteProfile::Lossless => self.encode_preserving_layout(),
WriteProfile::CanonicalCompact => self.encode_canonical_compact(),
}
}
fn encode_preserving_layout(&self) -> Result<Vec<u8>, NresError> {
let mut out = vec![0; HEADER_LEN];
let mut offsets = vec![0; self.entries.len()];
let mut sizes = vec![0; self.entries.len()];
for segment in &self.layout {
match segment {
EditableSegment::Entry(index) => {
let entry = self
.entries
.get(*index)
.ok_or(DecodeError::IntegerOverflow)?;
offsets[*index] = checked_u32_len(out.len())?;
sizes[*index] = checked_u32_len(entry.payload.len())?;
out.extend_from_slice(&entry.payload);
}
EditableSegment::Preserved(bytes) => {
out.len()
.checked_add(bytes.len())
.ok_or(DecodeError::IntegerOverflow)?;
out.extend_from_slice(bytes);
}
}
}
write_edit_archive_header_and_directory(&mut out, &self.entries, &offsets, &sizes)?;
Ok(out)
}
fn encode_canonical_compact(&self) -> Result<Vec<u8>, NresError> {
let mut out = vec![0; HEADER_LEN]; let mut out = vec![0; HEADER_LEN];
let mut offsets = Vec::with_capacity(self.entries.len()); let mut offsets = Vec::with_capacity(self.entries.len());
let mut sizes = Vec::with_capacity(self.entries.len()); let mut sizes = Vec::with_capacity(self.entries.len());
@@ -560,23 +612,7 @@ impl NresEditor {
); );
} }
let sort_order = build_edit_sort_order(&self.entries); write_edit_archive_header_and_directory(&mut out, &self.entries, &offsets, &sizes)?;
for (index, entry) in self.entries.iter().enumerate() {
push_u32(&mut out, entry.type_id);
push_u32(&mut out, entry.attr1);
push_u32(&mut out, entry.attr2);
push_u32(&mut out, sizes[index]);
push_u32(&mut out, entry.attr3);
out.extend_from_slice(&entry.name_raw);
push_u32(&mut out, offsets[index]);
push_u32(&mut out, checked_u32_len(sort_order[index])?);
}
let total_size = checked_u32_len(out.len())?;
out[0..4].copy_from_slice(b"NRes");
out[4..8].copy_from_slice(&VERSION_0100.to_le_bytes());
out[8..12].copy_from_slice(&checked_u32_len(self.entries.len())?.to_le_bytes());
out[12..16].copy_from_slice(&total_size.to_le_bytes());
Ok(out) Ok(out)
} }
@@ -899,6 +935,76 @@ fn build_edit_sort_order(entries: &[EditableEntry]) -> Vec<usize> {
order order
} }
fn build_edit_layout(document: &NresDocument) -> Result<Vec<EditableSegment>, NresError> {
let mut ranges: Vec<(Range<usize>, usize)> = document
.entries
.iter()
.enumerate()
.map(|(index, entry)| (entry.data_range.clone(), index))
.collect();
ranges.sort_by(|(left, _), (right, _)| {
left.start
.cmp(&right.start)
.then_with(|| left.end.cmp(&right.end))
});
let mut cursor = HEADER_LEN;
let directory_offset = usize::try_from(document.header.directory_offset)
.map_err(|_| DecodeError::IntegerOverflow)?;
let mut layout = Vec::new();
for (range, index) in ranges {
if cursor < range.start {
layout.push(EditableSegment::Preserved(
document.bytes[cursor..range.start].to_vec(),
));
}
layout.push(EditableSegment::Entry(index));
cursor = cursor.max(range.end);
}
if cursor < directory_offset {
layout.push(EditableSegment::Preserved(
document.bytes[cursor..directory_offset].to_vec(),
));
}
Ok(layout)
}
fn write_edit_archive_header_and_directory(
out: &mut Vec<u8>,
entries: &[EditableEntry],
offsets: &[u32],
sizes: &[u32],
) -> Result<(), NresError> {
if offsets.len() != entries.len() || sizes.len() != entries.len() {
return Err(DecodeError::IntegerOverflow.into());
}
let directory_len = ENTRY_LEN
.checked_mul(entries.len())
.ok_or(DecodeError::IntegerOverflow)?;
out.len()
.checked_add(directory_len)
.ok_or(DecodeError::IntegerOverflow)?;
let sort_order = build_edit_sort_order(entries);
for (index, entry) in entries.iter().enumerate() {
push_u32(out, entry.type_id);
push_u32(out, entry.attr1);
push_u32(out, entry.attr2);
push_u32(out, sizes[index]);
push_u32(out, entry.attr3);
out.extend_from_slice(&entry.name_raw);
push_u32(out, offsets[index]);
push_u32(out, checked_u32_len(sort_order[index])?);
}
let total_size = checked_u32_len(out.len())?;
out[0..4].copy_from_slice(b"NRes");
out[4..8].copy_from_slice(&VERSION_0100.to_le_bytes());
out[8..12].copy_from_slice(&checked_u32_len(entries.len())?.to_le_bytes());
out[12..16].copy_from_slice(&total_size.to_le_bytes());
Ok(())
}
fn editable_name_bytes(raw: &[u8; NAME_LEN]) -> &[u8] { fn editable_name_bytes(raw: &[u8; NAME_LEN]) -> &[u8] {
let len = name_len(raw).unwrap_or(NAME_LEN); let len = name_len(raw).unwrap_or(NAME_LEN);
&raw[..len] &raw[..len]
@@ -1414,7 +1520,7 @@ mod tests {
#[test] #[test]
fn preserves_nonzero_unindexed_region() { fn preserves_nonzero_unindexed_region() {
let mut bytes = build_archive(&[SyntheticEntry { let bytes = build_archive_with_nonzero_prefix_gap(&[SyntheticEntry {
type_id: 1, type_id: 1,
attr1: 0, attr1: 0,
attr2: 0, attr2: 0,
@@ -1422,18 +1528,6 @@ mod tests {
name: "payload", name: "payload",
payload: b"data", payload: b"data",
}]); }]);
let directory_offset = bytes.len() - ENTRY_LEN;
bytes.splice(HEADER_LEN..HEADER_LEN, [0xAA, 0xBB, 0xCC, 0xDD]);
let total = u32::try_from(bytes.len()).expect("total size");
bytes[12..16].copy_from_slice(&total.to_le_bytes());
let offset = u32::from_le_bytes(
bytes[directory_offset + 4 + 56..directory_offset + 4 + 60]
.try_into()
.expect("shifted offset"),
);
let shifted_directory_offset = directory_offset + 4;
bytes[shifted_directory_offset + 56..shifted_directory_offset + 60]
.copy_from_slice(&(offset + 4).to_le_bytes());
let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("nres"); let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("nres");
assert!(doc.has_nonzero_preserved_region()); assert!(doc.has_nonzero_preserved_region());
@@ -1443,7 +1537,7 @@ mod tests {
#[test] #[test]
fn canonical_compact_roundtrip_preserves_entry_semantics() { fn canonical_compact_roundtrip_preserves_entry_semantics() {
let mut bytes = build_archive(&[ let bytes = build_archive_with_nonzero_prefix_gap(&[
SyntheticEntry { SyntheticEntry {
type_id: 7, type_id: 7,
attr1: 10, attr1: 10,
@@ -1461,16 +1555,6 @@ mod tests {
payload: b"aaaa", payload: b"aaaa",
}, },
]); ]);
let directory_offset = bytes.len() - ENTRY_LEN * 2;
bytes.splice(HEADER_LEN..HEADER_LEN, [0xAA, 0xBB, 0xCC, 0xDD]);
let total = u32::try_from(bytes.len()).expect("total size");
bytes[12..16].copy_from_slice(&total.to_le_bytes());
for entry_index in 0..2 {
let field = directory_offset + 4 + entry_index * ENTRY_LEN + 56;
let offset =
u32::from_le_bytes(bytes[field..field + 4].try_into().expect("shifted offset"));
bytes[field..field + 4].copy_from_slice(&(offset + 4).to_le_bytes());
}
let original = decode(arc(bytes), ReadProfile::Strict).expect("original"); let original = decode(arc(bytes), ReadProfile::Strict).expect("original");
let compact = decode( let compact = decode(
@@ -1529,8 +1613,13 @@ mod tests {
editor editor
.set_payload(EntryId(0), b"replacement".to_vec()) .set_payload(EntryId(0), b"replacement".to_vec())
.expect("set payload"); .expect("set payload");
let edited = let edited = decode(
decode(arc(editor.encode().expect("encode")), ReadProfile::Strict).expect("edited"); arc(editor
.encode_with_profile(WriteProfile::CanonicalCompact)
.expect("encode")),
ReadProfile::Strict,
)
.expect("edited");
let first = edited.entry(EntryId(0)).expect("first"); let first = edited.entry(EntryId(0)).expect("first");
let second = edited.entry(EntryId(1)).expect("second"); let second = edited.entry(EntryId(1)).expect("second");
@@ -1545,6 +1634,64 @@ mod tests {
assert!(second.meta().data_offset > first.meta().data_offset + first.meta().data_size); assert!(second.meta().data_offset > first.meta().data_offset + first.meta().data_size);
} }
#[test]
fn editor_payload_update_preserves_nonzero_unindexed_region_by_default() {
let bytes = build_archive_with_nonzero_prefix_gap(&[
SyntheticEntry {
type_id: 1,
attr1: 0,
attr2: 0,
attr3: 0,
name: "first",
payload: b"one",
},
SyntheticEntry {
type_id: 2,
attr1: 0,
attr2: 0,
attr3: 0,
name: "second",
payload: b"two",
},
]);
let original = decode(arc(bytes), ReadProfile::Strict).expect("original");
let marker = original
.preserved_regions()
.iter()
.find(|region| !region.all_zero)
.expect("nonzero preserved region")
.range
.clone();
let marker = original.bytes[usize::try_from(marker.start).expect("start")
..usize::try_from(marker.end).expect("end")]
.to_vec();
let mut editor = original.editor().expect("editor");
editor
.set_payload(EntryId(0), b"replacement".to_vec())
.expect("set payload");
let edited_bytes = editor.encode().expect("encode");
let edited = decode(arc(edited_bytes.clone()), ReadProfile::Strict).expect("edited");
assert_eq!(
edited.payload(EntryId(0)).expect("first payload"),
b"replacement"
);
assert!(edited.has_nonzero_preserved_region());
assert!(edited_bytes
.windows(marker.len())
.any(|window| window == marker));
let compact = decode(
arc(editor
.encode_with_profile(WriteProfile::CanonicalCompact)
.expect("compact")),
ReadProfile::Strict,
)
.expect("compact");
assert!(!compact.has_nonzero_preserved_region());
}
#[test] #[test]
fn editor_rename_rebuilds_search_mapping() { fn editor_rename_rebuilds_search_mapping() {
let bytes = build_archive(&[ let bytes = build_archive(&[
@@ -1779,6 +1926,7 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpora_nres_roundtrip_gates() { fn licensed_corpora_nres_roundtrip_gates() {
let part1 = corpus_gate("IS", 120, 6_804).expect("part 1 NRes gate"); let part1 = corpus_gate("IS", 120, 6_804).expect("part 1 NRes gate");
let part2 = corpus_gate("IS2", 134, 8_171).expect("part 2 NRes gate"); let part2 = corpus_gate("IS2", 134, 8_171).expect("part 2 NRes gate");
@@ -1800,10 +1948,14 @@ mod tests {
expected_files: usize, expected_files: usize,
expected_entries: usize, expected_entries: usize,
) -> Result<CorpusGateResult, String> { ) -> Result<CorpusGateResult, String> {
let root = Path::new(env!("CARGO_MANIFEST_DIR")) let variable = match name {
.join("../..") "IS" => "FPARKAN_CORPUS_PART1_ROOT",
.join("testdata") "IS2" => "FPARKAN_CORPUS_PART2_ROOT",
.join(name); _ => return Err(format!("unknown licensed corpus part: {name}")),
};
let root = std::env::var_os(variable)
.map(PathBuf::from)
.ok_or_else(|| format!("{variable} is required for licensed corpus tests"))?;
if !root.is_dir() { if !root.is_dir() {
return Err(format!( return Err(format!(
"licensed corpus root is missing: {}", "licensed corpus root is missing: {}",
@@ -1929,6 +2081,21 @@ mod tests {
out out
} }
fn build_archive_with_nonzero_prefix_gap(entries: &[SyntheticEntry<'_>]) -> Vec<u8> {
let mut bytes = build_archive(entries);
let directory_offset = bytes.len() - ENTRY_LEN * entries.len();
bytes.splice(HEADER_LEN..HEADER_LEN, [0xAA, 0xBB, 0xCC, 0xDD]);
let total = u32::try_from(bytes.len()).expect("total size");
bytes[12..16].copy_from_slice(&total.to_le_bytes());
for entry_index in 0..entries.len() {
let field = directory_offset + 4 + entry_index * ENTRY_LEN + 56;
let offset =
u32::from_le_bytes(bytes[field..field + 4].try_into().expect("shifted offset"));
bytes[field..field + 4].copy_from_slice(&(offset + 4).to_le_bytes());
}
bytes
}
fn arc(bytes: Vec<u8>) -> Arc<[u8]> { fn arc(bytes: Vec<u8>) -> Arc<[u8]> {
Arc::from(bytes.into_boxed_slice()) Arc::from(bytes.into_boxed_slice())
} }
+46 -2
View File
@@ -97,7 +97,14 @@ pub enum PathError {
impl fmt::Display for PathError { impl fmt::Display for PathError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{self:?}") match self {
Self::Empty => write!(f, "path is empty"),
Self::EmbeddedNul => write!(f, "path contains an embedded NUL byte"),
Self::Absolute => write!(f, "path must be relative and cannot be absolute"),
Self::ParentTraversal => write!(f, "path attempts to traverse outside its root"),
Self::EscapesRoot => write!(f, "normalized path escapes the configured root"),
Self::InvalidUtf8 => write!(f, "path is not valid UTF-8 after normalization"),
}
} }
} }
@@ -110,7 +117,7 @@ impl std::error::Error for PathError {}
/// 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 is not valid UTF-8 after
/// legacy separator normalization. /// 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,11 +131,17 @@ pub fn normalize_relative(raw: &[u8], _policy: PathPolicy) -> Result<NormalizedP
let mut parts = Vec::new(); let mut parts = Vec::new();
for part in text.split(['/', '\\']) { for part in text.split(['/', '\\']) {
if part.is_empty() || part == "." { if part.is_empty() || part == "." {
if policy == PathPolicy::StrictLegacy {
return Err(PathError::ParentTraversal);
}
continue; continue;
} }
if part == ".." { if part == ".." {
return Err(PathError::ParentTraversal); return Err(PathError::ParentTraversal);
} }
if policy == PathPolicy::StrictLegacy && part.contains(':') {
return Err(PathError::Absolute);
}
parts.push(part); parts.push(part);
} }
if parts.is_empty() { if parts.is_empty() {
@@ -223,6 +236,37 @@ mod tests {
); );
} }
#[test]
fn path_error_display_is_actionable() {
assert_eq!(
PathError::ParentTraversal.to_string(),
"path attempts to traverse outside its root"
);
assert_eq!(
PathError::EmbeddedNul.to_string(),
"path contains an embedded NUL byte"
);
}
#[test]
fn strict_legacy_rejects_host_only_segments() {
assert_eq!(
normalize_relative(b"./DATA/MAPS", PathPolicy::StrictLegacy),
Err(PathError::ParentTraversal)
);
assert_eq!(
normalize_relative(b"DATA//MAPS", PathPolicy::StrictLegacy),
Err(PathError::ParentTraversal)
);
assert_eq!(
normalize_relative(b"DATA/stream:name", PathPolicy::StrictLegacy),
Err(PathError::Absolute)
);
let host = normalize_relative(b"./DATA//MAPS", PathPolicy::HostCompatible).expect("host");
assert_eq!(host.as_str(), "DATA/MAPS");
}
#[test] #[test]
fn join_under_keeps_normalized_path_below_root() { fn join_under_keeps_normalized_path_below_root() {
let rel = normalize_relative(b"DATA/MAPS/Land.map", PathPolicy::StrictLegacy) let rel = normalize_relative(b"DATA/MAPS/Land.map", PathPolicy::StrictLegacy)
+23 -10
View File
@@ -1768,8 +1768,9 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn resolves_known_part1_registry_cases() { fn resolves_known_part1_registry_cases() {
let root = corpus_root("IS").expect("part 1 root"); let root = corpus_root("IS");
let vfs = Arc::new(DirectoryVfs::new(&root)); let vfs = Arc::new(DirectoryVfs::new(&root));
let repo = CachedResourceRepository::new(vfs.clone()); let repo = CachedResourceRepository::new(vfs.clone());
let cases = [ let cases = [
@@ -1799,9 +1800,10 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn resolves_some_registry_entries_in_both_corpora() { fn resolves_some_registry_entries_in_both_corpora() {
for corpus in ["IS", "IS2"] { for corpus in ["IS", "IS2"] {
let root = corpus_root(corpus).expect("corpus root"); let root = corpus_root(corpus);
let objects = std::fs::read(root.join("objects.rlb")).expect("objects.rlb"); let objects = std::fs::read(root.join("objects.rlb")).expect("objects.rlb");
let document = fparkan_nres::decode( let document = fparkan_nres::decode(
Arc::from(objects.into_boxed_slice()), Arc::from(objects.into_boxed_slice()),
@@ -1826,10 +1828,11 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpora_unit_dat_parse_counts() { fn licensed_corpora_unit_dat_parse_counts() {
let cases = [("IS", 425, 5_219), ("IS2", 676, 8_145)]; let cases = [("IS", 425, 5_219), ("IS2", 676, 8_145)];
for (corpus, expected_files, expected_records) in cases { for (corpus, expected_files, expected_records) in cases {
let root = corpus_root(corpus).expect("corpus root"); let root = corpus_root(corpus);
let mut dat_paths = Vec::new(); let mut dat_paths = Vec::new();
collect_unit_dat_files(&root, &mut dat_paths); collect_unit_dat_files(&root, &mut dat_paths);
dat_paths.sort(); dat_paths.sort();
@@ -1859,9 +1862,10 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpora_registry_payloads_are_record_aligned() { fn licensed_corpora_registry_payloads_are_record_aligned() {
for corpus in ["IS", "IS2"] { for corpus in ["IS", "IS2"] {
let root = corpus_root(corpus).expect("corpus root"); let root = corpus_root(corpus);
let objects = std::fs::read(root.join("objects.rlb")).expect("objects.rlb"); let objects = std::fs::read(root.join("objects.rlb")).expect("objects.rlb");
let document = fparkan_nres::decode( let document = fparkan_nres::decode(
Arc::from(objects.into_boxed_slice()), Arc::from(objects.into_boxed_slice()),
@@ -1907,12 +1911,21 @@ mod tests {
} }
} }
fn corpus_root(name: &str) -> Option<std::path::PathBuf> { fn corpus_root(name: &str) -> std::path::PathBuf {
let root = Path::new(env!("CARGO_MANIFEST_DIR")) let variable = match name {
.join("../..") "IS" => "FPARKAN_CORPUS_PART1_ROOT",
.join("testdata") "IS2" => "FPARKAN_CORPUS_PART2_ROOT",
.join(name); _ => panic!("unknown licensed corpus part: {name}"),
root.is_dir().then_some(root) };
let root = std::env::var_os(variable)
.map(std::path::PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
} }
fn generated_acyclic_graph( fn generated_acyclic_graph(
+466 -18
View File
@@ -139,6 +139,15 @@ pub struct RenderCommandList {
pub commands: Vec<RenderCommand>, pub commands: Vec<RenderCommand>,
} }
/// Optional render command validation limits.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct RenderValidationLimits {
/// Exclusive upper bound for GPU mesh ids.
pub mesh_count: Option<u64>,
/// Exclusive upper bound for index ranges.
pub index_count: Option<u32>,
}
/// Frame output. /// Frame output.
#[derive(Clone, Debug, Default, Eq, PartialEq)] #[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct FrameOutput; pub struct FrameOutput;
@@ -148,6 +157,13 @@ pub struct FrameOutput;
pub enum RenderError { pub enum RenderError {
/// Invalid range. /// Invalid range.
InvalidRange, InvalidRange,
/// Invalid command stream framing or ordering.
InvalidCommandStream {
/// Command index.
index: usize,
/// Contextual error message.
message: &'static str,
},
/// Invalid draw range with command-generation context. /// Invalid draw range with command-generation context.
InvalidDrawRange { InvalidDrawRange {
/// Draw id. /// Draw id.
@@ -159,6 +175,49 @@ pub enum RenderError {
/// Range count. /// Range count.
count: u32, count: u32,
}, },
/// Index range arithmetic overflow.
IndexRangeOverflow {
/// Draw id.
draw_id: DrawId,
/// Range start.
start: u32,
/// Range count.
count: u32,
},
/// Index range exceeds validation limits.
IndexRangeOutOfBounds {
/// Draw id.
draw_id: DrawId,
/// Exclusive index limit.
index_count: u32,
/// Range end.
end: u32,
},
/// Mesh id exceeds validation limits.
MeshOutOfBounds {
/// Draw id.
draw_id: DrawId,
/// Mesh id.
mesh: GpuMeshId,
/// Exclusive mesh limit.
mesh_count: u64,
},
/// Draw transform contains a non-finite value.
NonFiniteTransform {
/// Draw id.
draw_id: DrawId,
/// Matrix element index.
element: usize,
},
/// Draw commands are not ordered by phase, stable order and draw id.
PhaseOrderViolation {
/// Draw id.
draw_id: DrawId,
/// Previous phase.
previous: RenderPhase,
/// Current phase.
current: RenderPhase,
},
/// A batch material index did not resolve through the material table. /// A batch material index did not resolve through the material table.
MaterialIndexOutOfBounds { MaterialIndexOutOfBounds {
/// Draw id. /// Draw id.
@@ -172,7 +231,75 @@ pub enum RenderError {
impl std::fmt::Display for RenderError { impl std::fmt::Display for RenderError {
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::InvalidRange => write!(f, "render command contains an empty index range"),
Self::InvalidCommandStream { index, message } => {
write!(
f,
"render command stream is invalid at command {index}: {message}"
)
}
Self::InvalidDrawRange {
draw_id,
stable_order,
start,
count,
} => write!(
f,
"draw {} has invalid index range start={} count={} at stable order {}",
draw_id.0, start, count, stable_order
),
Self::IndexRangeOverflow {
draw_id,
start,
count,
} => write!(
f,
"draw {} index range overflows start={} count={}",
draw_id.0, start, count
),
Self::IndexRangeOutOfBounds {
draw_id,
index_count,
end,
} => write!(
f,
"draw {} index range ends at {} but mesh has {} indices",
draw_id.0, end, index_count
),
Self::MeshOutOfBounds {
draw_id,
mesh,
mesh_count,
} => write!(
f,
"draw {} references mesh {} but only {} meshes are available",
draw_id.0, mesh.0, mesh_count
),
Self::NonFiniteTransform { draw_id, element } => write!(
f,
"draw {} has non-finite transform element {}",
draw_id.0, element
),
Self::PhaseOrderViolation {
draw_id,
previous,
current,
} => write!(
f,
"draw {} phase order regressed from {:?} to {:?}",
draw_id.0, previous, current
),
Self::MaterialIndexOutOfBounds {
draw_id,
material_index,
material_count,
} => write!(
f,
"draw {} references material index {} but only {} material slots are available",
draw_id.0, material_index, material_count
),
}
} }
} }
@@ -206,6 +333,8 @@ pub fn build_commands(
count: draw.range.count, count: draw.range.count,
}); });
} }
validate_index_range(draw.id, draw.range)?;
validate_transform(draw.id, &draw.transform)?;
let material = draw let material = draw
.material_slots .material_slots
.get(usize::from(draw.material_index)) .get(usize::from(draw.material_index))
@@ -247,7 +376,7 @@ pub struct NullBackend;
impl RenderBackend for NullBackend { impl RenderBackend for NullBackend {
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> { fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
validate_commands(commands)?; validate_command_list(commands)?;
Ok(FrameOutput) Ok(FrameOutput)
} }
} }
@@ -291,7 +420,7 @@ impl RenderBackend for RecordingBackend {
/// ///
/// Returns [`RenderError`] when a draw command contains an invalid index range. /// Returns [`RenderError`] when a draw command contains an invalid index range.
pub fn canonical_capture(commands: &RenderCommandList) -> Result<Vec<u8>, RenderError> { pub fn canonical_capture(commands: &RenderCommandList) -> Result<Vec<u8>, RenderError> {
validate_commands(commands)?; validate_command_list(commands)?;
let mut out = Vec::new(); let mut out = Vec::new();
for command in &commands.commands { for command in &commands.commands {
match command { match command {
@@ -311,12 +440,132 @@ pub fn canonical_capture(commands: &RenderCommandList) -> Result<Vec<u8>, Render
Ok(out) Ok(out)
} }
fn validate_commands(commands: &RenderCommandList) -> Result<(), RenderError> { /// Validates a render command list without backend-specific resource limits.
for command in &commands.commands { ///
if let RenderCommand::Draw(draw) = command { /// # Errors
if draw.range.count == 0 { ///
return Err(RenderError::InvalidRange); /// Returns [`RenderError`] when framing, ordering or draw data is invalid.
pub fn validate_command_list(commands: &RenderCommandList) -> Result<(), RenderError> {
validate_command_list_with_limits(commands, RenderValidationLimits::default())
}
/// Validates a render command list with optional backend resource limits.
///
/// # Errors
///
/// Returns [`RenderError`] when framing, ordering, draw data or resource bounds
/// are invalid.
pub fn validate_command_list_with_limits(
commands: &RenderCommandList,
limits: RenderValidationLimits,
) -> Result<(), RenderError> {
let Some(first) = commands.commands.first() else {
return Err(RenderError::InvalidCommandStream {
index: 0,
message: "empty command list",
});
};
if !matches!(first, RenderCommand::BeginFrame) {
return Err(RenderError::InvalidCommandStream {
index: 0,
message: "first command must be BeginFrame",
});
}
if commands.commands.len() < 2 {
return Err(RenderError::InvalidCommandStream {
index: 0,
message: "frame must end with EndFrame",
});
}
let end_index = commands.commands.len() - 1;
if !matches!(commands.commands[end_index], RenderCommand::EndFrame) {
return Err(RenderError::InvalidCommandStream {
index: end_index,
message: "last command must be EndFrame",
});
}
let mut previous_key: Option<(RenderPhase, u64, DrawId)> = None;
for (index, command) in commands.commands.iter().enumerate() {
match command {
RenderCommand::BeginFrame if index == 0 => {}
RenderCommand::BeginFrame => {
return Err(RenderError::InvalidCommandStream {
index,
message: "nested BeginFrame is not allowed",
});
} }
RenderCommand::EndFrame if index == end_index => {}
RenderCommand::EndFrame => {
return Err(RenderError::InvalidCommandStream {
index,
message: "EndFrame before final command is not allowed",
});
}
RenderCommand::Draw(draw) => {
validate_draw_command(draw, limits)?;
let key = (draw.phase, draw.stable_order, draw.id);
if let Some(previous) = previous_key {
if key < previous {
return Err(RenderError::PhaseOrderViolation {
draw_id: draw.id,
previous: previous.0,
current: draw.phase,
});
}
}
previous_key = Some(key);
}
}
}
Ok(())
}
fn validate_draw_command(
draw: &DrawCommand,
limits: RenderValidationLimits,
) -> Result<(), RenderError> {
if draw.range.count == 0 {
return Err(RenderError::InvalidRange);
}
let end = validate_index_range(draw.id, draw.range)?;
validate_transform(draw.id, &draw.transform)?;
if let Some(mesh_count) = limits.mesh_count {
if draw.mesh.0 >= mesh_count {
return Err(RenderError::MeshOutOfBounds {
draw_id: draw.id,
mesh: draw.mesh,
mesh_count,
});
}
}
if let Some(index_count) = limits.index_count {
if end > index_count {
return Err(RenderError::IndexRangeOutOfBounds {
draw_id: draw.id,
index_count,
end,
});
}
}
Ok(())
}
fn validate_index_range(draw_id: DrawId, range: IndexRange) -> Result<u32, RenderError> {
range
.start
.checked_add(range.count)
.ok_or(RenderError::IndexRangeOverflow {
draw_id,
start: range.start,
count: range.count,
})
}
fn validate_transform(draw_id: DrawId, transform: &[f32; 16]) -> Result<(), RenderError> {
for (element, value) in transform.iter().enumerate() {
if !value.is_finite() {
return Err(RenderError::NonFiniteTransform { draw_id, element });
} }
} }
Ok(()) Ok(())
@@ -379,16 +628,20 @@ mod tests {
fn null_backend_validates_without_capture() { fn null_backend_validates_without_capture() {
let mut backend = NullBackend; let mut backend = NullBackend;
let invalid = RenderCommandList { let invalid = RenderCommandList {
commands: vec![RenderCommand::Draw(DrawCommand { commands: vec![
id: DrawId(1), RenderCommand::BeginFrame,
phase: RenderPhase::Opaque, RenderCommand::Draw(DrawCommand {
object_id: None, id: DrawId(1),
mesh: GpuMeshId(2), phase: RenderPhase::Opaque,
material: GpuMaterialId(3), object_id: None,
transform: [0.0; 16], mesh: GpuMeshId(2),
range: IndexRange { start: 0, count: 0 }, material: GpuMaterialId(3),
stable_order: 4, transform: [0.0; 16],
})], range: IndexRange { start: 0, count: 0 },
stable_order: 4,
}),
RenderCommand::EndFrame,
],
}; };
assert!(matches!( assert!(matches!(
@@ -534,6 +787,201 @@ mod tests {
)); ));
} }
#[test]
fn command_validation_rejects_bad_frame_framing() {
let missing_begin = RenderCommandList {
commands: vec![RenderCommand::EndFrame],
};
assert!(matches!(
validate_command_list(&missing_begin),
Err(RenderError::InvalidCommandStream {
index: 0,
message: "first command must be BeginFrame"
})
));
let nested = RenderCommandList {
commands: vec![
RenderCommand::BeginFrame,
RenderCommand::BeginFrame,
RenderCommand::EndFrame,
],
};
assert!(matches!(
validate_command_list(&nested),
Err(RenderError::InvalidCommandStream {
index: 1,
message: "nested BeginFrame is not allowed"
})
));
}
#[test]
fn command_validation_rejects_nonfinite_transform_and_range_overflow() {
let mut draw = snapshot_draw(10, RenderPhase::Opaque, 0, 10);
draw.transform[5] = f32::NAN;
let nonfinite = build_commands(
&RenderSnapshot {
camera: CameraSnapshot::default(),
draws: vec![draw],
},
RenderProfile::default(),
);
assert!(matches!(
nonfinite,
Err(RenderError::NonFiniteTransform {
draw_id: DrawId(10),
element: 5
})
));
let list = RenderCommandList {
commands: vec![
RenderCommand::BeginFrame,
RenderCommand::Draw(DrawCommand {
id: DrawId(11),
phase: RenderPhase::Opaque,
object_id: None,
mesh: GpuMeshId(2),
material: GpuMaterialId(3),
transform: identity_transform(),
range: IndexRange {
start: u32::MAX,
count: 1,
},
stable_order: 4,
}),
RenderCommand::EndFrame,
],
};
assert!(matches!(
validate_command_list(&list),
Err(RenderError::IndexRangeOverflow {
draw_id: DrawId(11),
start: u32::MAX,
count: 1
})
));
}
#[test]
fn command_validation_checks_order_and_resource_bounds() {
let ordered = build_commands(
&RenderSnapshot {
camera: CameraSnapshot::default(),
draws: vec![snapshot_draw(1, RenderPhase::Opaque, 0, 10)],
},
RenderProfile::default(),
)
.expect("commands");
assert!(matches!(
validate_command_list_with_limits(
&ordered,
RenderValidationLimits {
mesh_count: Some(5),
index_count: Some(16)
}
),
Err(RenderError::MeshOutOfBounds {
draw_id: DrawId(1),
mesh: GpuMeshId(11),
mesh_count: 5
})
));
let out_of_bounds = RenderCommandList {
commands: vec![
RenderCommand::BeginFrame,
RenderCommand::Draw(DrawCommand {
id: DrawId(12),
phase: RenderPhase::Opaque,
object_id: None,
mesh: GpuMeshId(2),
material: GpuMaterialId(3),
transform: identity_transform(),
range: IndexRange {
start: 14,
count: 3,
},
stable_order: 4,
}),
RenderCommand::EndFrame,
],
};
assert!(matches!(
validate_command_list_with_limits(
&out_of_bounds,
RenderValidationLimits {
mesh_count: Some(5),
index_count: Some(16)
}
),
Err(RenderError::IndexRangeOutOfBounds {
draw_id: DrawId(12),
index_count: 16,
end: 17
})
));
let unordered = RenderCommandList {
commands: vec![
RenderCommand::BeginFrame,
RenderCommand::Draw(DrawCommand {
id: DrawId(1),
phase: RenderPhase::Transparent,
object_id: None,
mesh: GpuMeshId(1),
material: GpuMaterialId(1),
transform: identity_transform(),
range: IndexRange { start: 0, count: 3 },
stable_order: 0,
}),
RenderCommand::Draw(DrawCommand {
id: DrawId(2),
phase: RenderPhase::Opaque,
object_id: None,
mesh: GpuMeshId(1),
material: GpuMaterialId(1),
transform: identity_transform(),
range: IndexRange { start: 0, count: 3 },
stable_order: 0,
}),
RenderCommand::EndFrame,
],
};
assert!(matches!(
validate_command_list(&unordered),
Err(RenderError::PhaseOrderViolation {
draw_id: DrawId(2),
previous: RenderPhase::Transparent,
current: RenderPhase::Opaque
})
));
}
#[test]
fn render_error_display_is_actionable() {
assert_eq!(
RenderError::InvalidDrawRange {
draw_id: DrawId(9),
stable_order: 10,
start: 4,
count: 0
}
.to_string(),
"draw 9 has invalid index range start=4 count=0 at stable order 10"
);
assert_eq!(
RenderError::MaterialIndexOutOfBounds {
draw_id: DrawId(7),
material_index: 3,
material_count: 2
}
.to_string(),
"draw 7 references material index 3 but only 2 material slots are available"
);
}
#[test] #[test]
fn ui_phase_is_excluded_until_requested() -> Result<(), RenderError> { fn ui_phase_is_excluded_until_requested() -> Result<(), RenderError> {
let snapshot = RenderSnapshot { let snapshot = RenderSnapshot {
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-binary = { path = "../fparkan-binary" }
fparkan-nres = { path = "../fparkan-nres" } fparkan-nres = { path = "../fparkan-nres" }
fparkan-path = { path = "../fparkan-path" } fparkan-path = { path = "../fparkan-path" }
fparkan-rsli = { path = "../fparkan-rsli" } fparkan-rsli = { path = "../fparkan-rsli" }
+284 -45
View File
@@ -1,6 +1,7 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
//! Resource identity and repository ports. //! Resource identity and repository ports.
use fparkan_binary::Sha256Digest;
use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName}; use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName};
use fparkan_vfs::{Vfs, VfsError}; use fparkan_vfs::{Vfs, VfsError};
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -40,6 +41,8 @@ pub struct ArchiveId(pub u64);
pub struct EntryHandle { pub struct EntryHandle {
/// Archive. /// Archive.
pub archive: ArchiveId, pub archive: ArchiveId,
/// Archive generation at the time the entry was resolved.
pub generation: u64,
/// Local entry index. /// Local entry index.
pub local: u32, pub local: u32,
} }
@@ -108,6 +111,8 @@ pub enum ResourceError {
MissingEntry, MissingEntry,
/// Stale or invalid handle. /// Stale or invalid handle.
InvalidHandle, InvalidHandle,
/// Handle belongs to an older archive generation.
StaleHandle,
/// Format error. /// Format error.
Format(String), Format(String),
/// Entry-specific read error. /// Entry-specific read error.
@@ -123,7 +128,30 @@ pub enum ResourceError {
impl std::fmt::Display for ResourceError { impl std::fmt::Display for ResourceError {
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::MissingArchive => write!(f, "archive was not found"),
Self::MissingEntry => write!(f, "resource entry was not found in the archive"),
Self::InvalidHandle => write!(
f,
"resource handle does not reference an open archive entry"
),
Self::StaleHandle => {
write!(f, "resource handle belongs to an older archive generation")
}
Self::Format(message) => write!(f, "resource archive format error: {message}"),
Self::EntryRead { key, source } => {
write!(
f,
"failed to read resource {}:{} from {}: {}",
key.type_id
.map_or_else(|| "-".to_string(), |type_id| type_id.to_string()),
String::from_utf8_lossy(&key.name.0),
key.archive.as_str(),
source
)
}
Self::Poisoned => write!(f, "resource repository state lock was poisoned"),
}
} }
} }
@@ -148,6 +176,12 @@ pub trait ResourceRepository {
archive: ArchiveId, archive: ArchiveId,
name: &ResourceName, name: &ResourceName,
) -> Result<Option<EntryHandle>, ResourceError>; ) -> Result<Option<EntryHandle>, ResourceError>;
/// Returns the first entry in archive directory order.
///
/// # Errors
///
/// Returns [`ResourceError`] when `archive` is not a valid opened archive.
fn first_entry(&self, archive: ArchiveId) -> Result<Option<EntryHandle>, ResourceError>;
/// Reads bytes. /// Reads bytes.
/// ///
/// # Errors /// # Errors
@@ -169,6 +203,24 @@ pub struct CachedResourceRepository {
state: Mutex<RepositoryState>, state: Mutex<RepositoryState>,
} }
/// Decoded payload cache limits.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PayloadCacheLimits {
/// Maximum cached decoded payload entries.
pub max_entries: usize,
/// Maximum cached decoded payload bytes.
pub max_bytes: usize,
}
impl Default for PayloadCacheLimits {
fn default() -> Self {
Self {
max_entries: 64,
max_bytes: 64 * 1024 * 1024,
}
}
}
#[derive(Default)] #[derive(Default)]
struct RepositoryState { struct RepositoryState {
paths: BTreeMap<String, ArchiveId>, paths: BTreeMap<String, ArchiveId>,
@@ -178,9 +230,10 @@ struct RepositoryState {
struct ArchiveSlot { struct ArchiveSlot {
path: NormalizedPath, path: NormalizedPath,
fingerprint: u64, fingerprint: Sha256Digest,
generation: u64,
kind: ArchiveKind, kind: ArchiveKind,
document: ArchiveDocument, document: Arc<ArchiveDocument>,
} }
enum ArchiveDocument { enum ArchiveDocument {
@@ -188,9 +241,16 @@ enum ArchiveDocument {
Rsli(fparkan_rsli::RsliDocument), Rsli(fparkan_rsli::RsliDocument),
} }
struct PayloadDecodeTask {
document: Arc<ArchiveDocument>,
key: ResourceKey,
}
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct DecodedPayloadCache { struct DecodedPayloadCache {
max_entries: usize, max_entries: usize,
max_bytes: usize,
current_bytes: usize,
generation: u64, generation: u64,
entries: BTreeMap<EntryHandle, PayloadCacheEntry>, entries: BTreeMap<EntryHandle, PayloadCacheEntry>,
} }
@@ -205,16 +265,28 @@ impl CachedResourceRepository {
/// Creates a cached repository. /// Creates a cached repository.
#[must_use] #[must_use]
pub fn new(vfs: Arc<dyn Vfs>) -> Self { pub fn new(vfs: Arc<dyn Vfs>) -> Self {
Self::with_payload_cache_budget(vfs, 64) Self::with_payload_cache_limits(vfs, PayloadCacheLimits::default())
} }
/// Creates a cached repository with a decoded payload entry budget. /// Creates a cached repository with a decoded payload entry budget.
#[must_use] #[must_use]
pub fn with_payload_cache_budget(vfs: Arc<dyn Vfs>, max_payload_entries: usize) -> Self { pub fn with_payload_cache_budget(vfs: Arc<dyn Vfs>, max_payload_entries: usize) -> Self {
Self::with_payload_cache_limits(
vfs,
PayloadCacheLimits {
max_entries: max_payload_entries,
..PayloadCacheLimits::default()
},
)
}
/// Creates a cached repository with decoded payload entry and byte budgets.
#[must_use]
pub fn with_payload_cache_limits(vfs: Arc<dyn Vfs>, limits: PayloadCacheLimits) -> Self {
Self { Self {
vfs, vfs,
state: Mutex::new(RepositoryState { state: Mutex::new(RepositoryState {
payload_cache: DecodedPayloadCache::new(max_payload_entries), payload_cache: DecodedPayloadCache::new(limits),
..RepositoryState::default() ..RepositoryState::default()
}), }),
} }
@@ -250,12 +322,13 @@ impl ResourceRepository for CachedResourceRepository {
} }
let bytes = self.vfs.read(path).map_err(resource_error_from_vfs)?; let bytes = self.vfs.read(path).map_err(resource_error_from_vfs)?;
let slot = decode_archive(path.clone(), bytes, fingerprint)?; let mut slot = decode_archive(path.clone(), bytes, fingerprint)?;
let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?; let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
if let Some(id) = state.paths.get(path.as_str()).copied() { if let Some(id) = state.paths.get(path.as_str()).copied() {
if state.archive(id)?.fingerprint == fingerprint { if state.archive(id)?.fingerprint == fingerprint {
return Ok(id); return Ok(id);
} }
slot.generation = state.archive(id)?.generation.saturating_add(1);
*state.archive_mut(id)? = slot; *state.archive_mut(id)? = slot;
state.payload_cache.remove_archive(id); state.payload_cache.remove_archive(id);
return Ok(id); return Ok(id);
@@ -275,37 +348,61 @@ impl ResourceRepository for CachedResourceRepository {
) -> Result<Option<EntryHandle>, ResourceError> { ) -> Result<Option<EntryHandle>, ResourceError> {
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?; let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
let slot = state.archive(archive)?; let slot = state.archive(archive)?;
let local = match &slot.document { let local = match slot.document.as_ref() {
ArchiveDocument::Nres(document) => document.find_bytes(&name.0).map(|id| id.0), ArchiveDocument::Nres(document) => document.find_bytes(&name.0).map(|id| id.0),
ArchiveDocument::Rsli(document) => document.find_bytes(&name.0).map(|id| id.0), ArchiveDocument::Rsli(document) => document.find_bytes(&name.0).map(|id| id.0),
}; };
Ok(local.map(|local| EntryHandle { archive, local })) Ok(local.map(|local| EntryHandle {
archive,
generation: slot.generation,
local,
}))
}
fn first_entry(&self, archive: ArchiveId) -> Result<Option<EntryHandle>, ResourceError> {
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
let slot = state.archive(archive)?;
let local = match slot.document.as_ref() {
ArchiveDocument::Nres(document) => document.entries().first().map(|entry| entry.id().0),
ArchiveDocument::Rsli(document) => document.entry(fparkan_rsli::EntryId(0)).map(|_| 0),
};
Ok(local.map(|local| EntryHandle {
archive,
generation: slot.generation,
local,
}))
} }
fn read(&self, entry: EntryHandle) -> Result<ResourceBytes, ResourceError> { fn read(&self, entry: EntryHandle) -> Result<ResourceBytes, ResourceError> {
let task = {
let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
if let Some(bytes) = state.payload_cache.get(entry) {
return Ok(ResourceBytes::Shared(bytes));
}
state.payload_decode_task(entry)?
};
let payload =
task.document
.read_payload(entry.local)
.map_err(|source| ResourceError::EntryRead {
key: task.key,
source,
})?;
let shared = Arc::from(payload.into_boxed_slice());
let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?; let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
if let Some(bytes) = state.payload_cache.get(entry) { if let Some(bytes) = state.payload_cache.get(entry) {
return Ok(ResourceBytes::Shared(bytes)); return Ok(ResourceBytes::Shared(bytes));
} }
state.entry_archive(entry)?;
let payload = {
let slot = state.archive(entry.archive)?;
let key = slot.entry_key(entry.local)?;
slot.read_payload(entry.local)
.map_err(|source| ResourceError::EntryRead {
key: key.clone(),
source,
})?
};
let shared = Arc::from(payload.into_boxed_slice());
state.payload_cache.insert(entry, Arc::clone(&shared)); state.payload_cache.insert(entry, Arc::clone(&shared));
Ok(ResourceBytes::Shared(shared)) Ok(ResourceBytes::Shared(shared))
} }
fn entry_info(&self, entry: EntryHandle) -> Result<ResourceEntryInfo, ResourceError> { fn entry_info(&self, entry: EntryHandle) -> Result<ResourceEntryInfo, ResourceError> {
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?; let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
let slot = state.archive(entry.archive)?; let slot = state.entry_archive(entry)?;
match &slot.document { match slot.document.as_ref() {
ArchiveDocument::Nres(document) => { ArchiveDocument::Nres(document) => {
let local = let local =
usize::try_from(entry.local).map_err(|_| ResourceError::InvalidHandle)?; usize::try_from(entry.local).map_err(|_| ResourceError::InvalidHandle)?;
@@ -348,7 +445,7 @@ impl CachedResourceRepository {
fn cached_id( fn cached_id(
&self, &self,
path: &NormalizedPath, path: &NormalizedPath,
fingerprint: u64, fingerprint: Sha256Digest,
) -> Result<Option<ArchiveId>, ResourceError> { ) -> Result<Option<ArchiveId>, ResourceError> {
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?; let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
let Some(id) = state.paths.get(path.as_str()).copied() else { let Some(id) = state.paths.get(path.as_str()).copied() else {
@@ -363,9 +460,11 @@ impl CachedResourceRepository {
} }
impl DecodedPayloadCache { impl DecodedPayloadCache {
fn new(max_entries: usize) -> Self { fn new(limits: PayloadCacheLimits) -> Self {
Self { Self {
max_entries, max_entries: limits.max_entries,
max_bytes: limits.max_bytes,
current_bytes: 0,
generation: 0, generation: 0,
entries: BTreeMap::new(), entries: BTreeMap::new(),
} }
@@ -379,18 +478,39 @@ impl DecodedPayloadCache {
} }
fn insert(&mut self, handle: EntryHandle, bytes: Arc<[u8]>) { fn insert(&mut self, handle: EntryHandle, bytes: Arc<[u8]>) {
if self.max_entries == 0 { let len = bytes.len();
if self.max_entries == 0 || len > self.max_bytes {
return; return;
} }
self.generation = self.generation.saturating_add(1); self.generation = self.generation.saturating_add(1);
self.entries.insert( if let Some(previous) = self.entries.insert(
handle, handle,
PayloadCacheEntry { PayloadCacheEntry {
bytes, bytes,
last_access: self.generation, last_access: self.generation,
}, },
); ) {
while self.entries.len() > self.max_entries { self.current_bytes = self.current_bytes.saturating_sub(previous.bytes.len());
}
self.current_bytes = self.current_bytes.saturating_add(len);
self.evict_until_within_budget();
}
fn remove_archive(&mut self, archive: ArchiveId) {
let mut removed_bytes = 0usize;
self.entries.retain(|handle, entry| {
if handle.archive == archive {
removed_bytes = removed_bytes.saturating_add(entry.bytes.len());
false
} else {
true
}
});
self.current_bytes = self.current_bytes.saturating_sub(removed_bytes);
}
fn evict_until_within_budget(&mut self) {
while self.entries.len() > self.max_entries || self.current_bytes > self.max_bytes {
let Some(victim) = self let Some(victim) = self
.entries .entries
.iter() .iter()
@@ -399,13 +519,11 @@ impl DecodedPayloadCache {
else { else {
break; break;
}; };
self.entries.remove(&victim); if let Some(removed) = self.entries.remove(&victim) {
self.current_bytes = self.current_bytes.saturating_sub(removed.bytes.len());
}
} }
} }
fn remove_archive(&mut self, archive: ArchiveId) {
self.entries.retain(|handle, _| handle.archive != archive);
}
} }
impl RepositoryState { impl RepositoryState {
@@ -420,11 +538,27 @@ impl RepositoryState {
.get_mut(index) .get_mut(index)
.ok_or(ResourceError::InvalidHandle) .ok_or(ResourceError::InvalidHandle)
} }
fn entry_archive(&self, entry: EntryHandle) -> Result<&ArchiveSlot, ResourceError> {
let slot = self.archive(entry.archive)?;
if slot.generation != entry.generation {
return Err(ResourceError::StaleHandle);
}
Ok(slot)
}
fn payload_decode_task(&self, entry: EntryHandle) -> Result<PayloadDecodeTask, ResourceError> {
let slot = self.entry_archive(entry)?;
Ok(PayloadDecodeTask {
document: Arc::clone(&slot.document),
key: slot.entry_key(entry.local)?,
})
}
} }
impl ArchiveSlot { impl ArchiveSlot {
fn entry_key(&self, local: u32) -> Result<ResourceKey, ResourceError> { fn entry_key(&self, local: u32) -> Result<ResourceKey, ResourceError> {
match &self.document { match self.document.as_ref() {
ArchiveDocument::Nres(document) => { ArchiveDocument::Nres(document) => {
let local = usize::try_from(local).map_err(|_| ResourceError::InvalidHandle)?; let local = usize::try_from(local).map_err(|_| ResourceError::InvalidHandle)?;
let entry = document let entry = document
@@ -449,9 +583,11 @@ impl ArchiveSlot {
} }
} }
} }
}
impl ArchiveDocument {
fn read_payload(&self, local: u32) -> Result<Vec<u8>, String> { fn read_payload(&self, local: u32) -> Result<Vec<u8>, String> {
match &self.document { match self {
ArchiveDocument::Nres(document) => document ArchiveDocument::Nres(document) => document
.payload(fparkan_nres::EntryId(local)) .payload(fparkan_nres::EntryId(local))
.map(<[u8]>::to_vec) .map(<[u8]>::to_vec)
@@ -466,7 +602,7 @@ impl ArchiveSlot {
fn decode_archive( fn decode_archive(
path: NormalizedPath, path: NormalizedPath,
bytes: Arc<[u8]>, bytes: Arc<[u8]>,
fingerprint: u64, fingerprint: Sha256Digest,
) -> Result<ArchiveSlot, ResourceError> { ) -> Result<ArchiveSlot, ResourceError> {
if bytes.starts_with(b"NRes") { if bytes.starts_with(b"NRes") {
let document = fparkan_nres::decode(bytes, fparkan_nres::ReadProfile::Compatible) let document = fparkan_nres::decode(bytes, fparkan_nres::ReadProfile::Compatible)
@@ -474,8 +610,9 @@ fn decode_archive(
return Ok(ArchiveSlot { return Ok(ArchiveSlot {
path, path,
fingerprint, fingerprint,
generation: 0,
kind: ArchiveKind::Nres, kind: ArchiveKind::Nres,
document: ArchiveDocument::Nres(document), document: Arc::new(ArchiveDocument::Nres(document)),
}); });
} }
if bytes.get(0..4) == Some(b"NL\0\x01") { if bytes.get(0..4) == Some(b"NL\0\x01") {
@@ -484,8 +621,9 @@ fn decode_archive(
return Ok(ArchiveSlot { return Ok(ArchiveSlot {
path, path,
fingerprint, fingerprint,
generation: 0,
kind: ArchiveKind::Rsli, kind: ArchiveKind::Rsli,
document: ArchiveDocument::Rsli(document), document: Arc::new(ArchiveDocument::Rsli(document)),
}); });
} }
Err(ResourceError::Format( Err(ResourceError::Format(
@@ -528,7 +666,7 @@ fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
mod tests { mod tests {
use super::*; use super::*;
use fparkan_vfs::{DirectoryVfs, MemoryVfs}; use fparkan_vfs::{DirectoryVfs, MemoryVfs};
use std::path::Path; use std::path::PathBuf;
#[test] #[test]
fn cached_repository_reads_synthetic_nres() { fn cached_repository_reads_synthetic_nres() {
@@ -554,6 +692,7 @@ mod tests {
assert!(matches!( assert!(matches!(
repo.read(EntryHandle { repo.read(EntryHandle {
archive: ArchiveId(99), archive: ArchiveId(99),
generation: 0,
local: 0 local: 0
}), }),
Err(ResourceError::InvalidHandle) Err(ResourceError::InvalidHandle)
@@ -632,6 +771,77 @@ mod tests {
); );
} }
#[test]
fn decoded_payload_cache_evicts_by_byte_budget() {
let path = archive_path(b"cache/bytes.lib").expect("path");
let bytes = build_nres(&[
("a.bin", b"1234".as_slice()),
("b.bin", b"5678".as_slice()),
("c.bin", b"90".as_slice()),
]);
let mut vfs = MemoryVfs::default();
vfs.insert(path.clone(), Arc::from(bytes.into_boxed_slice()));
let repo = CachedResourceRepository::with_payload_cache_limits(
Arc::new(vfs),
PayloadCacheLimits {
max_entries: 64,
max_bytes: 6,
},
);
let archive = repo.open_archive(&path).expect("open archive");
let first = repo
.find(archive, &resource_name(b"a.bin"))
.expect("find a")
.expect("a");
let second = repo
.find(archive, &resource_name(b"b.bin"))
.expect("find b")
.expect("b");
let third = repo
.find(archive, &resource_name(b"c.bin"))
.expect("find c")
.expect("c");
assert_eq!(repo.read(first).expect("read a").as_slice(), b"1234");
assert_eq!(repo.read(second).expect("read b").as_slice(), b"5678");
assert_eq!(repo.read(third).expect("read c").as_slice(), b"90");
let state = repo.state.lock().expect("state");
assert_eq!(state.payload_cache.current_bytes, 6);
assert_eq!(state.payload_cache.entries.len(), 2);
assert!(!state.payload_cache.entries.contains_key(&first));
assert!(state.payload_cache.entries.contains_key(&second));
assert!(state.payload_cache.entries.contains_key(&third));
}
#[test]
fn decoded_payload_cache_does_not_store_payload_larger_than_budget() {
let path = archive_path(b"cache/oversized.lib").expect("path");
let bytes = build_nres(&[("big.bin", b"1234567".as_slice())]);
let mut vfs = MemoryVfs::default();
vfs.insert(path.clone(), Arc::from(bytes.into_boxed_slice()));
let repo = CachedResourceRepository::with_payload_cache_limits(
Arc::new(vfs),
PayloadCacheLimits {
max_entries: 64,
max_bytes: 6,
},
);
let archive = repo.open_archive(&path).expect("open archive");
let handle = repo
.find(archive, &resource_name(b"big.bin"))
.expect("find big")
.expect("big");
assert_eq!(repo.read(handle).expect("read big").as_slice(), b"1234567");
let state = repo.state.lock().expect("state");
assert_eq!(state.payload_cache.current_bytes, 0);
assert!(state.payload_cache.entries.is_empty());
}
#[test] #[test]
fn archive_cache_invalidates_when_vfs_bytes_change() { fn archive_cache_invalidates_when_vfs_bytes_change() {
let root = temp_dir("archive-invalidate"); let root = temp_dir("archive-invalidate");
@@ -652,7 +862,7 @@ mod tests {
b"before" b"before"
); );
std::fs::write(&host_path, build_nres(&[("a.bin", b"after".as_slice())])) std::fs::write(&host_path, build_nres(&[("a.bin", b"after!".as_slice())]))
.expect("updated archive"); .expect("updated archive");
let reopened = repo.open_archive(&path).expect("open updated archive"); let reopened = repo.open_archive(&path).expect("open updated archive");
let second = repo let second = repo
@@ -661,9 +871,11 @@ mod tests {
.expect("updated handle"); .expect("updated handle");
assert_eq!(reopened, archive); assert_eq!(reopened, archive);
assert_ne!(first, second);
assert!(matches!(repo.read(first), Err(ResourceError::StaleHandle)));
assert_eq!( assert_eq!(
repo.read(second).expect("read updated").as_slice(), repo.read(second).expect("read updated").as_slice(),
b"after" b"after!"
); );
let _ = std::fs::remove_dir_all(root); let _ = std::fs::remove_dir_all(root);
} }
@@ -696,16 +908,43 @@ mod tests {
} }
#[test] #[test]
fn resource_error_display_is_actionable() {
let path = archive_path(b"bad/rsli.lib").expect("path");
let err = ResourceError::EntryRead {
key: ResourceKey {
archive: path,
name: resource_name(b"BROKEN.TEX"),
type_id: None,
},
source: "unsupported packing method 0x1e0".to_string(),
};
assert_eq!(
err.to_string(),
"failed to read resource -:BROKEN.TEX from bad/rsli.lib: unsupported packing method 0x1e0"
);
assert_eq!(
ResourceError::StaleHandle.to_string(),
"resource handle belongs to an older archive generation"
);
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpora_repository_reads_nres_and_rsli() { fn licensed_corpora_repository_reads_nres_and_rsli() {
licensed_repository_gate("IS").expect("part 1 repository gate"); licensed_repository_gate("IS").expect("part 1 repository gate");
licensed_repository_gate("IS2").expect("part 2 repository gate"); licensed_repository_gate("IS2").expect("part 2 repository gate");
} }
fn licensed_repository_gate(corpus: &str) -> Result<(), String> { fn licensed_repository_gate(corpus: &str) -> Result<(), String> {
let root = Path::new(env!("CARGO_MANIFEST_DIR")) let variable = match corpus {
.join("../..") "IS" => "FPARKAN_CORPUS_PART1_ROOT",
.join("testdata") "IS2" => "FPARKAN_CORPUS_PART2_ROOT",
.join(corpus); _ => return Err(format!("unknown licensed corpus part: {corpus}")),
};
let root = std::env::var_os(variable)
.map(PathBuf::from)
.ok_or_else(|| format!("{variable} is required for licensed corpus tests"))?;
if !root.is_dir() { if !root.is_dir() {
return Err(format!( return Err(format!(
"licensed corpus root is missing: {}", "licensed corpus root is missing: {}",
+177 -15
View File
@@ -14,6 +14,44 @@ pub enum ReadProfile {
Compatible, Compatible,
} }
/// Detailed read profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RsliReadProfile {
/// Reject compatibility quirks.
Strict,
/// Accept selected retail compatibility quirks.
Compatible(RsliCompatibilityProfile),
}
impl From<ReadProfile> for RsliReadProfile {
fn from(value: ReadProfile) -> Self {
match value {
ReadProfile::Strict => Self::Strict,
ReadProfile::Compatible => Self::Compatible(RsliCompatibilityProfile::default()),
}
}
}
impl RsliReadProfile {
/// Strict profile with every compatibility quirk disabled.
#[must_use]
pub const fn strict() -> Self {
Self::Strict
}
/// Retail-compatible profile with the default approved quirk set.
#[must_use]
pub const fn compatible() -> Self {
Self::Compatible(RsliCompatibilityProfile::retail())
}
/// Retail-compatible profile with a caller-provided quirk set.
#[must_use]
pub const fn compatible_with(profile: RsliCompatibilityProfile) -> Self {
Self::Compatible(profile)
}
}
/// Write profile. /// Write profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WriteProfile { pub enum WriteProfile {
@@ -34,12 +72,30 @@ pub struct RsliCompatibilityProfile {
impl Default for RsliCompatibilityProfile { impl Default for RsliCompatibilityProfile {
fn default() -> Self { fn default() -> Self {
Self::retail()
}
}
impl RsliCompatibilityProfile {
/// Retail-compatible profile with every approved quirk enabled.
#[must_use]
pub const fn retail() -> Self {
Self { Self {
allow_ao_trailer: true, allow_ao_trailer: true,
allow_deflate_eof_plus_one: true, allow_deflate_eof_plus_one: true,
allow_invalid_presorted_fallback: true, allow_invalid_presorted_fallback: true,
} }
} }
/// Profile with every compatibility quirk disabled.
#[must_use]
pub const fn none() -> Self {
Self {
allow_ao_trailer: false,
allow_deflate_eof_plus_one: false,
allow_invalid_presorted_fallback: false,
}
}
} }
/// `RsLi` packing method. /// `RsLi` packing method.
@@ -197,6 +253,11 @@ pub enum RsliError {
/// Archive byte length. /// Archive byte length.
file_len: u64, file_len: u64,
}, },
/// Registered `AO` overlay is rejected by the selected profile.
AoTrailerQuirkRejected {
/// Overlay byte offset.
overlay: u32,
},
/// Unsupported packing method. /// Unsupported packing method.
UnsupportedMethod { UnsupportedMethod {
/// Raw method bits. /// Raw method bits.
@@ -266,6 +327,9 @@ impl fmt::Display for RsliError {
"media overlay out of bounds: overlay={overlay}, file={file_len}" "media overlay out of bounds: overlay={overlay}, file={file_len}"
) )
} }
Self::AoTrailerQuirkRejected { overlay } => {
write!(f, "AO trailer quirk rejected: overlay={overlay}")
}
Self::UnsupportedMethod { raw } => write!(f, "unsupported packing method: {raw:#x}"), Self::UnsupportedMethod { raw } => write!(f, "unsupported packing method: {raw:#x}"),
Self::PackedSizePastEof { Self::PackedSizePastEof {
id, id,
@@ -298,20 +362,31 @@ impl std::error::Error for RsliError {}
/// compatibility quirks, or packed payloads are invalid for the selected /// compatibility quirks, or packed payloads are invalid for the selected
/// profile. /// profile.
pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<RsliDocument, RsliError> { pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<RsliDocument, RsliError> {
decode_with_profile(bytes, profile.into())
}
/// Decodes an `RsLi` document with explicit compatibility switches.
///
/// # Errors
///
/// Returns [`RsliError`] when the header, table, payload ranges, registered
/// compatibility quirks, or packed payloads are invalid for the selected
/// profile.
pub fn decode_with_profile(
bytes: Arc<[u8]>,
profile: RsliReadProfile,
) -> Result<RsliDocument, RsliError> {
let options = match profile { let options = match profile {
ReadProfile::Strict => ParseOptions { RsliReadProfile::Strict => ParseOptions {
allow_ao_trailer: false, allow_ao_trailer: false,
allow_deflate_eof_plus_one: false, allow_deflate_eof_plus_one: false,
allow_invalid_presorted_fallback: false, allow_invalid_presorted_fallback: false,
}, },
ReadProfile::Compatible => { RsliReadProfile::Compatible(profile) => ParseOptions {
let profile = RsliCompatibilityProfile::default(); allow_ao_trailer: profile.allow_ao_trailer,
ParseOptions { allow_deflate_eof_plus_one: profile.allow_deflate_eof_plus_one,
allow_ao_trailer: profile.allow_ao_trailer, allow_invalid_presorted_fallback: profile.allow_invalid_presorted_fallback,
allow_deflate_eof_plus_one: profile.allow_deflate_eof_plus_one, },
allow_invalid_presorted_fallback: profile.allow_invalid_presorted_fallback,
}
}
}; };
let ParsedRsli { let ParsedRsli {
header, header,
@@ -691,7 +766,7 @@ fn rebuild_sorted_mapping(records: &mut [EntryRecord]) -> Result<(), RsliError>
} }
fn parse_ao_trailer(bytes: &[u8], allow: bool) -> Result<(u32, Option<[u8; 6]>), RsliError> { fn parse_ao_trailer(bytes: &[u8], allow: bool) -> Result<(u32, Option<[u8; 6]>), RsliError> {
if !allow || bytes.len() < 6 || &bytes[bytes.len() - 6..bytes.len() - 4] != b"AO" { if bytes.len() < 6 || &bytes[bytes.len() - 6..bytes.len() - 4] != b"AO" {
return Ok((0, None)); return Ok((0, None));
} }
let mut raw = [0u8; 6]; let mut raw = [0u8; 6];
@@ -703,6 +778,9 @@ fn parse_ao_trailer(bytes: &[u8], allow: bool) -> Result<(u32, Option<[u8; 6]>),
file_len: u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?, file_len: u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?,
}); });
} }
if !allow {
return Err(RsliError::AoTrailerQuirkRejected { overlay });
}
Ok((overlay, Some(raw))) Ok((overlay, Some(raw)))
} }
@@ -1330,6 +1408,39 @@ mod tests {
assert_eq!(doc.find("B"), Some(EntryId(0))); assert_eq!(doc.find("B"), Some(EntryId(0)));
} }
#[test]
fn explicit_profile_controls_invalid_presorted_fallback() {
let bytes = synthetic_rsli(
&[
SyntheticEntry::stored(b"B", 0, b"bee"),
SyntheticEntry::stored(b"A", 0, b"aye"),
],
true,
0x0102,
None,
);
let profile = RsliCompatibilityProfile {
allow_invalid_presorted_fallback: false,
..RsliCompatibilityProfile::retail()
};
assert!(matches!(
decode_with_profile(
arc(bytes.clone()),
RsliReadProfile::compatible_with(profile)
),
Err(RsliError::CorruptEntryTable(_))
));
let profile = RsliCompatibilityProfile {
allow_invalid_presorted_fallback: true,
..RsliCompatibilityProfile::none()
};
let doc = decode_with_profile(arc(bytes), RsliReadProfile::compatible_with(profile))
.expect("presorted fallback only");
assert_eq!(doc.find("A"), Some(EntryId(1)));
}
#[test] #[test]
fn stored_method_uses_exact_size() { fn stored_method_uses_exact_size() {
let bytes = synthetic_rsli( let bytes = synthetic_rsli(
@@ -1510,6 +1621,16 @@ mod tests {
decode(arc(approved.clone()), ReadProfile::Strict), decode(arc(approved.clone()), ReadProfile::Strict),
Err(RsliError::DeflateEofPlusOneQuirkRejected { id: 0 }) Err(RsliError::DeflateEofPlusOneQuirkRejected { id: 0 })
)); ));
assert!(matches!(
decode_with_profile(
arc(approved.clone()),
RsliReadProfile::compatible_with(RsliCompatibilityProfile {
allow_deflate_eof_plus_one: false,
..RsliCompatibilityProfile::retail()
})
),
Err(RsliError::DeflateEofPlusOneQuirkRejected { id: 0 })
));
let doc = decode(arc(approved), ReadProfile::Compatible).expect("approved EOF+1 quirk"); let doc = decode(arc(approved), ReadProfile::Compatible).expect("approved EOF+1 quirk");
assert_eq!(doc.load(EntryId(0)).expect("approved payload"), b"raw"); assert_eq!(doc.load(EntryId(0)).expect("approved payload"), b"raw");
@@ -1606,11 +1727,22 @@ mod tests {
Some(4), Some(4),
); );
let doc = decode(arc(bytes), ReadProfile::Compatible).expect("AO overlay"); let doc = decode(arc(bytes.clone()), ReadProfile::Compatible).expect("AO overlay");
let meta = doc.entry(EntryId(0)).expect("AO meta"); let meta = doc.entry(EntryId(0)).expect("AO meta");
assert_eq!(meta.data_offset, 64); assert_eq!(meta.data_offset, 64);
assert_eq!(meta.data_offset_raw, 60); assert_eq!(meta.data_offset_raw, 60);
assert_eq!(doc.load(EntryId(0)).expect("AO payload"), b"media"); assert_eq!(doc.load(EntryId(0)).expect("AO payload"), b"media");
assert!(matches!(
decode_with_profile(
arc(bytes),
RsliReadProfile::compatible_with(RsliCompatibilityProfile {
allow_ao_trailer: false,
..RsliCompatibilityProfile::retail()
})
),
Err(RsliError::AoTrailerQuirkRejected { overlay: 4 })
));
} }
#[test] #[test]
@@ -1625,6 +1757,28 @@ mod tests {
)); ));
} }
#[test]
fn strict_profile_distinguishes_valid_ao_quirk_from_malformed_ao() {
let valid = synthetic_rsli(
&[SyntheticEntry::stored(b"A", 0, b"media")],
true,
0x3333,
Some(4),
);
assert!(matches!(
decode_with_profile(arc(valid), RsliReadProfile::strict()),
Err(RsliError::AoTrailerQuirkRejected { overlay: 4 })
));
let mut malformed = synthetic_rsli(&[], false, 0, None);
malformed.extend_from_slice(b"AO");
malformed.extend_from_slice(&1000u32.to_le_bytes());
assert!(matches!(
decode_with_profile(arc(malformed), RsliReadProfile::strict()),
Err(RsliError::MediaOverlayOutOfBounds { overlay: 1000, .. })
));
}
#[test] #[test]
fn unknown_header_bytes_are_lossless() { fn unknown_header_bytes_are_lossless() {
let mut bytes = synthetic_rsli( let mut bytes = synthetic_rsli(
@@ -1742,6 +1896,7 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpora_rsli_roundtrip_gates() { fn licensed_corpora_rsli_roundtrip_gates() {
let part1 = corpus_gate("IS", 2).expect("part 1 RsLi gate"); let part1 = corpus_gate("IS", 2).expect("part 1 RsLi gate");
let part2 = corpus_gate("IS2", 2).expect("part 2 RsLi gate"); let part2 = corpus_gate("IS2", 2).expect("part 2 RsLi gate");
@@ -1751,6 +1906,7 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_part1_rsli_method_distribution_baseline() { fn licensed_part1_rsli_method_distribution_baseline() {
let stats = corpus_gate("IS", 2).expect("part 1 RsLi gate"); let stats = corpus_gate("IS", 2).expect("part 1 RsLi gate");
@@ -1770,6 +1926,7 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_part2_rsli_method_distribution_baseline() { fn licensed_part2_rsli_method_distribution_baseline() {
let stats = corpus_gate("IS2", 2).expect("part 2 RsLi gate"); let stats = corpus_gate("IS2", 2).expect("part 2 RsLi gate");
@@ -1789,6 +1946,7 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpora_rsli_quirk_is_only_approved_interf8_tex() { fn licensed_corpora_rsli_quirk_is_only_approved_interf8_tex() {
let part1 = corpus_gate("IS", 2).expect("part 1 RsLi gate"); let part1 = corpus_gate("IS", 2).expect("part 1 RsLi gate");
let part2 = corpus_gate("IS2", 2).expect("part 2 RsLi gate"); let part2 = corpus_gate("IS2", 2).expect("part 2 RsLi gate");
@@ -1908,10 +2066,14 @@ mod tests {
} }
fn corpus_files(name: &str) -> Result<Vec<PathBuf>, String> { fn corpus_files(name: &str) -> Result<Vec<PathBuf>, String> {
let root = Path::new(env!("CARGO_MANIFEST_DIR")) let variable = match name {
.join("../..") "IS" => "FPARKAN_CORPUS_PART1_ROOT",
.join("testdata") "IS2" => "FPARKAN_CORPUS_PART2_ROOT",
.join(name); _ => return Err(format!("unknown licensed corpus part: {name}")),
};
let root = std::env::var_os(variable)
.map(PathBuf::from)
.ok_or_else(|| format!("{variable} is required for licensed corpus tests"))?;
if !root.is_dir() { if !root.is_dir() {
return Err(format!( return Err(format!(
"licensed corpus root is missing: {}", "licensed corpus root is missing: {}",
+32 -19
View File
@@ -695,8 +695,9 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn load_trace_records_preparation_before_registration_and_raw_transforms() { fn load_trace_records_preparation_before_registration_and_raw_transforms() {
let root = workspace_root().join("testdata").join("IS"); let root = licensed_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root)); let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
let mut engine = create( let mut engine = create(
EngineConfig { EngineConfig {
@@ -736,8 +737,9 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn missing_map_and_missing_reachable_resource_fail_before_registration() { fn missing_map_and_missing_reachable_resource_fail_before_registration() {
let root = workspace_root().join("testdata").join("IS"); let root = licensed_root("IS");
for (denied, mission) in [ for (denied, mission) in [
( (
DenyRule::Suffix("Land.map"), DenyRule::Suffix("Land.map"),
@@ -779,8 +781,9 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn registration_phase_failure_uses_normal_teardown_and_keeps_engine_world() { fn registration_phase_failure_uses_normal_teardown_and_keeps_engine_world() {
let root = workspace_root().join("testdata").join("IS"); let root = licensed_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
let mut engine = create( let mut engine = create(
EngineConfig { EngineConfig {
@@ -816,6 +819,7 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn selected_is_and_is2_missions_execute_10000_deterministic_ticks() { fn selected_is_and_is2_missions_execute_10000_deterministic_ticks() {
for case in [ for case in [
HeadlessCase { HeadlessCase {
@@ -823,9 +827,9 @@ mod tests {
mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma", mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma",
object_count: 33, object_count: 33,
expected_hash: [ expected_hash: [
0x19, 0xdc, 0xd3, 0x9b, 0x35, 0xad, 0x90, 0x6c, 0x92, 0x2d, 0x83, 0x7b, 0x7a, 0xc7, 0xb0, 0x6e, 0x0a, 0x31, 0x1f, 0x5d, 0x8c, 0xde, 0x64, 0xa5, 0x33, 0x1f,
0xb3, 0xa6, 0x15, 0xa6, 0x15, 0x92, 0x2d, 0x83, 0x7b, 0x7a, 0xb3, 0xe9, 0xcd, 0x2c, 0xd0, 0x2c, 0x21, 0x44, 0x2f, 0x34, 0x5d, 0x16, 0xe8, 0x94, 0xaf, 0xa2,
0x9a, 0x56, 0x48, 0xb6, 0x0c, 0xee, 0x2b, 0xa9, 0xd4, 0x24, 0xd2, 0xf9,
], ],
}, },
HeadlessCase { HeadlessCase {
@@ -833,9 +837,9 @@ mod tests {
mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma", mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
object_count: 10, object_count: 10,
expected_hash: [ expected_hash: [
0x59, 0x6e, 0x88, 0xcc, 0xd0, 0x3a, 0xd9, 0x68, 0x1b, 0x2d, 0xcb, 0x0d, 0x91, 0x3c, 0xe5, 0xa6, 0x39, 0x47, 0x86, 0x76, 0xe1, 0xb2, 0x1a, 0x8e, 0x96, 0x3d,
0x19, 0x5a, 0x27, 0x5a, 0x27, 0x1b, 0x2d, 0xcb, 0x0d, 0x91, 0x19, 0x44, 0x66, 0x60, 0x6e, 0xc6, 0x8c, 0xe2, 0x28, 0x4f, 0x57, 0xd9, 0xe1, 0xe4, 0xb5, 0x95,
0x68, 0x9d, 0x6c, 0xb4, 0x2c, 0x37, 0xdf, 0x88, 0xd3, 0x2f, 0x4a, 0x4d,
], ],
}, },
] { ] {
@@ -849,9 +853,9 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpora_load_all_mission_foundations() { fn licensed_corpora_load_all_mission_foundations() {
let root = workspace_root(); let part1 = load_all(&licensed_root("IS"));
let part1 = load_all(&root.join("testdata").join("IS"));
assert_eq!(part1.missions, 29); assert_eq!(part1.missions, 29);
assert_eq!(part1.paths, 34); assert_eq!(part1.paths, 34);
assert_eq!(part1.clans, 101); assert_eq!(part1.clans, 101);
@@ -871,7 +875,7 @@ mod tests {
assert_eq!(part1.texture_requests, part1.texture_resolved); assert_eq!(part1.texture_requests, part1.texture_resolved);
assert_eq!(part1.lightmap_requests, part1.lightmap_resolved); assert_eq!(part1.lightmap_requests, part1.lightmap_resolved);
let part2 = load_all(&root.join("testdata").join("IS2")); let part2 = load_all(&licensed_root("IS2"));
assert_eq!(part2.missions, 31); assert_eq!(part2.missions, 31);
assert_eq!(part2.paths, 61); assert_eq!(part2.paths, 61);
assert_eq!(part2.clans, 91); assert_eq!(part2.clans, 91);
@@ -923,7 +927,7 @@ mod tests {
} }
fn run_headless_case(case: HeadlessCase) -> WorldSnapshot { fn run_headless_case(case: HeadlessCase) -> WorldSnapshot {
let root = workspace_root().join("testdata").join(case.root); let root = licensed_root(case.root);
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
let mut engine = create( let mut engine = create(
EngineConfig { EngineConfig {
@@ -1043,12 +1047,21 @@ mod tests {
} }
} }
fn workspace_root() -> PathBuf { fn licensed_root(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")) let variable = match name {
.parent() "IS" => "FPARKAN_CORPUS_PART1_ROOT",
.and_then(Path::parent) "IS2" => "FPARKAN_CORPUS_PART2_ROOT",
.expect("workspace root") _ => panic!("unknown licensed corpus part: {name}"),
.to_path_buf() };
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
+21 -15
View File
@@ -1488,14 +1488,13 @@ Generator 1
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_land_msh_validate() { fn licensed_corpus_land_msh_validate() {
for (corpus, expected_files, expected_vertices, expected_faces) in [ for (corpus, expected_files, expected_vertices, expected_faces) in [
("IS", 33_usize, 299_450_usize, 275_882_usize), ("IS", 33_usize, 299_450_usize, 275_882_usize),
("IS2", 32_usize, 188_024_usize, 184_454_usize), ("IS2", 32_usize, 188_024_usize, 184_454_usize),
] { ] {
let Some(root) = corpus_root(corpus) else { let root = corpus_root(corpus);
continue;
};
let mut files = 0usize; let mut files = 0usize;
let mut vertices = 0usize; let mut vertices = 0usize;
let mut faces = 0usize; let mut faces = 0usize;
@@ -1536,11 +1535,10 @@ Generator 1
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_build_dat_validate() { fn licensed_corpus_build_dat_validate() {
for (corpus, expected_ai_prefix) in [("IS", false), ("IS2", true)] { for (corpus, expected_ai_prefix) in [("IS", false), ("IS2", true)] {
let Some(root) = corpus_root(corpus) else { let root = corpus_root(corpus);
continue;
};
let path = root.join("BuildDat.lst"); let path = root.join("BuildDat.lst");
let bytes = std::fs::read(&path).expect("read BuildDat.lst"); let bytes = std::fs::read(&path).expect("read BuildDat.lst");
let categories = let categories =
@@ -1583,14 +1581,13 @@ Generator 1
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_land_map_validate() { fn licensed_corpus_land_map_validate() {
for (corpus, expected_files, expected_areals, expected_vertices, expected_max_hits) in [ for (corpus, expected_files, expected_areals, expected_vertices, expected_max_hits) in [
("IS", 33_usize, 34_662_usize, 197_698_usize, 20_usize), ("IS", 33_usize, 34_662_usize, 197_698_usize, 20_usize),
("IS2", 32_usize, 18_984_usize, 114_968_usize, 14_usize), ("IS2", 32_usize, 18_984_usize, 114_968_usize, 14_usize),
] { ] {
let Some(root) = corpus_root(corpus) else { let root = corpus_root(corpus);
continue;
};
let mut files = 0usize; let mut files = 0usize;
let mut areals = 0usize; let mut areals = 0usize;
let mut vertices = 0usize; let mut vertices = 0usize;
@@ -1880,12 +1877,21 @@ Generator 1
out.extend_from_slice(&value.to_le_bytes()); out.extend_from_slice(&value.to_le_bytes());
} }
fn corpus_root(name: &str) -> Option<PathBuf> { fn corpus_root(name: &str) -> PathBuf {
let root = Path::new(env!("CARGO_MANIFEST_DIR")) let variable = match name {
.join("../..") "IS" => "FPARKAN_CORPUS_PART1_ROOT",
.join("testdata") "IS2" => "FPARKAN_CORPUS_PART2_ROOT",
.join(name); _ => panic!("unknown licensed corpus part: {name}"),
root.is_dir().then_some(root) };
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
} }
fn files_under(root: &Path) -> Vec<PathBuf> { fn files_under(root: &Path) -> Vec<PathBuf> {
+19 -12
View File
@@ -794,14 +794,13 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_land_maps_build_navigation_worlds() { fn licensed_corpus_land_maps_build_navigation_worlds() {
for (corpus, expected_files, expected_areals) in [ for (corpus, expected_files, expected_areals) in [
("IS", 33_usize, 34_662_usize), ("IS", 33_usize, 34_662_usize),
("IS2", 32_usize, 18_984_usize), ("IS2", 32_usize, 18_984_usize),
] { ] {
let Some(root) = corpus_root(corpus) else { let root = corpus_root(corpus);
continue;
};
let mut files = 0usize; let mut files = 0usize;
let mut areals = 0usize; let mut areals = 0usize;
let mut located_centers = 0usize; let mut located_centers = 0usize;
@@ -849,14 +848,13 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_land_meshes_build_surface_worlds() { fn licensed_corpus_land_meshes_build_surface_worlds() {
for (corpus, expected_files, expected_faces) in [ for (corpus, expected_files, expected_faces) in [
("IS", 33_usize, 275_882_usize), ("IS", 33_usize, 275_882_usize),
("IS2", 32_usize, 184_454_usize), ("IS2", 32_usize, 184_454_usize),
] { ] {
let Some(root) = corpus_root(corpus) else { let root = corpus_root(corpus);
continue;
};
let mut files = 0usize; let mut files = 0usize;
let mut faces = 0usize; let mut faces = 0usize;
for path in files_under(&root) { for path in files_under(&root) {
@@ -1049,12 +1047,21 @@ mod tests {
vertices.first().copied() vertices.first().copied()
} }
fn corpus_root(name: &str) -> Option<PathBuf> { fn corpus_root(name: &str) -> PathBuf {
let root = Path::new(env!("CARGO_MANIFEST_DIR")) let variable = match name {
.join("../..") "IS" => "FPARKAN_CORPUS_PART1_ROOT",
.join("testdata") "IS2" => "FPARKAN_CORPUS_PART2_ROOT",
.join(name); _ => panic!("unknown licensed corpus part: {name}"),
root.is_dir().then_some(root) };
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
} }
fn files_under(root: &Path) -> Vec<PathBuf> { fn files_under(root: &Path) -> Vec<PathBuf> {
+17 -9
View File
@@ -1071,11 +1071,10 @@ mod tests {
} }
#[test] #[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_texm_assets_validate_and_decode_mip0() { fn licensed_corpus_texm_assets_validate_and_decode_mip0() {
for (corpus, expected) in [("IS", 518_usize), ("IS2", 631_usize)] { for (corpus, expected) in [("IS", 518_usize), ("IS2", 631_usize)] {
let Some(root) = corpus_root(corpus) else { let root = corpus_root(corpus);
continue;
};
let mut count = 0usize; let mut count = 0usize;
for path in files_under(&root) { for path in files_under(&root) {
let Ok(bytes) = std::fs::read(&path) else { let Ok(bytes) = std::fs::read(&path) else {
@@ -1157,12 +1156,21 @@ mod tests {
} }
} }
fn corpus_root(name: &str) -> Option<PathBuf> { fn corpus_root(name: &str) -> PathBuf {
let root = Path::new(env!("CARGO_MANIFEST_DIR")) let variable = match name {
.join("../..") "IS" => "FPARKAN_CORPUS_PART1_ROOT",
.join("testdata") "IS2" => "FPARKAN_CORPUS_PART2_ROOT",
.join(name); _ => panic!("unknown licensed corpus part: {name}"),
root.is_dir().then_some(root) };
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
} }
fn files_under(root: &Path) -> Vec<PathBuf> { fn files_under(root: &Path) -> Vec<PathBuf> {
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-binary = { path = "../fparkan-binary" }
fparkan-path = { path = "../fparkan-path" } fparkan-path = { path = "../fparkan-path" }
[lints] [lints]
+211 -52
View File
@@ -1,19 +1,21 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
//! Virtual filesystem ports for resource loading. //! Virtual filesystem ports for resource loading.
use fparkan_path::{join_under, NormalizedPath}; use fparkan_binary::{sha256, Sha256Digest};
use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::{Arc, Mutex};
use std::time::SystemTime;
/// VFS metadata. /// VFS metadata.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct VfsMetadata { pub struct VfsMetadata {
/// Byte length. /// Byte length.
pub len: u64, pub len: u64,
/// Stable-enough source fingerprint for cache invalidation. /// SHA-256 content fingerprint for cache invalidation.
pub fingerprint: u64, pub fingerprint: Sha256Digest,
} }
/// VFS entry. /// VFS entry.
@@ -80,6 +82,7 @@ pub trait Vfs: Send + Sync {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct DirectoryVfs { pub struct DirectoryVfs {
root: PathBuf, root: PathBuf,
fingerprint_cache: Arc<Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>>,
} }
impl DirectoryVfs { impl DirectoryVfs {
@@ -88,26 +91,43 @@ impl DirectoryVfs {
pub fn new(root: impl AsRef<Path>) -> Self { pub fn new(root: impl AsRef<Path>) -> Self {
Self { Self {
root: root.as_ref().to_path_buf(), root: root.as_ref().to_path_buf(),
fingerprint_cache: Arc::default(),
} }
} }
fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> { fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> {
let exact = join_under(&self.root, path).map_err(|_| VfsError::Path)?; join_under(&self.root, path).map_err(|_| VfsError::Path)?;
if exact.exists() {
return Ok(exact);
}
resolve_casefolded(&self.root, path.as_str()) resolve_casefolded(&self.root, path.as_str())
} }
fn metadata_from_host_file(&self, path: &Path) -> Result<VfsMetadata, VfsError> {
let metadata = fs::symlink_metadata(path).map_err(VfsError::Io)?;
metadata_from_host_file_with_cache(path, &metadata, &self.fingerprint_cache)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct CachedHostFingerprint {
len: u64,
modified: Option<SystemTime>,
fingerprint: Sha256Digest,
} }
impl Vfs for DirectoryVfs { impl Vfs for DirectoryVfs {
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> { fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
let meta = fs::metadata(self.host_path(path)?).map_err(VfsError::Io)?; self.metadata_from_host_file(&self.host_path(path)?)
Ok(metadata_from_fs(&meta))
} }
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> { fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
let bytes = fs::read(self.host_path(path)?).map_err(VfsError::Io)?; let host = self.host_path(path)?;
if fs::symlink_metadata(&host)
.map_err(VfsError::Io)?
.file_type()
.is_symlink()
{
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()))
} }
@@ -115,14 +135,18 @@ impl Vfs for DirectoryVfs {
let base = self.host_path(prefix)?; let base = self.host_path(prefix)?;
let mut entries = Vec::new(); let mut entries = Vec::new();
if base.is_file() { if base.is_file() {
let metadata = fs::metadata(&base).map_err(VfsError::Io)?; let metadata = fs::symlink_metadata(&base).map_err(VfsError::Io)?;
entries.push(VfsEntry { entries.push(VfsEntry {
path: prefix.clone(), path: prefix.clone(),
metadata: metadata_from_fs(&metadata), metadata: metadata_from_host_file_with_cache(
&base,
&metadata,
&self.fingerprint_cache,
)?,
}); });
return Ok(entries); return Ok(entries);
} }
list_recursive(&self.root, &base, &mut entries)?; list_recursive(&self.root, &base, &self.fingerprint_cache, &mut entries)?;
entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str())); entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
Ok(entries) Ok(entries)
} }
@@ -140,6 +164,9 @@ fn resolve_casefolded(root: &Path, normalized: &str) -> Result<PathBuf, VfsError
continue; continue;
}; };
if name.eq_ignore_ascii_case(segment) { if name.eq_ignore_ascii_case(segment) {
if entry.file_type().map_err(VfsError::Io)?.is_symlink() {
return Err(VfsError::Path);
}
matches.push(entry.path()); matches.push(entry.path());
} }
} }
@@ -166,7 +193,12 @@ fn select_casefolded_match(
} }
} }
fn list_recursive(root: &Path, dir: &Path, out: &mut Vec<VfsEntry>) -> Result<(), VfsError> { fn list_recursive(
root: &Path,
dir: &Path,
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
out: &mut Vec<VfsEntry>,
) -> Result<(), VfsError> {
let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?; let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?;
let mut children = Vec::new(); let mut children = Vec::new();
for entry in read_dir { for entry in read_dir {
@@ -175,9 +207,12 @@ fn list_recursive(root: &Path, dir: &Path, out: &mut Vec<VfsEntry>) -> Result<()
} }
children.sort(); children.sort();
for child in children { for child in children {
let metadata = fs::metadata(&child).map_err(VfsError::Io)?; let metadata = fs::symlink_metadata(&child).map_err(VfsError::Io)?;
if metadata.file_type().is_symlink() {
return Err(VfsError::Path);
}
if metadata.is_dir() { if metadata.is_dir() {
list_recursive(root, &child, out)?; list_recursive(root, &child, fingerprint_cache, out)?;
continue; continue;
} }
if !metadata.is_file() { if !metadata.is_file() {
@@ -192,56 +227,111 @@ fn list_recursive(root: &Path, dir: &Path, out: &mut Vec<VfsEntry>) -> Result<()
.map_err(|_| VfsError::Path)?; .map_err(|_| VfsError::Path)?;
out.push(VfsEntry { out.push(VfsEntry {
path, path,
metadata: metadata_from_fs(&metadata), metadata: metadata_from_host_file_with_cache(&child, &metadata, fingerprint_cache)?,
}); });
} }
Ok(()) Ok(())
} }
fn metadata_from_fs(metadata: &fs::Metadata) -> VfsMetadata { fn metadata_from_host_file_with_cache(
let mut fingerprint = 0xcbf2_9ce4_8422_2325; path: &Path,
hash_u64(&mut fingerprint, metadata.len()); metadata: &fs::Metadata,
if let Ok(modified) = metadata.modified() { fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) { ) -> Result<VfsMetadata, VfsError> {
hash_u64(&mut fingerprint, duration.as_secs()); if !metadata.is_file() {
hash_u64(&mut fingerprint, u64::from(duration.subsec_nanos())); return Err(VfsError::Path);
}
} }
VfsMetadata { let len = metadata.len();
len: metadata.len(), let modified = metadata.modified().ok();
fingerprint, if let Some(cached) = fingerprint_cache
.lock()
.map_err(|_| VfsError::Path)?
.get(path)
.cloned()
.filter(|cached| cached.len == len && cached.modified == modified)
{
return Ok(VfsMetadata {
len,
fingerprint: cached.fingerprint,
});
} }
let bytes = fs::read(path).map_err(VfsError::Io)?;
let fingerprint = sha256(&bytes);
fingerprint_cache
.lock()
.map_err(|_| VfsError::Path)?
.insert(
path.to_path_buf(),
CachedHostFingerprint {
len,
modified,
fingerprint,
},
);
Ok(VfsMetadata { len, fingerprint })
} }
/// 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<String, Arc<[u8]>>,
lookup: BTreeMap<Vec<u8>, Vec<String>>,
} }
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]>) {
self.files.insert(path.as_str().to_string(), bytes); let path = path.as_str().to_string();
self.files.insert(path, bytes);
self.rebuild_lookup();
}
fn rebuild_lookup(&mut self) {
self.lookup.clear();
for path in self.files.keys() {
self.lookup
.entry(ascii_lookup_key(path.as_bytes()).0)
.or_default()
.push(path.clone());
}
for paths in self.lookup.values_mut() {
paths.sort();
}
}
fn resolve_path(&self, path: &NormalizedPath) -> Result<&str, VfsError> {
let key = ascii_lookup_key(path.as_str().as_bytes()).0;
let matches = self
.lookup
.get(&key)
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
match matches.as_slice() {
[single] => Ok(single.as_str()),
[] => Err(VfsError::NotFound(path.as_str().to_string())),
_ => Err(VfsError::Ambiguous(path.as_str().to_string())),
}
} }
} }
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 bytes = self let bytes = self
.files .files
.get(path.as_str()) .get(resolved)
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?; .ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
Ok(VfsMetadata { Ok(VfsMetadata {
len: bytes.len() as u64, len: bytes.len() as u64,
fingerprint: stable_hash(bytes), fingerprint: sha256(bytes),
}) })
} }
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> { fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
let resolved = self.resolve_path(path)?;
self.files self.files
.get(path.as_str()) .get(resolved)
.cloned() .cloned()
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string())) .ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))
} }
@@ -263,7 +353,7 @@ impl Vfs for MemoryVfs {
path: normalized, path: normalized,
metadata: VfsMetadata { metadata: VfsMetadata {
len: bytes.len() as u64, len: bytes.len() as u64,
fingerprint: stable_hash(bytes), fingerprint: sha256(bytes),
}, },
}); });
} }
@@ -272,22 +362,6 @@ impl Vfs for MemoryVfs {
} }
} }
fn stable_hash(bytes: &[u8]) -> u64 {
let mut state = 0xcbf2_9ce4_8422_2325;
for byte in bytes {
state ^= u64::from(*byte);
state = state.wrapping_mul(0x0000_0100_0000_01b3);
}
state
}
fn hash_u64(state: &mut u64, value: u64) {
for byte in value.to_le_bytes() {
*state ^= u64::from(byte);
*state = state.wrapping_mul(0x0000_0100_0000_01b3);
}
}
/// 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 {
@@ -384,6 +458,36 @@ mod tests {
std::fs::remove_dir_all(root).expect("cleanup"); std::fs::remove_dir_all(root).expect("cleanup");
} }
#[test]
fn directory_vfs_reports_casefold_ambiguity_even_for_exact_host_path() {
let root = unique_test_dir("casefold-ambiguous");
std::fs::create_dir_all(root.join("Data")).expect("mkdir first");
std::fs::create_dir_all(root.join("data")).expect("mkdir second");
std::fs::write(root.join("Data").join("File.bin"), b"first").expect("write first");
std::fs::write(root.join("data").join("File.bin"), b"second").expect("write second");
let collision_count = std::fs::read_dir(&root)
.expect("read root")
.flatten()
.filter(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| name.eq_ignore_ascii_case("data"))
})
.count();
if collision_count < 2 {
std::fs::remove_dir_all(root).expect("cleanup");
return;
}
let vfs = DirectoryVfs::new(&root);
let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path");
assert!(matches!(vfs.read(&path), Err(VfsError::Ambiguous(_))));
std::fs::remove_dir_all(root).expect("cleanup");
}
#[test] #[test]
fn directory_vfs_lists_files_below_prefix() { fn directory_vfs_lists_files_below_prefix() {
let root = unique_test_dir("list"); let root = unique_test_dir("list");
@@ -403,6 +507,45 @@ mod tests {
std::fs::remove_dir_all(root).expect("cleanup"); std::fs::remove_dir_all(root).expect("cleanup");
} }
#[test]
fn directory_vfs_fingerprint_changes_for_same_length_content() {
let root = unique_test_dir("content-fingerprint");
std::fs::create_dir_all(root.join("DATA")).expect("mkdir");
std::fs::write(root.join("DATA").join("File.bin"), b"before").expect("write before");
let vfs = DirectoryVfs::new(&root);
let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path");
let before = vfs.metadata(&path).expect("before metadata");
std::fs::write(root.join("DATA").join("File.bin"), b"after!").expect("write after");
let after = vfs.metadata(&path).expect("after metadata");
assert_eq!(before.len, after.len);
assert_ne!(before.fingerprint, after.fingerprint);
std::fs::remove_dir_all(root).expect("cleanup");
}
#[cfg(unix)]
#[test]
fn directory_vfs_rejects_symlink_escape() {
let root = unique_test_dir("symlink-escape");
let outside = unique_test_dir("symlink-outside");
std::fs::create_dir_all(&root).expect("mkdir root");
std::fs::create_dir_all(&outside).expect("mkdir outside");
std::fs::write(outside.join("secret.bin"), b"secret").expect("write outside");
std::os::unix::fs::symlink(&outside, root.join("DATA")).expect("symlink");
let vfs = DirectoryVfs::new(&root);
let path = normalize_relative(b"DATA/secret.bin", PathPolicy::StrictLegacy).expect("path");
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
assert!(matches!(vfs.read(&path), Err(VfsError::Path)));
assert!(matches!(vfs.list(&prefix), Err(VfsError::Path)));
std::fs::remove_dir_all(root).expect("cleanup root");
std::fs::remove_dir_all(outside).expect("cleanup outside");
}
#[test] #[test]
fn casefold_selector_reports_ambiguous_segments() { fn casefold_selector_reports_ambiguous_segments() {
let err = select_casefolded_match( let err = select_casefolded_match(
@@ -417,7 +560,7 @@ mod tests {
} }
#[test] #[test]
fn memory_vfs_uses_exact_lookup() { fn memory_vfs_uses_ascii_casefold_lookup() {
let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path"); let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path");
let mut vfs = MemoryVfs::default(); let mut vfs = MemoryVfs::default();
vfs.insert(path.clone(), Arc::from(b"payload".as_slice())); vfs.insert(path.clone(), Arc::from(b"payload".as_slice()));
@@ -427,7 +570,23 @@ mod tests {
let other_case = let other_case =
normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("path"); normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("path");
assert!(matches!(vfs.read(&other_case), Err(VfsError::NotFound(_)))); assert_eq!(
vfs.read(&other_case).expect("casefold read").as_ref(),
b"payload"
);
}
#[test]
fn memory_vfs_reports_casefold_ambiguity() {
let first = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("first");
let second =
normalize_relative(b"DATA/file.BIN", PathPolicy::StrictLegacy).expect("second");
let query = normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("query");
let mut vfs = MemoryVfs::default();
vfs.insert(first, Arc::from(b"first".as_slice()));
vfs.insert(second, Arc::from(b"second".as_slice()));
assert!(matches!(vfs.read(&query), Err(VfsError::Ambiguous(_))));
} }
#[test] #[test]
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-binary = { path = "../fparkan-binary" }
[lints] [lints]
workspace = true workspace = true
+306 -57
View File
@@ -1,8 +1,11 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
//! Deterministic world identity, queue, lifecycle, and snapshots. //! Deterministic world identity, queue, lifecycle, and snapshots.
use fparkan_binary::sha256;
use std::collections::VecDeque; use std::collections::VecDeque;
const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v2\0";
/// Object handle with generation. /// Object handle with generation.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct ObjectHandle { pub struct ObjectHandle {
@@ -105,6 +108,8 @@ pub struct FixedStepClock {
tick: Tick, tick: Tick,
paused: bool, paused: bool,
platform_event_collections: u64, platform_event_collections: u64,
dropped_presentation_millis: u64,
dropped_presentation_frames: u64,
} }
/// Fixed-step configuration. /// Fixed-step configuration.
@@ -112,11 +117,16 @@ pub struct FixedStepClock {
pub struct FixedStepConfig { pub struct FixedStepConfig {
/// Milliseconds per simulation tick. /// Milliseconds per simulation tick.
pub step_millis: u32, pub step_millis: u32,
/// Maximum simulation ticks executed for a single presentation frame.
pub max_steps_per_frame: u32,
} }
impl Default for FixedStepConfig { impl Default for FixedStepConfig {
fn default() -> Self { fn default() -> Self {
Self { step_millis: 16 } Self {
step_millis: 16,
max_steps_per_frame: 8,
}
} }
} }
@@ -169,7 +179,17 @@ pub enum WorldError {
impl std::fmt::Display for WorldError { impl std::fmt::Display for WorldError {
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::InvalidHandle => write!(f, "object handle does not reference a known slot"),
Self::StaleHandle => write!(f, "object handle belongs to an older slot generation"),
Self::Deleted => write!(f, "object has already been deleted"),
Self::DuplicateOriginalObjectId(id) => {
write!(f, "original object id {} is already registered", id.0)
}
Self::InvalidFixedStep => {
write!(f, "fixed-step configuration values must be non-zero")
}
}
} }
} }
@@ -357,68 +377,90 @@ pub fn step_with_handler<F>(
where where
F: FnMut(&mut World, &WorldCommand) -> Result<(), WorldError>, F: FnMut(&mut World, &WorldCommand) -> Result<(), WorldError>,
{ {
let before = world.clone();
world.phase = WorldPhase::Calculating; world.phase = WorldPhase::Calculating;
let mut events = Vec::new(); let mut events = Vec::new();
while let Some(command) = world.queue.pop_front() { let result = (|| {
if let Some(handle) = command.target { while let Some(command) = world.queue.pop_front() {
if world.deferred_delete.contains(&handle) { if let Some(handle) = command.target {
continue; if world.deferred_delete.contains(&handle) {
continue;
}
checked_slot(world, handle)?;
} }
checked_slot(world, handle)?; handler(world, &command)?;
events.push(WorldEvent {
sequence: command.sequence,
target: command.target,
});
} }
handler(world, &command)?; world.phase = WorldPhase::ApplyingDeferred;
events.push(WorldEvent { let deletes = std::mem::take(&mut world.deferred_delete);
sequence: command.sequence, for handle in deletes {
target: command.target, delete_now(world, handle)?;
}); }
world.tick.0 = world.tick.0.saturating_add(1);
world.phase = WorldPhase::PublishingSnapshot;
let snapshot = WorldSnapshot {
tick: world.tick,
objects: live_registered(world),
events,
hash: canonical_state_hash(world),
};
world.phase = WorldPhase::Idle;
Ok(snapshot)
})();
if let Err(err) = result {
*world = before;
world.phase = WorldPhase::Idle;
return Err(err);
} }
world.phase = WorldPhase::ApplyingDeferred; result
let deletes = std::mem::take(&mut world.deferred_delete);
for handle in deletes {
let _ = delete_now(world, handle);
}
world.tick.0 = world.tick.0.saturating_add(1);
world.phase = WorldPhase::PublishingSnapshot;
let snapshot = WorldSnapshot {
tick: world.tick,
objects: live_registered(world),
events,
hash: canonical_state_hash(world),
};
world.phase = WorldPhase::Idle;
Ok(snapshot)
} }
/// Computes canonical state hash. /// Computes canonical state hash.
#[must_use] #[must_use]
pub fn canonical_state_hash(world: &World) -> StateHash { pub fn canonical_state_hash(world: &World) -> StateHash {
let mut state = 0xcbf2_9ce4_8422_2325_u64; StateHash(sha256(&canonical_state_bytes(world)))
hash_u64(&mut state, world.tick.0); }
fn canonical_state_bytes(world: &World) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(WORLD_STATE_HASH_SCHEMA);
push_u64(&mut out, world.tick.0);
push_u64(&mut out, world.next_sequence);
push_u64(&mut out, world.next_registration_sequence);
push_len(&mut out, world.slots.len());
for (idx, slot) in world.slots.iter().enumerate() { for (idx, slot) in world.slots.iter().enumerate() {
hash_u64(&mut state, idx as u64); push_len(&mut out, idx);
hash_u64(&mut state, u64::from(slot.generation)); push_u32(&mut out, slot.generation);
hash_u64(&mut state, u64::from(u8::from(slot.live))); push_bool(&mut out, slot.live);
hash_u64(&mut state, u64::from(u8::from(slot.registered))); push_bool(&mut out, slot.registered);
hash_u64(&mut state, slot.original_id.map_or(0, |id| u64::from(id.0))); push_optional_u32(&mut out, slot.original_id.map(|id| id.0));
hash_u64(&mut state, slot.mirror_id.map_or(0, |id| u64::from(id.0))); push_optional_u32(&mut out, slot.mirror_id.map(|id| id.0));
hash_u64(&mut state, slot.owner_id.map_or(0, |id| u64::from(id.0))); push_optional_u16(&mut out, slot.owner_id.map(|id| id.0));
hash_u64(&mut state, slot.registration_sequence.unwrap_or(u64::MAX)); push_optional_u64(&mut out, slot.registration_sequence);
} }
let mut out = [0; 32]; push_len(&mut out, world.queue.len());
out[..8].copy_from_slice(&state.to_le_bytes()); for command in &world.queue {
out[8..16].copy_from_slice(&state.rotate_left(13).to_le_bytes()); push_u64(&mut out, command.sequence);
out[16..24].copy_from_slice(&state.rotate_left(29).to_le_bytes()); push_optional_handle(&mut out, command.target);
out[24..32].copy_from_slice(&state.rotate_left(47).to_le_bytes()); }
StateHash(out) push_len(&mut out, world.deferred_delete.len());
for handle in &world.deferred_delete {
push_handle(&mut out, *handle);
}
out
} }
/// Creates a fixed-step clock. /// Creates a fixed-step clock.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns [`WorldError::InvalidFixedStep`] when the configured step is zero. /// Returns [`WorldError::InvalidFixedStep`] when the configured step or
/// per-frame catch-up limit is zero.
pub fn fixed_step_clock(config: FixedStepConfig) -> Result<FixedStepClock, WorldError> { pub fn fixed_step_clock(config: FixedStepConfig) -> Result<FixedStepClock, WorldError> {
if config.step_millis == 0 { if config.step_millis == 0 || config.max_steps_per_frame == 0 {
return Err(WorldError::InvalidFixedStep); return Err(WorldError::InvalidFixedStep);
} }
Ok(FixedStepClock { Ok(FixedStepClock {
@@ -426,6 +468,8 @@ pub fn fixed_step_clock(config: FixedStepConfig) -> Result<FixedStepClock, World
tick: Tick(0), tick: Tick(0),
paused: false, paused: false,
platform_event_collections: 0, platform_event_collections: 0,
dropped_presentation_millis: 0,
dropped_presentation_frames: 0,
}) })
} }
@@ -445,13 +489,14 @@ pub fn set_paused(clock: &mut FixedStepClock, paused: bool) {
/// ///
/// # Errors /// # Errors
/// ///
/// Returns [`WorldError::InvalidFixedStep`] when the configured step is zero. /// Returns [`WorldError::InvalidFixedStep`] when the configured step or
/// per-frame catch-up limit is zero.
pub fn advance_fixed_step( pub fn advance_fixed_step(
clock: &mut FixedStepClock, clock: &mut FixedStepClock,
config: FixedStepConfig, config: FixedStepConfig,
elapsed_millis: u64, elapsed_millis: u64,
) -> Result<u32, WorldError> { ) -> Result<u32, WorldError> {
if config.step_millis == 0 { if config.step_millis == 0 || config.max_steps_per_frame == 0 {
return Err(WorldError::InvalidFixedStep); return Err(WorldError::InvalidFixedStep);
} }
if clock.paused { if clock.paused {
@@ -459,12 +504,20 @@ pub fn advance_fixed_step(
} }
clock.accumulated_millis = clock.accumulated_millis.saturating_add(elapsed_millis); clock.accumulated_millis = clock.accumulated_millis.saturating_add(elapsed_millis);
let step = u64::from(config.step_millis); let step = u64::from(config.step_millis);
let mut ticks = 0_u32; let available_steps = clock.accumulated_millis / step;
while clock.accumulated_millis >= step { let ticks_u64 = available_steps.min(u64::from(config.max_steps_per_frame));
clock.accumulated_millis -= step; let consumed = ticks_u64.saturating_mul(step);
clock.tick.0 = clock.tick.0.saturating_add(1); if available_steps > u64::from(config.max_steps_per_frame) {
ticks = ticks.saturating_add(1); let dropped = clock.accumulated_millis.saturating_sub(consumed);
clock.dropped_presentation_millis =
clock.dropped_presentation_millis.saturating_add(dropped);
clock.dropped_presentation_frames = clock.dropped_presentation_frames.saturating_add(1);
clock.accumulated_millis = 0;
} else {
clock.accumulated_millis = clock.accumulated_millis.saturating_sub(consumed);
} }
let ticks = u32::try_from(ticks_u64).unwrap_or(u32::MAX);
clock.tick.0 = clock.tick.0.saturating_add(ticks_u64);
Ok(ticks) Ok(ticks)
} }
@@ -480,6 +533,18 @@ pub fn platform_event_collections(clock: &FixedStepClock) -> u64 {
clock.platform_event_collections clock.platform_event_collections
} }
/// Returns total presentation time dropped by fixed-step catch-up limits.
#[must_use]
pub fn dropped_presentation_millis(clock: &FixedStepClock) -> u64 {
clock.dropped_presentation_millis
}
/// Returns how many presentation frames exceeded fixed-step catch-up limits.
#[must_use]
pub fn dropped_presentation_frames(clock: &FixedStepClock) -> u64 {
clock.dropped_presentation_frames
}
/// Runs end-frame callbacks in stable sequence order. /// Runs end-frame callbacks in stable sequence order.
#[must_use] #[must_use]
pub fn end_frame_callback_order(mut callbacks: Vec<WorldEvent>) -> Vec<u64> { pub fn end_frame_callback_order(mut callbacks: Vec<WorldEvent>) -> Vec<u64> {
@@ -502,13 +567,59 @@ pub fn shutdown(mut world: World) -> ShutdownReport {
} }
} }
fn hash_u64(state: &mut u64, value: u64) { fn push_len(out: &mut Vec<u8>, value: usize) {
for byte in value.to_le_bytes() { push_u64(out, u64::try_from(value).unwrap_or(u64::MAX));
*state ^= u64::from(byte); }
*state = state.wrapping_mul(0x0000_0100_0000_01b3);
fn push_u64(out: &mut Vec<u8>, value: u64) {
out.extend_from_slice(&value.to_le_bytes());
}
fn push_u32(out: &mut Vec<u8>, value: u32) {
out.extend_from_slice(&value.to_le_bytes());
}
fn push_u16(out: &mut Vec<u8>, value: u16) {
out.extend_from_slice(&value.to_le_bytes());
}
fn push_bool(out: &mut Vec<u8>, value: bool) {
out.push(u8::from(value));
}
fn push_optional_u64(out: &mut Vec<u8>, value: Option<u64>) {
push_bool(out, value.is_some());
if let Some(value) = value {
push_u64(out, value);
} }
} }
fn push_optional_u32(out: &mut Vec<u8>, value: Option<u32>) {
push_bool(out, value.is_some());
if let Some(value) = value {
push_u32(out, value);
}
}
fn push_optional_u16(out: &mut Vec<u8>, value: Option<u16>) {
push_bool(out, value.is_some());
if let Some(value) = value {
push_u16(out, value);
}
}
fn push_optional_handle(out: &mut Vec<u8>, handle: Option<ObjectHandle>) {
push_bool(out, handle.is_some());
if let Some(handle) = handle {
push_handle(out, handle);
}
}
fn push_handle(out: &mut Vec<u8>, handle: ObjectHandle) {
push_u32(out, handle.generation);
push_u32(out, handle.slot);
}
fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> { fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> {
let slot = world let slot = world
.slots .slots
@@ -623,6 +734,18 @@ mod tests {
); );
} }
#[test]
fn world_error_display_is_actionable() {
assert_eq!(
WorldError::StaleHandle.to_string(),
"object handle belongs to an older slot generation"
);
assert_eq!(
WorldError::DuplicateOriginalObjectId(OriginalObjectId(8)).to_string(),
"original object id 8 is already registered"
);
}
#[test] #[test]
fn identity_metadata_keeps_original_mirror_and_owner_distinct() { fn identity_metadata_keeps_original_mirror_and_owner_distinct() {
let mut world = new(WorldConfig); let mut world = new(WorldConfig);
@@ -710,6 +833,44 @@ mod tests {
); );
} }
#[test]
fn callback_error_rolls_back_phase_queue_and_deferred_deletes() {
let mut world = new(WorldConfig);
let first = construct_object(&mut world, ObjectDraft { original_id: None }).expect("first");
register_object(&mut world, first).expect("register");
enqueue(
&mut world,
WorldCommand {
sequence: 7,
target: Some(first),
},
)
.expect("enqueue");
let err = step_with_handler(&mut world, &InputSnapshot, |world, _| {
request_delete(world, first)?;
Err(WorldError::InvalidFixedStep)
})
.expect_err("handler error");
assert_eq!(err, WorldError::InvalidFixedStep);
assert_eq!(world.phase, WorldPhase::Idle);
assert_eq!(world.tick, Tick(0));
assert!(world.deferred_delete.is_empty());
assert_eq!(world.queue.len(), 1);
let snapshot = step(&mut world, &InputSnapshot).expect("retry step");
assert_eq!(snapshot.tick, Tick(1));
assert_eq!(
snapshot.events,
vec![WorldEvent {
sequence: 0,
target: Some(first)
}]
);
assert_eq!(snapshot.objects, vec![first]);
}
#[test] #[test]
fn snapshot_hash_determinism_and_immutability() { fn snapshot_hash_determinism_and_immutability() {
let mut left = new(WorldConfig); let mut left = new(WorldConfig);
@@ -736,9 +897,40 @@ mod tests {
); );
} }
#[test]
fn state_hash_uses_canonical_sha256_instead_of_legacy_rotated_fnv() {
let mut world = new(WorldConfig);
let handle = construct_object(
&mut world,
ObjectDraft {
original_id: Some(OriginalObjectId(42)),
},
)
.expect("object");
set_mirror_original(&mut world, handle, Some(OriginalObjectId(420))).expect("mirror");
set_owner(&mut world, handle, Some(OwnerId(9))).expect("owner");
register_object(&mut world, handle).expect("register");
enqueue(
&mut world,
WorldCommand {
sequence: 999,
target: Some(handle),
},
)
.expect("enqueue");
let snapshot = step(&mut world, &InputSnapshot).expect("step");
assert_ne!(snapshot.hash, legacy_rotated_fnv_hash(&world));
assert_ne!(snapshot.hash, canonical_state_hash(&new(WorldConfig)));
}
#[test] #[test]
fn fixed_step_pause_and_long_determinism_are_stable() { fn fixed_step_pause_and_long_determinism_are_stable() {
let config = FixedStepConfig { step_millis: 20 }; let config = FixedStepConfig {
step_millis: 20,
max_steps_per_frame: 8,
};
let mut clock = fixed_step_clock(config).expect("clock"); let mut clock = fixed_step_clock(config).expect("clock");
collect_platform_events(&mut clock); collect_platform_events(&mut clock);
set_paused(&mut clock, true); set_paused(&mut clock, true);
@@ -762,6 +954,32 @@ mod tests {
assert_eq!(first_hashes, second_hashes); assert_eq!(first_hashes, second_hashes);
} }
#[test]
fn fixed_step_catch_up_is_capped_and_reports_dropped_time() {
let config = FixedStepConfig {
step_millis: 20,
max_steps_per_frame: 3,
};
let mut clock = fixed_step_clock(config).expect("clock");
assert_eq!(advance_fixed_step(&mut clock, config, 95), Ok(3));
assert_eq!(fixed_step_tick(&clock), Tick(3));
assert_eq!(dropped_presentation_millis(&clock), 35);
assert_eq!(dropped_presentation_frames(&clock), 1);
assert_eq!(advance_fixed_step(&mut clock, config, 10), Ok(0));
assert_eq!(advance_fixed_step(&mut clock, config, 10), Ok(1));
assert_eq!(fixed_step_tick(&clock), Tick(4));
assert_eq!(dropped_presentation_millis(&clock), 35);
assert_eq!(dropped_presentation_frames(&clock), 1);
assert_eq!(
advance_fixed_step(&mut clock, config, u64::MAX),
Ok(config.max_steps_per_frame)
);
assert_eq!(dropped_presentation_frames(&clock), 2);
}
#[test] #[test]
fn render_disabled_does_not_change_hash_end_callbacks_and_shutdown_order() { fn render_disabled_does_not_change_hash_end_callbacks_and_shutdown_order() {
let callbacks = vec![ let callbacks = vec![
@@ -837,4 +1055,35 @@ mod tests {
} }
} }
} }
fn legacy_rotated_fnv_hash(world: &World) -> StateHash {
let mut state = 0xcbf2_9ce4_8422_2325_u64;
legacy_hash_u64(&mut state, world.tick.0);
for (idx, slot) in world.slots.iter().enumerate() {
legacy_hash_u64(
&mut state,
u64::try_from(idx).expect("slot index should fit"),
);
legacy_hash_u64(&mut state, u64::from(slot.generation));
legacy_hash_u64(&mut state, u64::from(u8::from(slot.live)));
legacy_hash_u64(&mut state, u64::from(u8::from(slot.registered)));
legacy_hash_u64(&mut state, slot.original_id.map_or(0, |id| u64::from(id.0)));
legacy_hash_u64(&mut state, slot.mirror_id.map_or(0, |id| u64::from(id.0)));
legacy_hash_u64(&mut state, slot.owner_id.map_or(0, |id| u64::from(id.0)));
legacy_hash_u64(&mut state, slot.registration_sequence.unwrap_or(u64::MAX));
}
let mut out = [0; 32];
out[..8].copy_from_slice(&state.to_le_bytes());
out[8..16].copy_from_slice(&state.rotate_left(13).to_le_bytes());
out[16..24].copy_from_slice(&state.rotate_left(29).to_le_bytes());
out[24..32].copy_from_slice(&state.rotate_left(47).to_le_bytes());
StateHash(out)
}
fn legacy_hash_u64(state: &mut u64, value: u64) {
for byte in value.to_le_bytes() {
*state ^= u64::from(byte);
*state = state.wrapping_mul(0x0000_0100_0000_01b3);
}
}
} }
+13 -6
View File
@@ -114,16 +114,23 @@ 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 и offscreen Для macOS Desktop GL есть только безопасный command/state trace и исторический
pixel capture: одноразовый offscreen pixel probe:
- `cargo test -p fparkan-render-gl --offline desktop_gl33_triangle_command_capture`; - `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` считается закрытым для текущей macOS-focused цели: временный `S3-GL-001` не считается закрытым: временный `rustc` probe создал CGL/OpenGL
`rustc` probe создал CGL/OpenGL offscreen FBO, выполнил shader-based triangle offscreen FBO, выполнил shader-based triangle draw, прочитал RGBA pixels и
draw, прочитал RGBA pixels и сохранил hash capture. Probe не добавляет сохранил hash capture, но постоянный workspace adapter по-прежнему не создаёт
project-owned `unsafe` в workspace; постоянный adapter API остаётся safe. SDL window, GL context, GPU resources, shader programs, draw calls или present.
Probe не добавляет project-owned `unsafe` в workspace и остаётся только external
evidence request artifact.
Для повышения `S3-GL-001` до `covered` нужен постоянный macOS backend через
выбранную safe facade stack: SDL event/window/context lifecycle, Desktop GL 3.3
shader/buffer/texture/draw/present path, hidden-window/offscreen smoke test и
licensed local model/terrain frame capture.
Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2 Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2
backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же
+13 -13
View File
@@ -21,7 +21,7 @@ S0-CORPUS-005 covered cargo test -p fparkan-corpus --offline fingerprint_changes
S0-CORPUS-006 covered cargo test -p fparkan-corpus --offline atomic_report_write S0-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_reports_safe_project_layer_ready S0-GL-001 covered cargo test -p fparkan-platform-sdl -p fparkan-render-gl --offline adapter_boundary_is_project_owned_unsafe_free
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
@@ -220,7 +220,7 @@ S3-RENDER-006 covered cargo test -p fparkan-render --offline invalid_range_retur
S3-RENDER-007 covered cargo test -p fparkan-render --offline capture_is_stable S3-RENDER-007 covered cargo test -p fparkan-render --offline capture_is_stable
S3-RENDER-008 covered cargo test -p fparkan-render --offline recording_backend_stores_captures S3-RENDER-008 covered cargo test -p fparkan-render --offline recording_backend_stores_captures
S3-RENDER-009 covered cargo xtask policy S3-RENDER-009 covered cargo xtask policy
S3-GL-001 covered cargo test -p fparkan-render-gl --offline desktop_gl33_triangle_command_capture plus fixtures/acceptance/macos-gl33-triangle-capture.json records macOS CGL/OpenGL offscreen FBO pixel capture 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 covered cargo test -p fparkan-render-gl --offline shader_compile_failure_diagnostic_contains_profile_and_log
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
@@ -232,8 +232,8 @@ S4-ANIM-005 covered cargo test -p fparkan-animation --offline exact_key_time_ret
S4-ANIM-006 covered cargo test -p fparkan-animation --offline pose_track_blends_translation_and_rotation S4-ANIM-006 covered cargo test -p fparkan-animation --offline pose_track_blends_translation_and_rotation
S4-ANIM-007 covered cargo test -p fparkan-animation --offline quaternion_shortest_path_sign_flip_is_stable S4-ANIM-007 covered cargo test -p fparkan-animation --offline quaternion_shortest_path_sign_flip_is_stable
S4-ANIM-008 covered cargo test -p fparkan-animation --offline zero_or_degenerate_key_interval_is_rejected S4-ANIM-008 covered cargo test -p fparkan-animation --offline zero_or_degenerate_key_interval_is_rejected
S4-ANIM-009 covered cargo test -p fparkan-animation --offline x87_boundary_golden_vectors_and_profile_difference_report S4-ANIM-009 omitted current sampler is a portable reference path; runtime-captured x87 parity vectors are not implemented in the macOS-focused scope
S4-ANIM-010 covered cargo test -p fparkan-animation --offline x87_boundary_golden_vectors_and_profile_difference_report S4-ANIM-010 omitted current sampler accepts the profile marker but does not implement an independent x87 compatibility path
S4-ANIM-011 covered cargo test -p fparkan-animation --offline blend_optional_pose_uses_valid_side S4-ANIM-011 covered cargo test -p fparkan-animation --offline blend_optional_pose_uses_valid_side
S4-ANIM-012 covered cargo test -p fparkan-animation --offline hierarchy_evaluates_parent_before_child_and_rejects_cycles S4-ANIM-012 covered cargo test -p fparkan-animation --offline hierarchy_evaluates_parent_before_child_and_rejects_cycles
S4-ANIM-013 covered cargo test -p fparkan-animation --offline hierarchy_evaluates_parent_before_child_and_rejects_cycles S4-ANIM-013 covered cargo test -p fparkan-animation --offline hierarchy_evaluates_parent_before_child_and_rejects_cycles
@@ -254,13 +254,13 @@ S4-FX-014 covered cargo test -p fparkan-fx --offline rejects_command_count_that_
S4-FX-015 covered cargo test -p fparkan-fx --offline rejects_trailing_bytes_after_command_stream S4-FX-015 covered cargo test -p fparkan-fx --offline rejects_trailing_bytes_after_command_stream
S4-FX-016 covered cargo test -p fparkan-fx --offline fixed_resource_refs_preserve_tails S4-FX-016 covered cargo test -p fparkan-fx --offline fixed_resource_refs_preserve_tails
S4-FX-017 covered cargo test -p fparkan-fx --offline missing_dependency_error_contains_effect_command_archive_and_name S4-FX-017 covered cargo test -p fparkan-fx --offline missing_dependency_error_contains_effect_command_archive_and_name
S4-FX-018 covered cargo test -p fparkan-fx --offline create_records_seed_transform_and_start_time S4-FX-018 omitted FX create/update/emit is documented as a deterministic reference stub until runtime-captured lifecycle semantics are implemented
S4-FX-019 covered cargo test -p fparkan-fx --offline update_and_emit_are_separate S4-FX-019 omitted FX update side-effect contract is stub-level only and not accepted as original runtime parity evidence
S4-FX-020 covered cargo test -p fparkan-fx --offline update_and_emit_are_separate S4-FX-020 omitted FX emit side-effect contract is stub-level only and not accepted as original runtime parity evidence
S4-FX-021 covered cargo test -p fparkan-fx --offline stable_command_order_and_emission_capture_are_seed_stable S4-FX-021 omitted FX command order is deterministic for the stub but original runtime ordering evidence is not implemented
S4-FX-022 covered cargo test -p fparkan-fx --offline stop_restart_end_lifecycle_controls_emission S4-FX-022 omitted FX lifecycle controls are deterministic for the stub but original stop/restart/end semantics are not implemented
S4-FX-023 covered cargo test -p fparkan-fx --offline stable_command_order_and_emission_capture_are_seed_stable S4-FX-023 omitted FX emission capture is deterministic for the stub but lacks runtime-captured parity evidence
S4-FX-024 covered cargo test -p fparkan-fx --offline unrelated_rng_stream_use_does_not_perturb_fx_capture S4-FX-024 omitted FX RNG accounting is not implemented beyond the reference stub and must not count as compatibility coverage
S4-FX-FUZZ-001 covered cargo test -p fparkan-fx --offline arbitrary_command_streams_are_bounded_and_panic_free S4-FX-FUZZ-001 covered cargo test -p fparkan-fx --offline arbitrary_command_streams_are_bounded_and_panic_free
L4-P1-ANIM-001 covered cargo test -p fparkan-msh --offline licensed_corpus_animation_streams_sample_approved_pose_captures L4-P1-ANIM-001 covered cargo test -p fparkan-msh --offline licensed_corpus_animation_streams_sample_approved_pose_captures
L4-P2-ANIM-001 covered cargo test -p fparkan-msh --offline licensed_corpus_animation_streams_sample_approved_pose_captures L4-P2-ANIM-001 covered cargo test -p fparkan-msh --offline licensed_corpus_animation_streams_sample_approved_pose_captures
@@ -271,8 +271,8 @@ L4-P2-FX-001 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exa
L4-P1-FX-002 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution L4-P1-FX-002 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution
L4-P2-FX-002 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution L4-P2-FX-002 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution
L4-FX-OP6-001 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution L4-FX-OP6-001 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution
L4-P1-EFFECT-001 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_emission_captures_are_approved L4-P1-EFFECT-001 omitted licensed FX emission capture currently covers deterministic reference-stub output, not original runtime-captured effect parity
L4-P2-EFFECT-001 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_emission_captures_are_approved L4-P2-EFFECT-001 omitted licensed FX emission capture currently covers deterministic reference-stub output, not original runtime-captured effect parity
S5-LMESH-001 covered cargo test -p fparkan-terrain-format --offline land_msh_required_streams_are_order_independent_and_stride_checked S5-LMESH-001 covered cargo test -p fparkan-terrain-format --offline land_msh_required_streams_are_order_independent_and_stride_checked
S5-LMESH-002 covered cargo test -p fparkan-terrain-format --offline land_msh_required_streams_are_order_independent_and_stride_checked S5-LMESH-002 covered cargo test -p fparkan-terrain-format --offline land_msh_required_streams_are_order_independent_and_stride_checked
S5-LMESH-003 covered cargo test -p fparkan-terrain-format --offline decodes_minimal_land_msh S5-LMESH-003 covered cargo test -p fparkan-terrain-format --offline decodes_minimal_land_msh
1 # Acceptance coverage manifest.
21 S0-CORPUS-006
22 S0-CLI-001
23 S0-CLI-002
24 S0-GL-001
25 S0-LIMIT-001
26 S0-LIMIT-002
27 L1-P1-NRES-001
220 S3-RENDER-007
221 S3-RENDER-008
222 S3-RENDER-009
223 S3-GL-001
224 S3-GL-002
225 S3-GL-003
226 S3-VIEWER-001
232 S4-ANIM-006
233 S4-ANIM-007
234 S4-ANIM-008
235 S4-ANIM-009
236 S4-ANIM-010
237 S4-ANIM-011
238 S4-ANIM-012
239 S4-ANIM-013
254 S4-FX-015
255 S4-FX-016
256 S4-FX-017
257 S4-FX-018
258 S4-FX-019
259 S4-FX-020
260 S4-FX-021
261 S4-FX-022
262 S4-FX-023
263 S4-FX-024
264 S4-FX-FUZZ-001
265 L4-P1-ANIM-001
266 L4-P2-ANIM-001
271 L4-P1-FX-002
272 L4-P2-FX-002
273 L4-FX-OP6-001
274 L4-P1-EFFECT-001
275 L4-P2-EFFECT-001
276 S5-LMESH-001
277 S5-LMESH-002
278 S5-LMESH-003
+323 -24
View File
@@ -10,6 +10,10 @@ use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
const CORPORA_MANIFEST_ENV: &str = "FPARKAN_CORPORA_MANIFEST";
const PART1_ROOT_ENV: &str = "FPARKAN_CORPUS_PART1_ROOT";
const PART2_ROOT_ENV: &str = "FPARKAN_CORPUS_PART2_ROOT";
fn main() { fn main() {
let args = std::env::args().skip(1).collect::<Vec<_>>(); let args = std::env::args().skip(1).collect::<Vec<_>>();
let code = match run(&args) { let code = match run(&args) {
@@ -27,8 +31,8 @@ fn run(args: &[String]) -> Result<(), String> {
[cmd] if cmd == "ci" => { [cmd] if cmd == "ci" => {
run_rustfmt_check(Path::new("."))?; run_rustfmt_check(Path::new("."))?;
run_policy(Path::new("."))?; run_policy(Path::new("."))?;
cargo(&["test", "--workspace", "--offline"])?; cargo(&["test", "--workspace", "--locked", "--offline"])?;
clippy_rustup(&["--workspace", "--offline"])?; clippy_rustup(&["--workspace", "--locked", "--offline"])?;
Ok(()) Ok(())
} }
[cmd] if cmd == "policy" => run_policy(Path::new(".")), [cmd] if cmd == "policy" => run_policy(Path::new(".")),
@@ -46,23 +50,23 @@ fn run(args: &[String]) -> Result<(), String> {
} }
[cmd, suite, rest @ ..] if cmd == "test" && suite == "synthetic" => { [cmd, suite, rest @ ..] if cmd == "test" && suite == "synthetic" => {
let options = parse_test_options(rest, PathBuf::from("testdata"))?; let options = parse_test_options(rest, PathBuf::from("testdata"))?;
run_stage_tests(options.stage) run_stage_tests(options.stage, TestSuite::Synthetic, None)
} }
[cmd, suite, rest @ ..] if cmd == "test" && suite == "licensed" => { [cmd, suite, rest @ ..] if cmd == "test" && suite == "licensed" => {
let options = parse_test_options(rest, PathBuf::from("testdata"))?; let options = parse_test_options(rest, PathBuf::from("testdata"))?;
validate_licensed_root(&options.root)?; let roots = load_licensed_roots(options.manifest.as_deref())?;
run_stage_tests(options.stage) run_stage_tests(options.stage, TestSuite::Licensed, Some(&roots))
} }
[cmd, subcmd, rest @ ..] if cmd == "corpus" && subcmd == "baseline" => { [cmd, subcmd, rest @ ..] if cmd == "corpus" && subcmd == "baseline" => {
let root = parse_root(rest)?; let root = parse_root(rest)?;
let manifest = let manifest =
discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?; discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
let report = report(&root, &manifest); let report = report(&root, &manifest).map_err(|e| e.to_string())?;
println!("{}", render_report_json(&report)); println!("{}", render_report_json(&report));
Ok(()) Ok(())
} }
_ => Err( _ => Err(
"usage: cargo xtask ci | policy | acceptance report --suite synthetic|licensed [--stage 0..5|all] [--root testdata] [--out <path>] | acceptance audit [--roadmap <path>] [--coverage <path>] [--out <path>] [--strict] | package --target <triple> --app viewer|game|headless|cli | test synthetic|licensed [--stage 0..5|all] [--root testdata] | corpus baseline --root <path>" "usage: cargo xtask ci | policy | acceptance report --suite synthetic|licensed [--stage 0..5|all] [--manifest corpora.toml] [--out <path>] | acceptance audit [--roadmap <path>] [--coverage <path>] [--out <path>] [--strict] | package --target <triple> --app viewer|game|headless|cli | test synthetic|licensed [--stage 0..5|all] [--manifest corpora.toml] | corpus baseline --root <path>"
.to_string(), .to_string(),
), ),
} }
@@ -94,6 +98,23 @@ fn cargo_owned(args: &[String]) -> Result<(), String> {
} }
} }
fn cargo_with_env(args: &[&str], envs: &[(&str, &Path)]) -> Result<(), String> {
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let mut command = Command::new(cargo);
command.args(args);
for (key, value) in envs {
command.env(key, value);
}
let status = command
.status()
.map_err(|err| format!("failed to run cargo: {err}"))?;
if status.success() {
Ok(())
} else {
Err(format!("cargo exited with {status}"))
}
}
fn clippy_rustup(args: &[&str]) -> Result<(), String> { fn clippy_rustup(args: &[&str]) -> Result<(), String> {
let rustup = std::env::var_os("RUSTUP").unwrap_or_else(|| "rustup".into()); let rustup = std::env::var_os("RUSTUP").unwrap_or_else(|| "rustup".into());
let status = Command::new(rustup) let status = Command::new(rustup)
@@ -153,19 +174,124 @@ fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String>
Ok(()) Ok(())
} }
fn validate_licensed_root(root: &Path) -> Result<(), String> { #[derive(Clone, Debug, Eq, PartialEq)]
for part in ["IS", "IS2"] { struct LicensedCorpusRoots {
let part_root = root.join(part); part1: PathBuf,
if !part_root.is_dir() { part2: PathBuf,
return Err(format!( }
"licensed corpus part is missing: {}",
part_root.display() impl LicensedCorpusRoots {
)); fn envs(&self) -> [(&str, &Path); 2] {
[
(PART1_ROOT_ENV, self.part1.as_path()),
(PART2_ROOT_ENV, self.part2.as_path()),
]
}
}
fn load_licensed_roots(manifest: Option<&Path>) -> Result<LicensedCorpusRoots, String> {
let manifest = manifest
.map(Path::to_path_buf)
.or_else(|| std::env::var_os(CORPORA_MANIFEST_ENV).map(PathBuf::from))
.ok_or_else(|| {
format!(
"licensed tests require --manifest or {CORPORA_MANIFEST_ENV}=<absolute corpora.toml>"
)
})?;
parse_licensed_manifest(&manifest)
}
fn parse_licensed_manifest(path: &Path) -> Result<LicensedCorpusRoots, String> {
let text = fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?;
let mut part1 = None;
let mut part2 = None;
let mut current_kind: Option<String> = None;
let mut current_root: Option<PathBuf> = None;
for raw_line in text.lines() {
let line = raw_line.split('#').next().unwrap_or_default().trim();
if line.is_empty() {
continue;
}
if line == "[[corpus]]" {
flush_manifest_entry(&mut part1, &mut part2, &mut current_kind, &mut current_root)?;
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
match key {
"kind" => current_kind = Some(parse_manifest_string(value.trim())?),
"root" => current_root = Some(PathBuf::from(parse_manifest_string(value.trim())?)),
_ => {}
} }
} }
flush_manifest_entry(&mut part1, &mut part2, &mut current_kind, &mut current_root)?;
let roots = LicensedCorpusRoots {
part1: part1.ok_or_else(|| "licensed manifest is missing kind = \"part1\"".to_string())?,
part2: part2.ok_or_else(|| "licensed manifest is missing kind = \"part2\"".to_string())?,
};
validate_licensed_part("part1", &roots.part1)?;
validate_licensed_part("part2", &roots.part2)?;
Ok(roots)
}
fn flush_manifest_entry(
part1: &mut Option<PathBuf>,
part2: &mut Option<PathBuf>,
current_kind: &mut Option<String>,
current_root: &mut Option<PathBuf>,
) -> Result<(), String> {
let Some(kind) = current_kind.take() else {
*current_root = None;
return Ok(());
};
let root = current_root
.take()
.ok_or_else(|| format!("licensed manifest entry {kind} is missing root"))?;
match kind.as_str() {
"part1" => assign_manifest_root(part1, root, "part1"),
"part2" => assign_manifest_root(part2, root, "part2"),
_ => Ok(()),
}
}
fn assign_manifest_root(
target: &mut Option<PathBuf>,
root: PathBuf,
kind: &str,
) -> Result<(), String> {
if target.replace(root).is_some() {
return Err(format!("licensed manifest contains duplicate {kind} root"));
}
Ok(()) Ok(())
} }
fn parse_manifest_string(value: &str) -> Result<String, String> {
let trimmed = value.trim();
if let Some(quoted) = trimmed
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
{
Ok(quoted.to_string())
} else {
Err(format!("manifest value must be a quoted string: {trimmed}"))
}
}
fn validate_licensed_part(kind: &str, root: &Path) -> Result<(), String> {
if root.is_dir() {
Ok(())
} else {
Err(format!(
"licensed corpus {kind} root is missing: {}",
root.display()
))
}
}
fn parse_root(args: &[String]) -> Result<PathBuf, String> { fn parse_root(args: &[String]) -> Result<PathBuf, String> {
let mut iter = args.iter(); let mut iter = args.iter();
while let Some(arg) = iter.next() { while let Some(arg) = iter.next() {
@@ -248,6 +374,7 @@ fn run_package(options: &PackageOptions) -> Result<(), String> {
"-p".to_string(), "-p".to_string(),
options.app.package().to_string(), options.app.package().to_string(),
"--release".to_string(), "--release".to_string(),
"--locked".to_string(),
"--offline".to_string(), "--offline".to_string(),
"--target".to_string(), "--target".to_string(),
options.target.clone(), options.target.clone(),
@@ -258,6 +385,8 @@ fn run_policy(root: &Path) -> Result<(), String> {
let mut failures = Vec::new(); let mut failures = Vec::new();
scan_policy_dir(root, &mut failures)?; scan_policy_dir(root, &mut failures)?;
validate_cargo_metadata(root, &mut failures)?; validate_cargo_metadata(root, &mut failures)?;
validate_lockfile(root, &mut failures);
validate_workspace_license(root, &mut failures)?;
validate_dependency_boundaries(root, &mut failures)?; validate_dependency_boundaries(root, &mut failures)?;
if failures.is_empty() { if failures.is_empty() {
Ok(()) Ok(())
@@ -278,6 +407,7 @@ fn validate_cargo_metadata(root: &Path, failures: &mut Vec<String>) -> Result<()
"--format-version", "--format-version",
"1", "1",
"--offline", "--offline",
"--locked",
"--no-deps", "--no-deps",
"--manifest-path", "--manifest-path",
]) ])
@@ -295,6 +425,62 @@ fn validate_cargo_metadata(root: &Path, failures: &mut Vec<String>) -> Result<()
Ok(()) Ok(())
} }
fn validate_lockfile(root: &Path, failures: &mut Vec<String>) {
let lockfile = root.join("Cargo.lock");
if !lockfile.is_file() {
failures.push(format!(
"{}: workspace lockfile is required for locked/offline builds",
lockfile.display()
));
}
}
fn validate_workspace_license(root: &Path, failures: &mut Vec<String>) -> Result<(), String> {
let manifest = root.join("Cargo.toml");
let license = fs::read_to_string(root.join("LICENSE.txt"))
.map_err(|err| format!("{}: {err}", root.join("LICENSE.txt").display()))?;
let expected = if license.contains("GNU GENERAL PUBLIC LICENSE")
&& license.contains("Version 2, June 1991")
{
"GPL-2.0-only"
} else {
failures.push(format!(
"{}: unsupported repository license text",
root.join("LICENSE.txt").display()
));
return Ok(());
};
let mut manifests = Vec::new();
collect_cargo_manifests(root, &mut manifests)?;
manifests.push(manifest);
manifests.sort();
manifests.dedup();
for manifest in manifests {
let text = fs::read_to_string(&manifest)
.map_err(|err| format!("{}: {err}", manifest.display()))?;
let explicit_license = parse_manifest_license(&text);
let is_root = manifest == root.join("Cargo.toml");
if is_root {
if explicit_license.as_deref() != Some(expected) {
failures.push(format!(
"{}: workspace.package license must be {expected}",
manifest.display()
));
}
} else if let Some(license) = explicit_license {
if license != expected {
failures.push(format!(
"{}: package license {license} does not match repository license {expected}",
manifest.display()
));
}
}
}
Ok(())
}
fn validate_dependency_boundaries(root: &Path, failures: &mut Vec<String>) -> Result<(), String> { fn validate_dependency_boundaries(root: &Path, failures: &mut Vec<String>) -> Result<(), String> {
let mut manifests = Vec::new(); let mut manifests = Vec::new();
collect_cargo_manifests(root, &mut manifests)?; collect_cargo_manifests(root, &mut manifests)?;
@@ -357,6 +543,23 @@ fn collect_cargo_manifests(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), Str
Ok(()) Ok(())
} }
fn parse_manifest_license(manifest: &str) -> Option<String> {
let mut in_package = false;
let mut in_workspace_package = false;
for line in manifest.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_package = trimmed == "[package]";
in_workspace_package = trimmed == "[workspace.package]";
continue;
}
if (in_package || in_workspace_package) && trimmed.starts_with("license") {
return parse_toml_string_value(trimmed);
}
}
None
}
fn parse_package_name(manifest: &str) -> Option<String> { fn parse_package_name(manifest: &str) -> Option<String> {
let mut in_package = false; let mut in_package = false;
for line in manifest.lines() { for line in manifest.lines() {
@@ -640,6 +843,7 @@ impl fmt::Display for Stage {
struct TestOptions { struct TestOptions {
stage: Stage, stage: Stage,
root: PathBuf, root: PathBuf,
manifest: Option<PathBuf>,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -670,6 +874,7 @@ struct AcceptanceOptions {
suite: TestSuite, suite: TestSuite,
stage: Stage, stage: Stage,
root: PathBuf, root: PathBuf,
manifest: Option<PathBuf>,
out: PathBuf, out: PathBuf,
} }
@@ -685,6 +890,7 @@ fn parse_test_options(args: &[String], default_root: PathBuf) -> Result<TestOpti
let mut options = TestOptions { let mut options = TestOptions {
stage: Stage::All, stage: Stage::All,
root: default_root, root: default_root,
manifest: None,
}; };
let mut iter = args.iter(); let mut iter = args.iter();
while let Some(arg) = iter.next() { while let Some(arg) = iter.next() {
@@ -701,6 +907,12 @@ fn parse_test_options(args: &[String], default_root: PathBuf) -> Result<TestOpti
.ok_or_else(|| "--root requires a path".to_string())?; .ok_or_else(|| "--root requires a path".to_string())?;
options.root = PathBuf::from(value); options.root = PathBuf::from(value);
} }
"--manifest" => {
let value = iter
.next()
.ok_or_else(|| "--manifest requires a path".to_string())?;
options.manifest = Some(PathBuf::from(value));
}
_ => return Err(format!("unknown test option: {arg}")), _ => return Err(format!("unknown test option: {arg}")),
} }
} }
@@ -711,6 +923,7 @@ fn parse_acceptance_options(args: &[String]) -> Result<AcceptanceOptions, String
let mut suite = None; let mut suite = None;
let mut stage = Stage::All; let mut stage = Stage::All;
let mut root = PathBuf::from("testdata"); let mut root = PathBuf::from("testdata");
let mut manifest = None;
let mut out = None; let mut out = None;
let mut iter = args.iter(); let mut iter = args.iter();
while let Some(arg) = iter.next() { while let Some(arg) = iter.next() {
@@ -733,6 +946,12 @@ fn parse_acceptance_options(args: &[String]) -> Result<AcceptanceOptions, String
.ok_or_else(|| "--root requires a path".to_string())?; .ok_or_else(|| "--root requires a path".to_string())?;
root = PathBuf::from(value); root = PathBuf::from(value);
} }
"--manifest" => {
let value = iter
.next()
.ok_or_else(|| "--manifest requires a path".to_string())?;
manifest = Some(PathBuf::from(value));
}
"--out" => { "--out" => {
let value = iter let value = iter
.next() .next()
@@ -755,6 +974,7 @@ fn parse_acceptance_options(args: &[String]) -> Result<AcceptanceOptions, String
suite, suite,
stage, stage,
root, root,
manifest,
out, out,
}) })
} }
@@ -1079,10 +1299,12 @@ fn json_escape(value: &str) -> String {
} }
fn run_acceptance_report(options: &AcceptanceOptions) -> Result<(), String> { fn run_acceptance_report(options: &AcceptanceOptions) -> Result<(), String> {
if options.suite == TestSuite::Licensed { let roots = if options.suite == TestSuite::Licensed {
validate_licensed_root(&options.root)?; Some(load_licensed_roots(options.manifest.as_deref())?)
} } else {
run_stage_tests(options.stage)?; None
};
run_stage_tests(options.stage, options.suite, roots.as_ref())?;
if let Some(parent) = options.out.parent() { if let Some(parent) = options.out.parent() {
fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?; fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?;
@@ -1131,12 +1353,35 @@ fn stage_report_packages(stage: Stage) -> Vec<&'static str> {
} }
} }
fn run_stage_tests(stage: Stage) -> Result<(), String> { fn run_stage_tests(
stage: Stage,
suite: TestSuite,
roots: Option<&LicensedCorpusRoots>,
) -> Result<(), String> {
let mut suffix = Vec::new();
if suite == TestSuite::Licensed {
suffix.extend(["--", "--ignored"]);
}
let envs = roots.map(LicensedCorpusRoots::envs);
match stage { match stage {
Stage::All => cargo(&["test", "--workspace", "--offline"]), Stage::All => {
let mut args = vec!["test", "--workspace", "--locked", "--offline"];
args.extend(suffix);
if let Some(envs) = envs {
cargo_with_env(&args, &envs)
} else {
cargo(&args)
}
}
Stage::Number(number) => { Stage::Number(number) => {
for package in stage_packages(number)? { for package in stage_packages(number)? {
cargo(&["test", "-p", package, "--offline"])?; let mut args = vec!["test", "-p", package, "--locked", "--offline"];
args.extend(suffix.iter().copied());
if let Some(envs) = envs {
cargo_with_env(&args, &envs)?;
} else {
cargo(&args)?;
}
} }
Ok(()) Ok(())
} }
@@ -1189,6 +1434,13 @@ mod tests {
values.iter().map(|value| (*value).to_string()).collect() values.iter().map(|value| (*value).to_string()).collect()
} }
fn temp_dir(name: &str) -> PathBuf {
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
std::env::temp_dir().join(format!("fparkan-xtask-{name}-{suffix}"))
}
#[test] #[test]
fn parses_stage_and_root_options() { fn parses_stage_and_root_options() {
let args = strings(&["--stage", "3", "--root", "fixtures"]); let args = strings(&["--stage", "3", "--root", "fixtures"]);
@@ -1199,6 +1451,7 @@ mod tests {
Ok(TestOptions { Ok(TestOptions {
stage: Stage::Number(3), stage: Stage::Number(3),
root: PathBuf::from("fixtures"), root: PathBuf::from("fixtures"),
manifest: None,
}) })
); );
} }
@@ -1210,8 +1463,8 @@ mod tests {
"licensed", "licensed",
"--stage", "--stage",
"5", "5",
"--root", "--manifest",
"testdata", "corpora.toml",
"--out", "--out",
"target/report.json", "target/report.json",
])); ]));
@@ -1222,6 +1475,7 @@ mod tests {
suite: TestSuite::Licensed, suite: TestSuite::Licensed,
stage: Stage::Number(5), stage: Stage::Number(5),
root: PathBuf::from("testdata"), root: PathBuf::from("testdata"),
manifest: Some(PathBuf::from("corpora.toml")),
out: PathBuf::from("target/report.json"), out: PathBuf::from("target/report.json"),
}) })
); );
@@ -1233,6 +1487,7 @@ mod tests {
suite: TestSuite::Licensed, suite: TestSuite::Licensed,
stage: Stage::Number(0), stage: Stage::Number(0),
root: PathBuf::from("/private/game"), root: PathBuf::from("/private/game"),
manifest: Some(PathBuf::from("/private/corpora.toml")),
out: PathBuf::from("target/report.json"), out: PathBuf::from("target/report.json"),
}; };
let report = render_acceptance_report(&options); let report = render_acceptance_report(&options);
@@ -1334,10 +1589,54 @@ mod tests {
Ok(TestOptions { Ok(TestOptions {
stage: Stage::All, stage: Stage::All,
root: PathBuf::from("testdata"), root: PathBuf::from("testdata"),
manifest: None,
}) })
); );
} }
#[test]
fn parses_licensed_corpora_manifest() -> Result<(), String> {
let root = temp_dir("manifest");
let part1 = root.join("IS");
let part2 = root.join("IS2");
fs::create_dir_all(&part1).map_err(|err| err.to_string())?;
fs::create_dir_all(&part2).map_err(|err| err.to_string())?;
let manifest = root.join("corpora.toml");
fs::write(
&manifest,
format!(
"schema = 1\n\n[[corpus]]\nid = \"part1-local\"\nkind = \"part1\"\nroot = \"{}\"\nexpected_profile = \"parkan-is-part1\"\n\n[[corpus]]\nid = \"part2-local\"\nkind = \"part2\"\nroot = \"{}\"\nexpected_profile = \"parkan-is-part2\"\n",
part1.display(),
part2.display()
),
)
.map_err(|err| err.to_string())?;
assert_eq!(
parse_licensed_manifest(&manifest)?,
LicensedCorpusRoots { part1, part2 }
);
fs::remove_dir_all(root).map_err(|err| err.to_string())?;
Ok(())
}
#[test]
fn licensed_roots_require_manifest_configuration() {
let previous = std::env::var_os(CORPORA_MANIFEST_ENV);
std::env::remove_var(CORPORA_MANIFEST_ENV);
assert_eq!(
load_licensed_roots(None),
Err(format!(
"licensed tests require --manifest or {CORPORA_MANIFEST_ENV}=<absolute corpora.toml>"
))
);
if let Some(value) = previous {
std::env::set_var(CORPORA_MANIFEST_ENV, value);
}
}
#[test] #[test]
fn rejects_unknown_stage() { fn rejects_unknown_stage() {
assert_eq!(Stage::parse("6"), Err("stage out of range: 6".to_string())); assert_eq!(Stage::parse("6"), Err("stage out of range: 6".to_string()));