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
-4
View File
@@ -69,10 +69,6 @@ $RECYCLE.BIN/
debug/
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
**/*.rs.bk
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]
version = "0.1.0"
edition = "2021"
license = "MIT"
license = "GPL-2.0-only"
repository = "https://github.com/valentineus/fparkan"
[workspace.lints.rust]
+19 -2
View File
@@ -40,10 +40,27 @@ cargo xtask ci
- разместите игровые каталоги в [`testdata/`](testdata);
- игровые ресурсы в репозиторий не включаются, так как защищены авторским правом.
Локальный licensed gate:
Локальный licensed gate использует некоммитимый manifest:
```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
+17 -17
View File
@@ -1,5 +1,5 @@
#![forbid(unsafe_code)]
//! SDL platform adapter proof behind safe `FParkan` ports.
//! SDL platform adapter boundary stubs behind safe `FParkan` ports.
use fparkan_platform::{
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]
pub fn safe_adapter_ready() -> bool {
pub fn project_owned_layer_unsafe_free() -> bool {
SdlAdapterCapabilities::default().project_owned_unsafe_free
}
/// In-memory event source used by adapter smoke tests and composition roots
/// before a concrete SDL runtime is injected.
/// In-memory event source used by adapter smoke tests before a concrete SDL
/// runtime is selected.
#[derive(Clone, Debug, Default)]
pub struct SdlEventSourceProof {
pub struct SdlEventSourceStub {
pending: Vec<PlatformEvent>,
}
impl SdlEventSourceProof {
impl SdlEventSourceStub {
/// Creates an event source with deterministic pending events.
#[must_use]
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> {
out.append(&mut self.pending);
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)]
pub struct SdlWindowProof {
pub struct SdlWindowStub {
size: PhysicalSize,
presents: u64,
}
impl SdlWindowProof {
/// Creates a proof window with a fixed drawable size.
impl SdlWindowStub {
/// Creates a stub window with a fixed drawable size.
#[must_use]
pub fn new(size: PhysicalSize) -> Self {
Self { size, presents: 0 }
@@ -82,7 +82,7 @@ impl SdlWindowProof {
}
}
impl WindowPort for SdlWindowProof {
impl WindowPort for SdlWindowStub {
fn drawable_size(&self) -> PhysicalSize {
self.size
}
@@ -98,20 +98,20 @@ mod tests {
use super::*;
#[test]
fn adapter_reports_safe_project_layer_ready() {
assert!(safe_adapter_ready());
fn adapter_boundary_is_project_owned_unsafe_free() {
assert!(project_owned_layer_unsafe_free());
assert_eq!(SdlAdapterCapabilities::default().graphics.len(), 2);
}
#[test]
fn event_source_and_window_ports_are_deterministic() -> Result<(), PlatformError> {
let mut source = SdlEventSourceProof::new(vec![PlatformEvent::Quit]);
let mut source = SdlEventSourceStub::new(vec![PlatformEvent::Quit]);
let mut events = Vec::new();
source.poll(&mut events)?;
source.poll(&mut events)?;
assert_eq!(events, vec![PlatformEvent::Quit]);
let mut window = SdlWindowProof::new(PhysicalSize {
let mut window = SdlWindowStub::new(PhysicalSize {
width: 320,
height: 240,
});
+6 -6
View File
@@ -1,5 +1,5 @@
#![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::{
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]
pub fn safe_adapter_ready() -> bool {
pub fn project_owned_layer_unsafe_free() -> bool {
GlAdapterCapabilities::default().project_owned_unsafe_free
}
@@ -98,7 +98,7 @@ pub fn compile_shader_source(
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
/// [`RenderBackend`] port once an audited safe GL facade is selected. This type
@@ -147,8 +147,8 @@ mod tests {
};
#[test]
fn adapter_reports_safe_project_layer_ready() {
assert!(safe_adapter_ready());
fn adapter_boundary_is_project_owned_unsafe_free() {
assert!(project_owned_layer_unsafe_free());
assert_eq!(GlAdapterCapabilities::default().profiles.len(), 2);
}
+22 -1
View File
@@ -2,4 +2,25 @@
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 manifest =
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));
Ok(())
}
@@ -41,10 +41,13 @@ fn run(args: &[String]) -> Result<(), String> {
let root = parse_root(&rest)?;
let manifest =
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 {
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));
Ok(())
}
+19 -9
View File
@@ -262,21 +262,22 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn selected_is_and_is2_missions_produce_approved_render_captures() {
for case in [
RenderCase {
root: "IS",
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 {
root: "IS2",
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!(
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())
);
}
@@ -312,11 +313,20 @@ mod tests {
]
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.expect("workspace root")
.to_path_buf()
fn licensed_root(name: &str) -> PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
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)]
#![allow(clippy::cast_precision_loss)]
//! 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;
/// Numeric profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum NumericProfile {
/// Portable reference.
/// Portable reference sampler implemented by this crate.
PortableReference,
/// X87-compatible compatibility profile for captured parity vectors.
/// Reserved profile for future runtime-captured x87 parity vectors.
X87Compatibility,
}
@@ -424,8 +427,11 @@ impl PoseTrack {
&self.keys
}
/// Samples the pose track with linear translation and normalized quaternion
/// interpolation.
/// Samples the pose track with the portable reference path.
///
/// `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
///
+164 -23
View File
@@ -6,7 +6,7 @@ use fparkan_msh::{decode_msh, validate_msh};
use fparkan_nres::{decode as decode_nres, ReadProfile};
use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName};
use fparkan_prototype::{EffectivePrototype, PrototypeGeometry, PrototypeGraph};
use fparkan_resource::{ResourceKey, ResourceRepository};
use fparkan_resource::{ResourceError, ResourceKey, ResourceRepository};
use fparkan_texm::decode_texm;
use std::collections::BTreeSet;
use std::fmt;
@@ -279,17 +279,13 @@ pub fn prepare_visual_with_repository<R: ResourceRepository>(
material_count += 1;
for texture in material.document.texture_requests() {
resolve_texm(repository, &texture, &[TEXTURES_ARCHIVE, LIGHTMAP_ARCHIVE])?;
resolve_texture(repository, &texture)?;
texture_count += 1;
}
}
for lightmap in &wear.lightmaps {
resolve_texm(
repository,
&lightmap.lightmap,
&[LIGHTMAP_ARCHIVE, TEXTURES_ARCHIVE],
)?;
resolve_lightmap(repository, &lightmap.lightmap)?;
lightmap_count += 1;
}
@@ -325,28 +321,59 @@ fn read_key<R: ResourceRepository>(
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>(
repository: &R,
name: &ResourceName,
archives: &[&str],
archive: &str,
label: &'static str,
) -> Result<(), AssetError> {
for archive in archives {
let key = ResourceKey {
archive: parse_path(archive)?,
name: name.clone(),
type_id: None,
};
match read_key(repository, &key, Some("texm")) {
Ok(bytes) => {
decode_texm(bytes).map_err(|err| AssetError::Texture(err.to_string()))?;
return Ok(());
}
Err(AssetError::MissingDependency(_) | AssetError::Resource(_)) => {}
Err(err) => return Err(err),
}
let Some(bytes) = read_optional_key(repository, &key, Some(label))? else {
return Err(AssetError::MissingDependency(format!("{label} {name:?}")));
};
decode_texm(bytes)
.map(|_| ())
.map_err(|err| AssetError::Texture(err.to_string()))
}
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> {
@@ -412,7 +439,7 @@ mod tests {
use super::*;
use fparkan_prototype::build_prototype_graph;
use fparkan_resource::{resource_name, CachedResourceRepository};
use fparkan_vfs::{DirectoryVfs, Vfs};
use fparkan_vfs::{DirectoryVfs, MemoryVfs, Vfs};
use std::path::PathBuf;
#[test]
@@ -426,6 +453,48 @@ mod tests {
}
#[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() {
let root = fixture_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
@@ -448,6 +517,7 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn repository_plan_deduplicates_duplicate_visuals_but_graph_preserves_requests() {
let root = fixture_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
@@ -473,9 +543,80 @@ mod tests {
}
fn fixture_root(part: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(part)
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 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;
/// SHA-256 digest bytes.
pub type Sha256Digest = [u8; 32];
/// Parser limits shared by binary formats.
#[derive(Clone, Copy, Debug)]
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())
}
/// 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)]
mod tests {
use super::*;
@@ -305,4 +485,16 @@ mod tests {
);
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
[dependencies]
fparkan-binary = { path = "../fparkan-binary" }
fparkan-nres = { path = "../fparkan-nres" }
fparkan-path = { path = "../fparkan-path" }
[lints]
+344 -115
View File
@@ -1,12 +1,14 @@
#![forbid(unsafe_code)]
//! Licensed corpus discovery and aggregate reports.
use fparkan_binary::{sha256, sha256_hex, Sha256Digest};
use fparkan_path::{ascii_lookup_key, normalize_relative, PathPolicy};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Corpus kind.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -39,8 +41,8 @@ pub struct ManifestEntry {
pub path: String,
/// File size in bytes.
pub size: u64,
/// Stable content fingerprint.
pub hash: u64,
/// SHA-256 content fingerprint.
pub hash: Sha256Digest,
}
/// Corpus manifest.
@@ -70,7 +72,35 @@ pub struct CorpusReport {
/// Casefold collision count.
pub casefold_collisions: usize,
/// 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.
@@ -87,6 +117,13 @@ pub enum CorpusError {
InvalidRoot(PathBuf),
/// Invalid path.
InvalidPath(String),
/// Aggregate report failure.
Report {
/// Path where reporting failed.
path: String,
/// Failure message.
message: String,
},
}
impl fmt::Display for CorpusError {
@@ -95,6 +132,7 @@ impl fmt::Display for CorpusError {
Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
Self::InvalidRoot(path) => write!(f, "invalid corpus root: {}", path.display()),
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)> {
match self {
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 {
path: normalized.as_str().to_string(),
size: metadata.len(),
hash: stable_hash(&bytes),
hash: sha256(&bytes),
});
}
Ok(())
@@ -229,8 +267,39 @@ fn detect_casefold_collisions(files: &[ManifestEntry]) -> Vec<Vec<String>> {
}
/// 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();
metrics.insert("nres_files".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("fxid_entries".to_string(), 0);
metrics.insert("wear_entries".to_string(), 0);
metrics
}
for entry in &manifest.files {
fn inspect_report_file(
root: &Path,
entry: &ManifestEntry,
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 bytes.starts_with(b"NRes") {
variant = "nres".to_string();
bump(metrics, "nres_files", 1);
if let Err(message) = inspect_nres_metrics(bytes, metrics) {
return CorpusFileRecord {
path: entry.path.clone(),
status: CorpusFileStatus::Error,
variant,
message: Some(message),
};
}
} else if bytes.starts_with(b"NL") {
variant = "rsli".to_string();
bump(metrics, "rsli_files", 1);
}
CorpusFileRecord {
path: entry.path.clone(),
status: CorpusFileStatus::Ok,
variant,
message: None,
}
}
fn inspect_path_metrics(lower: &str, metrics: &mut BTreeMap<String, u64>) -> String {
let mut variant = "file";
if lower.ends_with("data.tma") {
bump(&mut metrics, "tma_files", 1);
bump(metrics, "tma_files", 1);
variant = "tma";
}
if lower.ends_with("land.msh") {
bump(&mut metrics, "land_msh_files", 1);
bump(metrics, "land_msh_files", 1);
variant = "land_msh";
}
if lower.ends_with("land.map") {
bump(&mut metrics, "land_map_files", 1);
bump(metrics, "land_map_files", 1);
variant = "land_map";
}
if has_extension(&lower, "dat")
&& (lower.starts_with("units/") || lower.contains("/units/"))
{
bump(&mut metrics, "unit_dat_files", 1);
if has_extension(lower, "dat") && (lower.starts_with("units/") || lower.contains("/units/")) {
bump(metrics, "unit_dat_files", 1);
variant = "unit_dat";
}
variant.to_string()
}
let path = root.join(&entry.path);
if let Ok(bytes) = fs::read(path) {
if bytes.starts_with(b"NRes") {
bump(&mut metrics, "nres_files", 1);
if let Some(entries) = inspect_nres_entries(&bytes) {
bump(&mut metrics, "nres_entries", entries.len() as u64);
fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) -> Result<(), String> {
let entries = inspect_nres_entries(bytes)?;
bump(metrics, "nres_entries", entries.len() as u64);
for entry in entries {
let name = entry.name.to_ascii_lowercase();
let name = String::from_utf8_lossy(entry.name_bytes()).to_ascii_lowercase();
if has_extension(&name, "msh") {
bump(&mut metrics, "msh_entries", 1);
bump(metrics, "msh_entries", 1);
}
match entry.kind {
match entry.meta().type_id {
0x3054_414D => {
bump(&mut metrics, "mat0_entries", 1);
bump(metrics, "mat0_entries", 1);
}
0x6D78_6554 => {
bump(&mut metrics, "texm_entries", 1);
bump(metrics, "texm_entries", 1);
}
0x4449_5846 => {
bump(&mut metrics, "fxid_entries", 1);
bump(metrics, "fxid_entries", 1);
}
0x5241_4557 => {
bump(&mut metrics, "wear_entries", 1);
bump(metrics, "wear_entries", 1);
}
_ => {}
}
}
}
} else if bytes.starts_with(b"NL") {
bump(&mut metrics, "rsli_files", 1);
}
}
}
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),
}
Ok(())
}
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))
}
#[derive(Clone, Debug)]
struct NresEntryBrief {
kind: u32,
name: String,
}
fn inspect_nres_entries(bytes: &[u8]) -> Option<Vec<NresEntryBrief>> {
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)
fn inspect_nres_entries(bytes: Vec<u8>) -> Result<Vec<fparkan_nres::NresEntry>, String> {
let document = fparkan_nres::decode(
Arc::from(bytes.into_boxed_slice()),
fparkan_nres::ReadProfile::Compatible,
)
.map_err(|err| err.to_string())?;
Ok(document.entries().to_vec())
}
/// Computes stable manifest fingerprint.
#[must_use]
pub fn fingerprint(manifest: &CorpusManifest) -> u64 {
let mut state = 0xcbf2_9ce4_8422_2325;
pub fn fingerprint(manifest: &CorpusManifest) -> Sha256Digest {
let mut bytes = Vec::new();
for file in &manifest.files {
hash_into(&mut state, file.path.as_bytes());
hash_into(&mut state, &file.size.to_le_bytes());
hash_into(&mut state, &file.hash.to_le_bytes());
}
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);
bytes.extend_from_slice(file.path.as_bytes());
bytes.push(0);
bytes.extend_from_slice(&file.size.to_le_bytes());
bytes.extend_from_slice(&file.hash);
}
sha256(&bytes)
}
/// Writes report atomically.
@@ -413,13 +478,15 @@ pub fn write_report_atomic(path: &Path, report: &CorpusReport) -> Result<(), Cor
#[must_use]
pub fn render_report_json(report: &CorpusReport) -> String {
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.kind,
report.files,
report.bytes,
report.casefold_collisions,
report.fingerprint
sha256_hex(&report.fingerprint),
report.failures,
report.records.len()
);
for (idx, (key, value)) in report.metrics.iter().enumerate() {
if idx > 0 {
@@ -442,25 +509,21 @@ mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
#[ignore = "requires licensed corpus"]
fn report_for_testdata_roots() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join("IS");
if !root.is_dir() {
return;
}
let root = licensed_root("IS");
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.metrics["nres_files"] > 0);
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_part1_manifest_profile_and_counts_match_baseline() {
let root = testdata_root("IS");
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!(report.files, 1_017);
@@ -473,10 +536,11 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_part2_manifest_profile_and_counts_match_baseline() {
let root = testdata_root("IS2");
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!(report.files, 1_302);
@@ -489,6 +553,7 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_part1_has_no_casefold_relative_path_collisions() {
let root = testdata_root("IS");
let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest");
@@ -497,6 +562,7 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_part2_has_no_casefold_relative_path_collisions() {
let root = testdata_root("IS2");
let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest");
@@ -505,11 +571,13 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_part1_paths_stay_under_root() {
assert_discovered_paths_stay_under_root("IS");
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_part2_paths_stay_under_root() {
assert_discovered_paths_stay_under_root("IS2");
}
@@ -521,20 +589,115 @@ mod tests {
files: vec![ManifestEntry {
path: "secret/payload.bin".to_string(),
size: 4,
hash: stable_hash(b"DATA"),
hash: sha256(b"DATA"),
}],
casefold_collisions: Vec::new(),
};
let report = report(Path::new("."), &manifest);
let report = report(Path::new("."), &manifest).expect("report");
let json = render_report_json(&report);
assert!(json.contains("\"schema_version\":\"fparkan-corpus-report-v1\""));
assert!(json.contains("\"fingerprint\":"));
assert!(json.contains("\"failures\":1"));
assert!(json.contains("\"record_count\":1"));
assert!(json.contains("\"metrics\":"));
assert!(!json.contains("secret/payload.bin"));
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]
fn deterministic_traversal_is_creation_order_independent() {
let first = temp_dir("order-first");
@@ -597,12 +760,12 @@ mod tests {
ManifestEntry {
path: "Textures/Foo.TEX".to_string(),
size: 1,
hash: 1,
hash: sha256(b"first"),
},
ManifestEntry {
path: "textures/foo.tex".to_string(),
size: 1,
hash: 2,
hash: sha256(b"second"),
},
],
casefold_collisions: Vec::new(),
@@ -626,12 +789,12 @@ mod tests {
files: vec![ManifestEntry {
path: "a".to_string(),
size: 1,
hash: 1,
hash: sha256(b"before"),
}],
casefold_collisions: Vec::new(),
};
let a = fingerprint(&manifest);
manifest.files[0].hash = 2;
manifest.files[0].hash = sha256(b"after");
assert_ne!(a, fingerprint(&manifest));
}
@@ -651,13 +814,65 @@ mod tests {
bytes: 0,
metrics: BTreeMap::new(),
casefold_collisions: 0,
fingerprint: 0,
fingerprint: sha256(b"empty-report"),
records: Vec::new(),
failures: 0,
};
write_report_atomic(&tmp, &report).expect("write");
assert!(tmp.is_file());
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 {
let path = std::env::temp_dir().join(format!(
"fparkan-corpus-{name}-{}",
@@ -671,10 +886,24 @@ mod tests {
}
fn testdata_root(part: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(part)
licensed_root(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) {
+31 -19
View File
@@ -1,5 +1,10 @@
#![forbid(unsafe_code)]
//! 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 std::sync::Arc;
@@ -121,7 +126,7 @@ impl Default for Transform {
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct GameTime(pub u64);
/// FX runtime state.
/// FX reference-stub runtime state.
#[derive(Clone, Debug)]
pub struct FxState {
/// 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
///
/// 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(
document: Arc<FxDocument>,
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
///
/// Reserved for future runtime validation.
/// Reserved for future runtime-captured compatibility validation.
pub fn update(state: &mut FxState, time: GameTime) -> Result<(), FxError> {
state.time = time;
Ok(())
}
/// Emits active commands without advancing state.
/// Emits reference-stub active commands without advancing state.
///
/// # 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> {
if state.lifecycle != FxLifecycle::Running {
return Ok(());
@@ -838,11 +843,10 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_fxid_exact_eof_and_distribution() {
for (corpus, expected_count) in [("IS", 923_usize), ("IS2", 1065_usize)] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut count = 0usize;
let mut opcodes = BTreeMap::<FxOpcode, usize>::new();
let mut time_modes = BTreeMap::<u32, usize>::new();
@@ -886,14 +890,13 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_fxid_emission_captures_are_approved() {
for (corpus, expected_count, expected_emitting, expected_hash) in [
("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),
] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut count = 0usize;
let mut emitting = 0usize;
let mut hash = FNV_OFFSET;
@@ -985,12 +988,21 @@ mod tests {
dst[..len].copy_from_slice(&src[..len]);
}
fn corpus_root(name: &str) -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(name);
root.is_dir().then_some(root)
fn corpus_root(name: &str) -> PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
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> {
+56 -17
View File
@@ -417,16 +417,9 @@ pub fn resolve_material(
{
return Ok(resolved);
}
if let Some(first) = table.entries.first() {
if let Some(resolved) = load_material_entry(
repository,
archive,
&first.material,
MaterialFallback::FirstEntry,
)? {
if let Some(resolved) = load_first_material_entry(repository, archive)? {
return Ok(resolved);
}
}
Err(MaterialError::MissingMaterial(
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> {
if lines.is_empty() || lines.iter().all(|line| line.trim().is_empty()) {
return Ok(Vec::new());
@@ -926,6 +939,24 @@ mod tests {
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]
fn resolve_material_empty_texture_means_untextured() {
let repo = material_repo(&[material_entry(b"MAT_EMPTY", &mat0_with_texture(b""))]);
@@ -1092,14 +1123,13 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_mat0_and_wear_parse() {
for (corpus, expected_mat0, expected_archive_wear, expected_standalone_wear) in [
("IS", 905_usize, 439_usize, 95_usize),
("IS2", 1127_usize, 515_usize, 95_usize),
] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut mat0_count = 0usize;
let mut archive_wear_count = 0usize;
let mut standalone_wear_count = 0usize;
@@ -1153,12 +1183,21 @@ mod tests {
}
}
fn corpus_root(name: &str) -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(name);
root.is_dir().then_some(root)
fn corpus_root(name: &str) -> PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
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> {
+17 -9
View File
@@ -979,6 +979,7 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_tma_validate() {
for (
corpus,
@@ -991,9 +992,7 @@ mod tests {
("IS", 29_usize, 34_usize, 101_usize, 864_usize, 28_usize),
("IS2", 31_usize, 61_usize, 91_usize, 885_usize, 41_usize),
] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut files = 0usize;
let mut paths = 0usize;
let mut clans = 0usize;
@@ -1142,12 +1141,21 @@ mod tests {
}
}
fn corpus_root(name: &str) -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(name);
root.is_dir().then_some(root)
fn corpus_root(name: &str) -> PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
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> {
+19 -12
View File
@@ -1236,11 +1236,10 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_msh_assets_validate() {
for (corpus, expected) in [("IS", 435_usize), ("IS2", 511_usize)] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut count = 0usize;
for path in files_under(&root) {
let Ok(bytes) = std::fs::read(&path) else {
@@ -1279,6 +1278,7 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_animation_streams_sample_approved_pose_captures() {
for (
corpus,
@@ -1302,9 +1302,7 @@ mod tests {
13_040_438_305_408_523_893_u64,
),
] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut models = 0usize;
let mut animated_models = 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")
}
fn corpus_root(name: &str) -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(name);
root.is_dir().then_some(root)
fn corpus_root(name: &str) -> PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
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> {
+217 -50
View File
@@ -26,7 +26,7 @@ pub enum ReadProfile {
/// Write profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
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,
/// Repack active payloads and rebuild the lookup table.
CanonicalCompact,
@@ -102,6 +102,7 @@ pub struct NresDocument {
#[derive(Clone, Debug)]
pub struct NresEditor {
entries: Vec<EditableEntry>,
layout: Vec<EditableSegment>,
}
#[derive(Clone, Debug)]
@@ -114,6 +115,12 @@ struct EditableEntry {
payload: Vec<u8>,
}
#[derive(Clone, Debug)]
enum EditableSegment {
Entry(usize),
Preserved(Vec<u8>),
}
/// `NRes` parse or write error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum NresError {
@@ -506,7 +513,8 @@ impl NresEditor {
payload: document.payload(entry.id())?.to_vec(),
});
}
Ok(Self { entries })
let layout = build_edit_layout(document)?;
Ok(Self { entries, layout })
}
/// Replaces an entry payload.
@@ -537,13 +545,57 @@ impl NresEditor {
Ok(())
}
/// Encodes the edited document in canonical compact form.
/// Encodes the edited document while preserving unindexed bytes.
///
/// # Errors
///
/// Returns [`NresError`] when offsets or sizes exceed the on-disk `u32`
/// representation.
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 offsets = 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);
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());
write_edit_archive_header_and_directory(&mut out, &self.entries, &offsets, &sizes)?;
Ok(out)
}
@@ -899,6 +935,76 @@ fn build_edit_sort_order(entries: &[EditableEntry]) -> Vec<usize> {
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] {
let len = name_len(raw).unwrap_or(NAME_LEN);
&raw[..len]
@@ -1414,7 +1520,7 @@ mod tests {
#[test]
fn preserves_nonzero_unindexed_region() {
let mut bytes = build_archive(&[SyntheticEntry {
let bytes = build_archive_with_nonzero_prefix_gap(&[SyntheticEntry {
type_id: 1,
attr1: 0,
attr2: 0,
@@ -1422,18 +1528,6 @@ mod tests {
name: "payload",
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");
assert!(doc.has_nonzero_preserved_region());
@@ -1443,7 +1537,7 @@ mod tests {
#[test]
fn canonical_compact_roundtrip_preserves_entry_semantics() {
let mut bytes = build_archive(&[
let bytes = build_archive_with_nonzero_prefix_gap(&[
SyntheticEntry {
type_id: 7,
attr1: 10,
@@ -1461,16 +1555,6 @@ mod tests {
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 compact = decode(
@@ -1529,8 +1613,13 @@ mod tests {
editor
.set_payload(EntryId(0), b"replacement".to_vec())
.expect("set payload");
let edited =
decode(arc(editor.encode().expect("encode")), ReadProfile::Strict).expect("edited");
let edited = decode(
arc(editor
.encode_with_profile(WriteProfile::CanonicalCompact)
.expect("encode")),
ReadProfile::Strict,
)
.expect("edited");
let first = edited.entry(EntryId(0)).expect("first");
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);
}
#[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]
fn editor_rename_rebuilds_search_mapping() {
let bytes = build_archive(&[
@@ -1779,6 +1926,7 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpora_nres_roundtrip_gates() {
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");
@@ -1800,10 +1948,14 @@ mod tests {
expected_files: usize,
expected_entries: usize,
) -> Result<CorpusGateResult, String> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(name);
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => 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() {
return Err(format!(
"licensed corpus root is missing: {}",
@@ -1929,6 +2081,21 @@ mod tests {
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]> {
Arc::from(bytes.into_boxed_slice())
}
+46 -2
View File
@@ -97,7 +97,14 @@ pub enum PathError {
impl fmt::Display for PathError {
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
/// embedded NUL, attempts parent traversal, or is not valid UTF-8 after
/// 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() {
return Err(PathError::Empty);
}
@@ -124,11 +131,17 @@ pub fn normalize_relative(raw: &[u8], _policy: PathPolicy) -> Result<NormalizedP
let mut parts = Vec::new();
for part in text.split(['/', '\\']) {
if part.is_empty() || part == "." {
if policy == PathPolicy::StrictLegacy {
return Err(PathError::ParentTraversal);
}
continue;
}
if part == ".." {
return Err(PathError::ParentTraversal);
}
if policy == PathPolicy::StrictLegacy && part.contains(':') {
return Err(PathError::Absolute);
}
parts.push(part);
}
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]
fn join_under_keeps_normalized_path_below_root() {
let rel = normalize_relative(b"DATA/MAPS/Land.map", PathPolicy::StrictLegacy)
+23 -10
View File
@@ -1768,8 +1768,9 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
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 repo = CachedResourceRepository::new(vfs.clone());
let cases = [
@@ -1799,9 +1800,10 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn resolves_some_registry_entries_in_both_corpora() {
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 document = fparkan_nres::decode(
Arc::from(objects.into_boxed_slice()),
@@ -1826,10 +1828,11 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpora_unit_dat_parse_counts() {
let cases = [("IS", 425, 5_219), ("IS2", 676, 8_145)];
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();
collect_unit_dat_files(&root, &mut dat_paths);
dat_paths.sort();
@@ -1859,9 +1862,10 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpora_registry_payloads_are_record_aligned() {
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 document = fparkan_nres::decode(
Arc::from(objects.into_boxed_slice()),
@@ -1907,12 +1911,21 @@ mod tests {
}
}
fn corpus_root(name: &str) -> Option<std::path::PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(name);
root.is_dir().then_some(root)
fn corpus_root(name: &str) -> std::path::PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
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(
+456 -8
View File
@@ -139,6 +139,15 @@ pub struct RenderCommandList {
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.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct FrameOutput;
@@ -148,6 +157,13 @@ pub struct FrameOutput;
pub enum RenderError {
/// Invalid range.
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.
InvalidDrawRange {
/// Draw id.
@@ -159,6 +175,49 @@ pub enum RenderError {
/// Range count.
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.
MaterialIndexOutOfBounds {
/// Draw id.
@@ -172,7 +231,75 @@ pub enum RenderError {
impl std::fmt::Display for RenderError {
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,
});
}
validate_index_range(draw.id, draw.range)?;
validate_transform(draw.id, &draw.transform)?;
let material = draw
.material_slots
.get(usize::from(draw.material_index))
@@ -247,7 +376,7 @@ pub struct NullBackend;
impl RenderBackend for NullBackend {
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
validate_commands(commands)?;
validate_command_list(commands)?;
Ok(FrameOutput)
}
}
@@ -291,7 +420,7 @@ impl RenderBackend for RecordingBackend {
///
/// Returns [`RenderError`] when a draw command contains an invalid index range.
pub fn canonical_capture(commands: &RenderCommandList) -> Result<Vec<u8>, RenderError> {
validate_commands(commands)?;
validate_command_list(commands)?;
let mut out = Vec::new();
for command in &commands.commands {
match command {
@@ -311,12 +440,132 @@ pub fn canonical_capture(commands: &RenderCommandList) -> Result<Vec<u8>, Render
Ok(out)
}
fn validate_commands(commands: &RenderCommandList) -> Result<(), RenderError> {
for command in &commands.commands {
if let RenderCommand::Draw(draw) = command {
/// Validates a render command list without backend-specific resource limits.
///
/// # Errors
///
/// 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(())
@@ -379,7 +628,9 @@ mod tests {
fn null_backend_validates_without_capture() {
let mut backend = NullBackend;
let invalid = RenderCommandList {
commands: vec![RenderCommand::Draw(DrawCommand {
commands: vec![
RenderCommand::BeginFrame,
RenderCommand::Draw(DrawCommand {
id: DrawId(1),
phase: RenderPhase::Opaque,
object_id: None,
@@ -388,7 +639,9 @@ mod tests {
transform: [0.0; 16],
range: IndexRange { start: 0, count: 0 },
stable_order: 4,
})],
}),
RenderCommand::EndFrame,
],
};
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]
fn ui_phase_is_excluded_until_requested() -> Result<(), RenderError> {
let snapshot = RenderSnapshot {
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-binary = { path = "../fparkan-binary" }
fparkan-nres = { path = "../fparkan-nres" }
fparkan-path = { path = "../fparkan-path" }
fparkan-rsli = { path = "../fparkan-rsli" }
+281 -42
View File
@@ -1,6 +1,7 @@
#![forbid(unsafe_code)]
//! Resource identity and repository ports.
use fparkan_binary::Sha256Digest;
use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName};
use fparkan_vfs::{Vfs, VfsError};
use std::collections::BTreeMap;
@@ -40,6 +41,8 @@ pub struct ArchiveId(pub u64);
pub struct EntryHandle {
/// Archive.
pub archive: ArchiveId,
/// Archive generation at the time the entry was resolved.
pub generation: u64,
/// Local entry index.
pub local: u32,
}
@@ -108,6 +111,8 @@ pub enum ResourceError {
MissingEntry,
/// Stale or invalid handle.
InvalidHandle,
/// Handle belongs to an older archive generation.
StaleHandle,
/// Format error.
Format(String),
/// Entry-specific read error.
@@ -123,7 +128,30 @@ pub enum ResourceError {
impl std::fmt::Display for ResourceError {
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,
name: &ResourceName,
) -> 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.
///
/// # Errors
@@ -169,6 +203,24 @@ pub struct CachedResourceRepository {
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)]
struct RepositoryState {
paths: BTreeMap<String, ArchiveId>,
@@ -178,9 +230,10 @@ struct RepositoryState {
struct ArchiveSlot {
path: NormalizedPath,
fingerprint: u64,
fingerprint: Sha256Digest,
generation: u64,
kind: ArchiveKind,
document: ArchiveDocument,
document: Arc<ArchiveDocument>,
}
enum ArchiveDocument {
@@ -188,9 +241,16 @@ enum ArchiveDocument {
Rsli(fparkan_rsli::RsliDocument),
}
struct PayloadDecodeTask {
document: Arc<ArchiveDocument>,
key: ResourceKey,
}
#[derive(Debug, Default)]
struct DecodedPayloadCache {
max_entries: usize,
max_bytes: usize,
current_bytes: usize,
generation: u64,
entries: BTreeMap<EntryHandle, PayloadCacheEntry>,
}
@@ -205,16 +265,28 @@ impl CachedResourceRepository {
/// Creates a cached repository.
#[must_use]
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.
#[must_use]
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 {
vfs,
state: Mutex::new(RepositoryState {
payload_cache: DecodedPayloadCache::new(max_payload_entries),
payload_cache: DecodedPayloadCache::new(limits),
..RepositoryState::default()
}),
}
@@ -250,12 +322,13 @@ impl ResourceRepository for CachedResourceRepository {
}
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)?;
if let Some(id) = state.paths.get(path.as_str()).copied() {
if state.archive(id)?.fingerprint == fingerprint {
return Ok(id);
}
slot.generation = state.archive(id)?.generation.saturating_add(1);
*state.archive_mut(id)? = slot;
state.payload_cache.remove_archive(id);
return Ok(id);
@@ -275,37 +348,61 @@ impl ResourceRepository for CachedResourceRepository {
) -> Result<Option<EntryHandle>, ResourceError> {
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
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::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> {
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));
}
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,
})?
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)?;
if let Some(bytes) = state.payload_cache.get(entry) {
return Ok(ResourceBytes::Shared(bytes));
}
state.entry_archive(entry)?;
state.payload_cache.insert(entry, Arc::clone(&shared));
Ok(ResourceBytes::Shared(shared))
}
fn entry_info(&self, entry: EntryHandle) -> Result<ResourceEntryInfo, ResourceError> {
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
let slot = state.archive(entry.archive)?;
match &slot.document {
let slot = state.entry_archive(entry)?;
match slot.document.as_ref() {
ArchiveDocument::Nres(document) => {
let local =
usize::try_from(entry.local).map_err(|_| ResourceError::InvalidHandle)?;
@@ -348,7 +445,7 @@ impl CachedResourceRepository {
fn cached_id(
&self,
path: &NormalizedPath,
fingerprint: u64,
fingerprint: Sha256Digest,
) -> Result<Option<ArchiveId>, ResourceError> {
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
let Some(id) = state.paths.get(path.as_str()).copied() else {
@@ -363,9 +460,11 @@ impl CachedResourceRepository {
}
impl DecodedPayloadCache {
fn new(max_entries: usize) -> Self {
fn new(limits: PayloadCacheLimits) -> Self {
Self {
max_entries,
max_entries: limits.max_entries,
max_bytes: limits.max_bytes,
current_bytes: 0,
generation: 0,
entries: BTreeMap::new(),
}
@@ -379,18 +478,39 @@ impl DecodedPayloadCache {
}
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;
}
self.generation = self.generation.saturating_add(1);
self.entries.insert(
if let Some(previous) = self.entries.insert(
handle,
PayloadCacheEntry {
bytes,
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
.entries
.iter()
@@ -399,12 +519,10 @@ impl DecodedPayloadCache {
else {
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);
}
}
@@ -420,11 +538,27 @@ impl RepositoryState {
.get_mut(index)
.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 {
fn entry_key(&self, local: u32) -> Result<ResourceKey, ResourceError> {
match &self.document {
match self.document.as_ref() {
ArchiveDocument::Nres(document) => {
let local = usize::try_from(local).map_err(|_| ResourceError::InvalidHandle)?;
let entry = document
@@ -449,9 +583,11 @@ impl ArchiveSlot {
}
}
}
}
impl ArchiveDocument {
fn read_payload(&self, local: u32) -> Result<Vec<u8>, String> {
match &self.document {
match self {
ArchiveDocument::Nres(document) => document
.payload(fparkan_nres::EntryId(local))
.map(<[u8]>::to_vec)
@@ -466,7 +602,7 @@ impl ArchiveSlot {
fn decode_archive(
path: NormalizedPath,
bytes: Arc<[u8]>,
fingerprint: u64,
fingerprint: Sha256Digest,
) -> Result<ArchiveSlot, ResourceError> {
if bytes.starts_with(b"NRes") {
let document = fparkan_nres::decode(bytes, fparkan_nres::ReadProfile::Compatible)
@@ -474,8 +610,9 @@ fn decode_archive(
return Ok(ArchiveSlot {
path,
fingerprint,
generation: 0,
kind: ArchiveKind::Nres,
document: ArchiveDocument::Nres(document),
document: Arc::new(ArchiveDocument::Nres(document)),
});
}
if bytes.get(0..4) == Some(b"NL\0\x01") {
@@ -484,8 +621,9 @@ fn decode_archive(
return Ok(ArchiveSlot {
path,
fingerprint,
generation: 0,
kind: ArchiveKind::Rsli,
document: ArchiveDocument::Rsli(document),
document: Arc::new(ArchiveDocument::Rsli(document)),
});
}
Err(ResourceError::Format(
@@ -528,7 +666,7 @@ fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
mod tests {
use super::*;
use fparkan_vfs::{DirectoryVfs, MemoryVfs};
use std::path::Path;
use std::path::PathBuf;
#[test]
fn cached_repository_reads_synthetic_nres() {
@@ -554,6 +692,7 @@ mod tests {
assert!(matches!(
repo.read(EntryHandle {
archive: ArchiveId(99),
generation: 0,
local: 0
}),
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]
fn archive_cache_invalidates_when_vfs_bytes_change() {
let root = temp_dir("archive-invalidate");
@@ -652,7 +862,7 @@ mod tests {
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");
let reopened = repo.open_archive(&path).expect("open updated archive");
let second = repo
@@ -661,9 +871,11 @@ mod tests {
.expect("updated handle");
assert_eq!(reopened, archive);
assert_ne!(first, second);
assert!(matches!(repo.read(first), Err(ResourceError::StaleHandle)));
assert_eq!(
repo.read(second).expect("read updated").as_slice(),
b"after"
b"after!"
);
let _ = std::fs::remove_dir_all(root);
}
@@ -696,16 +908,43 @@ mod tests {
}
#[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() {
licensed_repository_gate("IS").expect("part 1 repository gate");
licensed_repository_gate("IS2").expect("part 2 repository gate");
}
fn licensed_repository_gate(corpus: &str) -> Result<(), String> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(corpus);
let variable = match corpus {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => 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() {
return Err(format!(
"licensed corpus root is missing: {}",
+174 -12
View File
@@ -14,6 +14,44 @@ pub enum ReadProfile {
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.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WriteProfile {
@@ -34,12 +72,30 @@ pub struct RsliCompatibilityProfile {
impl Default for RsliCompatibilityProfile {
fn default() -> Self {
Self::retail()
}
}
impl RsliCompatibilityProfile {
/// Retail-compatible profile with every approved quirk enabled.
#[must_use]
pub const fn retail() -> Self {
Self {
allow_ao_trailer: true,
allow_deflate_eof_plus_one: 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.
@@ -197,6 +253,11 @@ pub enum RsliError {
/// Archive byte length.
file_len: u64,
},
/// Registered `AO` overlay is rejected by the selected profile.
AoTrailerQuirkRejected {
/// Overlay byte offset.
overlay: u32,
},
/// Unsupported packing method.
UnsupportedMethod {
/// Raw method bits.
@@ -266,6 +327,9 @@ impl fmt::Display for RsliError {
"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::PackedSizePastEof {
id,
@@ -298,20 +362,31 @@ impl std::error::Error for RsliError {}
/// compatibility quirks, or packed payloads are invalid for the selected
/// profile.
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 {
ReadProfile::Strict => ParseOptions {
RsliReadProfile::Strict => ParseOptions {
allow_ao_trailer: false,
allow_deflate_eof_plus_one: false,
allow_invalid_presorted_fallback: false,
},
ReadProfile::Compatible => {
let profile = RsliCompatibilityProfile::default();
ParseOptions {
RsliReadProfile::Compatible(profile) => ParseOptions {
allow_ao_trailer: profile.allow_ao_trailer,
allow_deflate_eof_plus_one: profile.allow_deflate_eof_plus_one,
allow_invalid_presorted_fallback: profile.allow_invalid_presorted_fallback,
}
}
},
};
let ParsedRsli {
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> {
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));
}
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)?,
});
}
if !allow {
return Err(RsliError::AoTrailerQuirkRejected { overlay });
}
Ok((overlay, Some(raw)))
}
@@ -1330,6 +1408,39 @@ mod tests {
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]
fn stored_method_uses_exact_size() {
let bytes = synthetic_rsli(
@@ -1510,6 +1621,16 @@ mod tests {
decode(arc(approved.clone()), ReadProfile::Strict),
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");
assert_eq!(doc.load(EntryId(0)).expect("approved payload"), b"raw");
@@ -1606,11 +1727,22 @@ mod tests {
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");
assert_eq!(meta.data_offset, 64);
assert_eq!(meta.data_offset_raw, 60);
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]
@@ -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]
fn unknown_header_bytes_are_lossless() {
let mut bytes = synthetic_rsli(
@@ -1742,6 +1896,7 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpora_rsli_roundtrip_gates() {
let part1 = corpus_gate("IS", 2).expect("part 1 RsLi gate");
let part2 = corpus_gate("IS2", 2).expect("part 2 RsLi gate");
@@ -1751,6 +1906,7 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_part1_rsli_method_distribution_baseline() {
let stats = corpus_gate("IS", 2).expect("part 1 RsLi gate");
@@ -1770,6 +1926,7 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_part2_rsli_method_distribution_baseline() {
let stats = corpus_gate("IS2", 2).expect("part 2 RsLi gate");
@@ -1789,6 +1946,7 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpora_rsli_quirk_is_only_approved_interf8_tex() {
let part1 = corpus_gate("IS", 2).expect("part 1 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> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(name);
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => 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() {
return Err(format!(
"licensed corpus root is missing: {}",
+32 -19
View File
@@ -695,8 +695,9 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
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 mut engine = create(
EngineConfig {
@@ -736,8 +737,9 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
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 [
(
DenyRule::Suffix("Land.map"),
@@ -779,8 +781,9 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
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 mut engine = create(
EngineConfig {
@@ -816,6 +819,7 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn selected_is_and_is2_missions_execute_10000_deterministic_ticks() {
for case in [
HeadlessCase {
@@ -823,9 +827,9 @@ mod tests {
mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma",
object_count: 33,
expected_hash: [
0x19, 0xdc, 0xd3, 0x9b, 0x35, 0xad, 0x90, 0x6c, 0x92, 0x2d, 0x83, 0x7b, 0x7a,
0xb3, 0xa6, 0x15, 0xa6, 0x15, 0x92, 0x2d, 0x83, 0x7b, 0x7a, 0xb3, 0xe9, 0xcd,
0x9a, 0x56, 0x48, 0xb6, 0x0c, 0xee,
0xc7, 0xb0, 0x6e, 0x0a, 0x31, 0x1f, 0x5d, 0x8c, 0xde, 0x64, 0xa5, 0x33, 0x1f,
0x2c, 0xd0, 0x2c, 0x21, 0x44, 0x2f, 0x34, 0x5d, 0x16, 0xe8, 0x94, 0xaf, 0xa2,
0x2b, 0xa9, 0xd4, 0x24, 0xd2, 0xf9,
],
},
HeadlessCase {
@@ -833,9 +837,9 @@ mod tests {
mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
object_count: 10,
expected_hash: [
0x59, 0x6e, 0x88, 0xcc, 0xd0, 0x3a, 0xd9, 0x68, 0x1b, 0x2d, 0xcb, 0x0d, 0x91,
0x19, 0x5a, 0x27, 0x5a, 0x27, 0x1b, 0x2d, 0xcb, 0x0d, 0x91, 0x19, 0x44, 0x66,
0x68, 0x9d, 0x6c, 0xb4, 0x2c, 0x37,
0x3c, 0xe5, 0xa6, 0x39, 0x47, 0x86, 0x76, 0xe1, 0xb2, 0x1a, 0x8e, 0x96, 0x3d,
0x60, 0x6e, 0xc6, 0x8c, 0xe2, 0x28, 0x4f, 0x57, 0xd9, 0xe1, 0xe4, 0xb5, 0x95,
0xdf, 0x88, 0xd3, 0x2f, 0x4a, 0x4d,
],
},
] {
@@ -849,9 +853,9 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpora_load_all_mission_foundations() {
let root = workspace_root();
let part1 = load_all(&root.join("testdata").join("IS"));
let part1 = load_all(&licensed_root("IS"));
assert_eq!(part1.missions, 29);
assert_eq!(part1.paths, 34);
assert_eq!(part1.clans, 101);
@@ -871,7 +875,7 @@ mod tests {
assert_eq!(part1.texture_requests, part1.texture_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.paths, 61);
assert_eq!(part2.clans, 91);
@@ -923,7 +927,7 @@ mod tests {
}
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 mut engine = create(
EngineConfig {
@@ -1043,12 +1047,21 @@ mod tests {
}
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.expect("workspace root")
.to_path_buf()
fn licensed_root(name: &str) -> PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
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)]
+21 -15
View File
@@ -1488,14 +1488,13 @@ Generator 1
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_land_msh_validate() {
for (corpus, expected_files, expected_vertices, expected_faces) in [
("IS", 33_usize, 299_450_usize, 275_882_usize),
("IS2", 32_usize, 188_024_usize, 184_454_usize),
] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut files = 0usize;
let mut vertices = 0usize;
let mut faces = 0usize;
@@ -1536,11 +1535,10 @@ Generator 1
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_build_dat_validate() {
for (corpus, expected_ai_prefix) in [("IS", false), ("IS2", true)] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let path = root.join("BuildDat.lst");
let bytes = std::fs::read(&path).expect("read BuildDat.lst");
let categories =
@@ -1583,14 +1581,13 @@ Generator 1
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_land_map_validate() {
for (corpus, expected_files, expected_areals, expected_vertices, expected_max_hits) in [
("IS", 33_usize, 34_662_usize, 197_698_usize, 20_usize),
("IS2", 32_usize, 18_984_usize, 114_968_usize, 14_usize),
] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut files = 0usize;
let mut areals = 0usize;
let mut vertices = 0usize;
@@ -1880,12 +1877,21 @@ Generator 1
out.extend_from_slice(&value.to_le_bytes());
}
fn corpus_root(name: &str) -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(name);
root.is_dir().then_some(root)
fn corpus_root(name: &str) -> PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
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> {
+19 -12
View File
@@ -794,14 +794,13 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_land_maps_build_navigation_worlds() {
for (corpus, expected_files, expected_areals) in [
("IS", 33_usize, 34_662_usize),
("IS2", 32_usize, 18_984_usize),
] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut files = 0usize;
let mut areals = 0usize;
let mut located_centers = 0usize;
@@ -849,14 +848,13 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_land_meshes_build_surface_worlds() {
for (corpus, expected_files, expected_faces) in [
("IS", 33_usize, 275_882_usize),
("IS2", 32_usize, 184_454_usize),
] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut files = 0usize;
let mut faces = 0usize;
for path in files_under(&root) {
@@ -1049,12 +1047,21 @@ mod tests {
vertices.first().copied()
}
fn corpus_root(name: &str) -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(name);
root.is_dir().then_some(root)
fn corpus_root(name: &str) -> PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
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> {
+17 -9
View File
@@ -1071,11 +1071,10 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_texm_assets_validate_and_decode_mip0() {
for (corpus, expected) in [("IS", 518_usize), ("IS2", 631_usize)] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut count = 0usize;
for path in files_under(&root) {
let Ok(bytes) = std::fs::read(&path) else {
@@ -1157,12 +1156,21 @@ mod tests {
}
}
fn corpus_root(name: &str) -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(name);
root.is_dir().then_some(root)
fn corpus_root(name: &str) -> PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
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> {
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-binary = { path = "../fparkan-binary" }
fparkan-path = { path = "../fparkan-path" }
[lints]
+210 -51
View File
@@ -1,19 +1,21 @@
#![forbid(unsafe_code)]
//! 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::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
/// VFS metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VfsMetadata {
/// Byte length.
pub len: u64,
/// Stable-enough source fingerprint for cache invalidation.
pub fingerprint: u64,
/// SHA-256 content fingerprint for cache invalidation.
pub fingerprint: Sha256Digest,
}
/// VFS entry.
@@ -80,6 +82,7 @@ pub trait Vfs: Send + Sync {
#[derive(Clone, Debug)]
pub struct DirectoryVfs {
root: PathBuf,
fingerprint_cache: Arc<Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>>,
}
impl DirectoryVfs {
@@ -88,26 +91,43 @@ impl DirectoryVfs {
pub fn new(root: impl AsRef<Path>) -> Self {
Self {
root: root.as_ref().to_path_buf(),
fingerprint_cache: Arc::default(),
}
}
fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> {
let exact = join_under(&self.root, path).map_err(|_| VfsError::Path)?;
if exact.exists() {
return Ok(exact);
}
join_under(&self.root, path).map_err(|_| VfsError::Path)?;
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 {
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
let meta = fs::metadata(self.host_path(path)?).map_err(VfsError::Io)?;
Ok(metadata_from_fs(&meta))
self.metadata_from_host_file(&self.host_path(path)?)
}
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()))
}
@@ -115,14 +135,18 @@ impl Vfs for DirectoryVfs {
let base = self.host_path(prefix)?;
let mut entries = Vec::new();
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 {
path: prefix.clone(),
metadata: metadata_from_fs(&metadata),
metadata: metadata_from_host_file_with_cache(
&base,
&metadata,
&self.fingerprint_cache,
)?,
});
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()));
Ok(entries)
}
@@ -140,6 +164,9 @@ fn resolve_casefolded(root: &Path, normalized: &str) -> Result<PathBuf, VfsError
continue;
};
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());
}
}
@@ -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 mut children = Vec::new();
for entry in read_dir {
@@ -175,9 +207,12 @@ fn list_recursive(root: &Path, dir: &Path, out: &mut Vec<VfsEntry>) -> Result<()
}
children.sort();
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() {
list_recursive(root, &child, out)?;
list_recursive(root, &child, fingerprint_cache, out)?;
continue;
}
if !metadata.is_file() {
@@ -192,56 +227,111 @@ fn list_recursive(root: &Path, dir: &Path, out: &mut Vec<VfsEntry>) -> Result<()
.map_err(|_| VfsError::Path)?;
out.push(VfsEntry {
path,
metadata: metadata_from_fs(&metadata),
metadata: metadata_from_host_file_with_cache(&child, &metadata, fingerprint_cache)?,
});
}
Ok(())
}
fn metadata_from_fs(metadata: &fs::Metadata) -> VfsMetadata {
let mut fingerprint = 0xcbf2_9ce4_8422_2325;
hash_u64(&mut fingerprint, metadata.len());
if let Ok(modified) = metadata.modified() {
if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
hash_u64(&mut fingerprint, duration.as_secs());
hash_u64(&mut fingerprint, u64::from(duration.subsec_nanos()));
fn metadata_from_host_file_with_cache(
path: &Path,
metadata: &fs::Metadata,
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
) -> Result<VfsMetadata, VfsError> {
if !metadata.is_file() {
return Err(VfsError::Path);
}
let len = metadata.len();
let modified = metadata.modified().ok();
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,
});
}
VfsMetadata {
len: metadata.len(),
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.
#[derive(Clone, Debug, Default)]
pub struct MemoryVfs {
files: BTreeMap<String, Arc<[u8]>>,
lookup: BTreeMap<Vec<u8>, Vec<String>>,
}
impl MemoryVfs {
/// Inserts a file.
#[allow(clippy::needless_pass_by_value)]
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 {
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
let resolved = self.resolve_path(path)?;
let bytes = self
.files
.get(path.as_str())
.get(resolved)
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
Ok(VfsMetadata {
len: bytes.len() as u64,
fingerprint: stable_hash(bytes),
fingerprint: sha256(bytes),
})
}
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
let resolved = self.resolve_path(path)?;
self.files
.get(path.as_str())
.get(resolved)
.cloned()
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))
}
@@ -263,7 +353,7 @@ impl Vfs for MemoryVfs {
path: normalized,
metadata: VfsMetadata {
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.
#[derive(Clone, Default)]
pub struct OverlayVfs {
@@ -384,6 +458,36 @@ mod tests {
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]
fn directory_vfs_lists_files_below_prefix() {
let root = unique_test_dir("list");
@@ -403,6 +507,45 @@ mod tests {
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]
fn casefold_selector_reports_ambiguous_segments() {
let err = select_casefolded_match(
@@ -417,7 +560,7 @@ mod tests {
}
#[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 mut vfs = MemoryVfs::default();
vfs.insert(path.clone(), Arc::from(b"payload".as_slice()));
@@ -427,7 +570,23 @@ mod tests {
let other_case =
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]
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-binary = { path = "../fparkan-binary" }
[lints]
workspace = true
+283 -34
View File
@@ -1,8 +1,11 @@
#![forbid(unsafe_code)]
//! Deterministic world identity, queue, lifecycle, and snapshots.
use fparkan_binary::sha256;
use std::collections::VecDeque;
const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v2\0";
/// Object handle with generation.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct ObjectHandle {
@@ -105,6 +108,8 @@ pub struct FixedStepClock {
tick: Tick,
paused: bool,
platform_event_collections: u64,
dropped_presentation_millis: u64,
dropped_presentation_frames: u64,
}
/// Fixed-step configuration.
@@ -112,11 +117,16 @@ pub struct FixedStepClock {
pub struct FixedStepConfig {
/// Milliseconds per simulation tick.
pub step_millis: u32,
/// Maximum simulation ticks executed for a single presentation frame.
pub max_steps_per_frame: u32,
}
impl Default for FixedStepConfig {
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 {
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,8 +377,10 @@ pub fn step_with_handler<F>(
where
F: FnMut(&mut World, &WorldCommand) -> Result<(), WorldError>,
{
let before = world.clone();
world.phase = WorldPhase::Calculating;
let mut events = Vec::new();
let result = (|| {
while let Some(command) = world.queue.pop_front() {
if let Some(handle) = command.target {
if world.deferred_delete.contains(&handle) {
@@ -375,7 +397,7 @@ where
world.phase = WorldPhase::ApplyingDeferred;
let deletes = std::mem::take(&mut world.deferred_delete);
for handle in deletes {
let _ = delete_now(world, handle);
delete_now(world, handle)?;
}
world.tick.0 = world.tick.0.saturating_add(1);
world.phase = WorldPhase::PublishingSnapshot;
@@ -387,38 +409,58 @@ where
};
world.phase = WorldPhase::Idle;
Ok(snapshot)
})();
if let Err(err) = result {
*world = before;
world.phase = WorldPhase::Idle;
return Err(err);
}
result
}
/// Computes canonical state hash.
#[must_use]
pub fn canonical_state_hash(world: &World) -> StateHash {
let mut state = 0xcbf2_9ce4_8422_2325_u64;
hash_u64(&mut state, world.tick.0);
for (idx, slot) in world.slots.iter().enumerate() {
hash_u64(&mut state, idx as u64);
hash_u64(&mut state, u64::from(slot.generation));
hash_u64(&mut state, u64::from(u8::from(slot.live)));
hash_u64(&mut state, u64::from(u8::from(slot.registered)));
hash_u64(&mut state, slot.original_id.map_or(0, |id| u64::from(id.0)));
hash_u64(&mut state, slot.mirror_id.map_or(0, |id| u64::from(id.0)));
hash_u64(&mut state, slot.owner_id.map_or(0, |id| u64::from(id.0)));
hash_u64(&mut state, slot.registration_sequence.unwrap_or(u64::MAX));
StateHash(sha256(&canonical_state_bytes(world)))
}
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 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() {
push_len(&mut out, idx);
push_u32(&mut out, slot.generation);
push_bool(&mut out, slot.live);
push_bool(&mut out, slot.registered);
push_optional_u32(&mut out, slot.original_id.map(|id| id.0));
push_optional_u32(&mut out, slot.mirror_id.map(|id| id.0));
push_optional_u16(&mut out, slot.owner_id.map(|id| id.0));
push_optional_u64(&mut out, slot.registration_sequence);
}
push_len(&mut out, world.queue.len());
for command in &world.queue {
push_u64(&mut out, command.sequence);
push_optional_handle(&mut out, command.target);
}
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.
///
/// # 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> {
if config.step_millis == 0 {
if config.step_millis == 0 || config.max_steps_per_frame == 0 {
return Err(WorldError::InvalidFixedStep);
}
Ok(FixedStepClock {
@@ -426,6 +468,8 @@ pub fn fixed_step_clock(config: FixedStepConfig) -> Result<FixedStepClock, World
tick: Tick(0),
paused: false,
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
///
/// 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(
clock: &mut FixedStepClock,
config: FixedStepConfig,
elapsed_millis: u64,
) -> Result<u32, WorldError> {
if config.step_millis == 0 {
if config.step_millis == 0 || config.max_steps_per_frame == 0 {
return Err(WorldError::InvalidFixedStep);
}
if clock.paused {
@@ -459,12 +504,20 @@ pub fn advance_fixed_step(
}
clock.accumulated_millis = clock.accumulated_millis.saturating_add(elapsed_millis);
let step = u64::from(config.step_millis);
let mut ticks = 0_u32;
while clock.accumulated_millis >= step {
clock.accumulated_millis -= step;
clock.tick.0 = clock.tick.0.saturating_add(1);
ticks = ticks.saturating_add(1);
let available_steps = clock.accumulated_millis / step;
let ticks_u64 = available_steps.min(u64::from(config.max_steps_per_frame));
let consumed = ticks_u64.saturating_mul(step);
if available_steps > u64::from(config.max_steps_per_frame) {
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)
}
@@ -480,6 +533,18 @@ pub fn platform_event_collections(clock: &FixedStepClock) -> u64 {
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.
#[must_use]
pub fn end_frame_callback_order(mut callbacks: Vec<WorldEvent>) -> Vec<u64> {
@@ -502,11 +567,57 @@ pub fn shutdown(mut world: World) -> ShutdownReport {
}
}
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);
fn push_len(out: &mut Vec<u8>, value: usize) {
push_u64(out, u64::try_from(value).unwrap_or(u64::MAX));
}
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> {
@@ -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]
fn identity_metadata_keeps_original_mirror_and_owner_distinct() {
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]
fn snapshot_hash_determinism_and_immutability() {
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]
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");
collect_platform_events(&mut clock);
set_paused(&mut clock, true);
@@ -762,6 +954,32 @@ mod tests {
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]
fn render_disabled_does_not_change_hash_end_callbacks_and_shutdown_order() {
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
На текущем рабочем месте закрыты статические, corpus и headless runtime gates.
Для macOS Desktop GL подтверждены безопасный command/state trace и offscreen
pixel capture:
Для macOS Desktop GL есть только безопасный command/state trace и исторический
одноразовый offscreen pixel probe:
- `cargo test -p fparkan-render-gl --offline desktop_gl33_triangle_command_capture`;
- `fixtures/acceptance/macos-gl33-triangle-capture.json`.
`S3-GL-001` считается закрытым для текущей macOS-focused цели: временный
`rustc` probe создал CGL/OpenGL offscreen FBO, выполнил shader-based triangle
draw, прочитал RGBA pixels и сохранил hash capture. Probe не добавляет
project-owned `unsafe` в workspace; постоянный adapter API остаётся safe.
`S3-GL-001` не считается закрытым: временный `rustc` probe создал CGL/OpenGL
offscreen FBO, выполнил shader-based triangle draw, прочитал RGBA pixels и
сохранил hash capture, но постоянный workspace adapter по-прежнему не создаёт
SDL window, GL context, GPU resources, shader programs, draw calls или present.
Probe не добавляет project-owned `unsafe` в workspace и остаётся только external
evidence request artifact.
Для повышения `S3-GL-001` до `covered` нужен постоянный macOS backend через
выбранную safe facade stack: SDL event/window/context lifecycle, Desktop GL 3.3
shader/buffer/texture/draw/present path, hidden-window/offscreen smoke test и
licensed local model/terrain frame capture.
Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2
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-CLI-001 covered cargo test -p fparkan-cli --offline stable_exit_codes_are_mapped
S0-CLI-002 covered cargo test -p fparkan-cli --offline accepts_json_format_option archive_json_has_schema_version
S0-GL-001 covered cargo test -p fparkan-platform-sdl -p fparkan-render-gl --offline adapter_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-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
@@ -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-008 covered cargo test -p fparkan-render --offline recording_backend_stores_captures
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-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
@@ -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-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-009 covered cargo test -p fparkan-animation --offline x87_boundary_golden_vectors_and_profile_difference_report
S4-ANIM-010 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 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-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
@@ -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-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-018 covered cargo test -p fparkan-fx --offline create_records_seed_transform_and_start_time
S4-FX-019 covered cargo test -p fparkan-fx --offline update_and_emit_are_separate
S4-FX-020 covered cargo test -p fparkan-fx --offline update_and_emit_are_separate
S4-FX-021 covered cargo test -p fparkan-fx --offline stable_command_order_and_emission_capture_are_seed_stable
S4-FX-022 covered cargo test -p fparkan-fx --offline stop_restart_end_lifecycle_controls_emission
S4-FX-023 covered cargo test -p fparkan-fx --offline stable_command_order_and_emission_capture_are_seed_stable
S4-FX-024 covered cargo test -p fparkan-fx --offline unrelated_rng_stream_use_does_not_perturb_fx_capture
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 omitted FX update side-effect contract is stub-level only and not accepted as original runtime parity evidence
S4-FX-020 omitted FX emit side-effect contract is stub-level only and not accepted as original runtime parity evidence
S4-FX-021 omitted FX command order is deterministic for the stub but original runtime ordering evidence is not implemented
S4-FX-022 omitted FX lifecycle controls are deterministic for the stub but original stop/restart/end semantics are not implemented
S4-FX-023 omitted FX emission capture is deterministic for the stub but lacks runtime-captured parity evidence
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
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
@@ -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-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-P1-EFFECT-001 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_emission_captures_are_approved
L4-P2-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 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-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
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::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() {
let args = std::env::args().skip(1).collect::<Vec<_>>();
let code = match run(&args) {
@@ -27,8 +31,8 @@ fn run(args: &[String]) -> Result<(), String> {
[cmd] if cmd == "ci" => {
run_rustfmt_check(Path::new("."))?;
run_policy(Path::new("."))?;
cargo(&["test", "--workspace", "--offline"])?;
clippy_rustup(&["--workspace", "--offline"])?;
cargo(&["test", "--workspace", "--locked", "--offline"])?;
clippy_rustup(&["--workspace", "--locked", "--offline"])?;
Ok(())
}
[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" => {
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" => {
let options = parse_test_options(rest, PathBuf::from("testdata"))?;
validate_licensed_root(&options.root)?;
run_stage_tests(options.stage)
let roots = load_licensed_roots(options.manifest.as_deref())?;
run_stage_tests(options.stage, TestSuite::Licensed, Some(&roots))
}
[cmd, subcmd, rest @ ..] if cmd == "corpus" && subcmd == "baseline" => {
let root = parse_root(rest)?;
let manifest =
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));
Ok(())
}
_ => 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(),
),
}
@@ -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> {
let rustup = std::env::var_os("RUSTUP").unwrap_or_else(|| "rustup".into());
let status = Command::new(rustup)
@@ -153,19 +174,124 @@ fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String>
Ok(())
}
fn validate_licensed_root(root: &Path) -> Result<(), String> {
for part in ["IS", "IS2"] {
let part_root = root.join(part);
if !part_root.is_dir() {
return Err(format!(
"licensed corpus part is missing: {}",
part_root.display()
));
#[derive(Clone, Debug, Eq, PartialEq)]
struct LicensedCorpusRoots {
part1: PathBuf,
part2: PathBuf,
}
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(())
}
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> {
let mut iter = args.iter();
while let Some(arg) = iter.next() {
@@ -248,6 +374,7 @@ fn run_package(options: &PackageOptions) -> Result<(), String> {
"-p".to_string(),
options.app.package().to_string(),
"--release".to_string(),
"--locked".to_string(),
"--offline".to_string(),
"--target".to_string(),
options.target.clone(),
@@ -258,6 +385,8 @@ fn run_policy(root: &Path) -> Result<(), String> {
let mut failures = Vec::new();
scan_policy_dir(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)?;
if failures.is_empty() {
Ok(())
@@ -278,6 +407,7 @@ fn validate_cargo_metadata(root: &Path, failures: &mut Vec<String>) -> Result<()
"--format-version",
"1",
"--offline",
"--locked",
"--no-deps",
"--manifest-path",
])
@@ -295,6 +425,62 @@ fn validate_cargo_metadata(root: &Path, failures: &mut Vec<String>) -> Result<()
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> {
let mut manifests = Vec::new();
collect_cargo_manifests(root, &mut manifests)?;
@@ -357,6 +543,23 @@ fn collect_cargo_manifests(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), Str
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> {
let mut in_package = false;
for line in manifest.lines() {
@@ -640,6 +843,7 @@ impl fmt::Display for Stage {
struct TestOptions {
stage: Stage,
root: PathBuf,
manifest: Option<PathBuf>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -670,6 +874,7 @@ struct AcceptanceOptions {
suite: TestSuite,
stage: Stage,
root: PathBuf,
manifest: Option<PathBuf>,
out: PathBuf,
}
@@ -685,6 +890,7 @@ fn parse_test_options(args: &[String], default_root: PathBuf) -> Result<TestOpti
let mut options = TestOptions {
stage: Stage::All,
root: default_root,
manifest: None,
};
let mut iter = args.iter();
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())?;
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}")),
}
}
@@ -711,6 +923,7 @@ fn parse_acceptance_options(args: &[String]) -> Result<AcceptanceOptions, String
let mut suite = None;
let mut stage = Stage::All;
let mut root = PathBuf::from("testdata");
let mut manifest = None;
let mut out = None;
let mut iter = args.iter();
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())?;
root = PathBuf::from(value);
}
"--manifest" => {
let value = iter
.next()
.ok_or_else(|| "--manifest requires a path".to_string())?;
manifest = Some(PathBuf::from(value));
}
"--out" => {
let value = iter
.next()
@@ -755,6 +974,7 @@ fn parse_acceptance_options(args: &[String]) -> Result<AcceptanceOptions, String
suite,
stage,
root,
manifest,
out,
})
}
@@ -1079,10 +1299,12 @@ fn json_escape(value: &str) -> String {
}
fn run_acceptance_report(options: &AcceptanceOptions) -> Result<(), String> {
if options.suite == TestSuite::Licensed {
validate_licensed_root(&options.root)?;
}
run_stage_tests(options.stage)?;
let roots = if options.suite == TestSuite::Licensed {
Some(load_licensed_roots(options.manifest.as_deref())?)
} else {
None
};
run_stage_tests(options.stage, options.suite, roots.as_ref())?;
if let Some(parent) = options.out.parent() {
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 {
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) => {
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(())
}
@@ -1189,6 +1434,13 @@ mod tests {
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]
fn parses_stage_and_root_options() {
let args = strings(&["--stage", "3", "--root", "fixtures"]);
@@ -1199,6 +1451,7 @@ mod tests {
Ok(TestOptions {
stage: Stage::Number(3),
root: PathBuf::from("fixtures"),
manifest: None,
})
);
}
@@ -1210,8 +1463,8 @@ mod tests {
"licensed",
"--stage",
"5",
"--root",
"testdata",
"--manifest",
"corpora.toml",
"--out",
"target/report.json",
]));
@@ -1222,6 +1475,7 @@ mod tests {
suite: TestSuite::Licensed,
stage: Stage::Number(5),
root: PathBuf::from("testdata"),
manifest: Some(PathBuf::from("corpora.toml")),
out: PathBuf::from("target/report.json"),
})
);
@@ -1233,6 +1487,7 @@ mod tests {
suite: TestSuite::Licensed,
stage: Stage::Number(0),
root: PathBuf::from("/private/game"),
manifest: Some(PathBuf::from("/private/corpora.toml")),
out: PathBuf::from("target/report.json"),
};
let report = render_acceptance_report(&options);
@@ -1334,10 +1589,54 @@ mod tests {
Ok(TestOptions {
stage: Stage::All,
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]
fn rejects_unknown_stage() {
assert_eq!(Stage::parse("6"), Err("stage out of range: 6".to_string()));