Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
162de8ccab
|
||
|
|
0b23cf48e7
|
||
|
|
7356238ffb
|
||
|
|
42441082f0
|
||
|
|
ccd61c05b0
|
||
|
|
813beec7be
|
||
|
|
91c7a8a14e
|
||
|
|
8b91a0bfbf
|
||
|
|
fb97405e0c
|
||
|
|
d579b696e6
|
||
|
|
aa1b809bd8
|
||
|
|
f69c893a40
|
||
|
|
5436727961
|
||
|
|
be41fa839f
|
||
|
|
8e5e46b7b3
|
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
///
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
@@ -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: {}",
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
|
||||
[lints]
|
||||
|
||||
+210
-51
@@ -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]
|
||||
|
||||
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+282
-33
@@ -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);
|
||||
StateHash(sha256(&canonical_state_bytes(world)))
|
||||
}
|
||||
|
||||
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() {
|
||||
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));
|
||||
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);
|
||||
}
|
||||
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)
|
||||
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,13 +567,59 @@ 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> {
|
||||
let slot = world
|
||||
.slots
|
||||
@@ -623,6 +734,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_error_display_is_actionable() {
|
||||
assert_eq!(
|
||||
WorldError::StaleHandle.to_string(),
|
||||
"object handle belongs to an older slot generation"
|
||||
);
|
||||
assert_eq!(
|
||||
WorldError::DuplicateOriginalObjectId(OriginalObjectId(8)).to_string(),
|
||||
"original object id 8 is already registered"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 и тот же
|
||||
|
||||
@@ -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
|
||||
|
||||
|
+323
-24
@@ -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()));
|
||||
|
||||
Reference in New Issue
Block a user