Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
162de8ccab
|
||
|
|
0b23cf48e7
|
||
|
|
7356238ffb
|
||
|
|
42441082f0
|
||
|
|
ccd61c05b0
|
||
|
|
813beec7be
|
||
|
|
91c7a8a14e
|
||
|
|
8b91a0bfbf
|
||
|
|
fb97405e0c
|
||
|
|
d579b696e6
|
||
|
|
aa1b809bd8
|
||
|
|
f69c893a40
|
||
|
|
5436727961
|
||
|
|
be41fa839f
|
||
|
|
8e5e46b7b3
|
+1
-5
@@ -69,10 +69,6 @@ $RECYCLE.BIN/
|
|||||||
debug/
|
debug/
|
||||||
target/
|
target/
|
||||||
|
|
||||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
|
||||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
|
||||||
Cargo.lock
|
|
||||||
|
|
||||||
# These are backup files generated by rustfmt
|
# These are backup files generated by rustfmt
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
|
|
||||||
@@ -215,4 +211,4 @@ poetry.toml
|
|||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
|
||||||
# LSP config files
|
# LSP config files
|
||||||
pyrightconfig.json
|
pyrightconfig.json
|
||||||
|
|||||||
Generated
+315
@@ -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]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "GPL-2.0-only"
|
||||||
repository = "https://github.com/valentineus/fparkan"
|
repository = "https://github.com/valentineus/fparkan"
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
|
|||||||
@@ -40,10 +40,27 @@ cargo xtask ci
|
|||||||
- разместите игровые каталоги в [`testdata/`](testdata);
|
- разместите игровые каталоги в [`testdata/`](testdata);
|
||||||
- игровые ресурсы в репозиторий не включаются, так как защищены авторским правом.
|
- игровые ресурсы в репозиторий не включаются, так как защищены авторским правом.
|
||||||
|
|
||||||
Локальный licensed gate:
|
Локальный licensed gate использует некоммитимый manifest:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo xtask acceptance report --suite licensed --stage 5 --root testdata
|
cat > /private/tmp/fparkan-corpora.toml <<'EOF'
|
||||||
|
schema = 1
|
||||||
|
|
||||||
|
[[corpus]]
|
||||||
|
id = "part1-local"
|
||||||
|
kind = "part1"
|
||||||
|
root = "/absolute/path/to/IS"
|
||||||
|
expected_profile = "parkan-is-part1"
|
||||||
|
|
||||||
|
[[corpus]]
|
||||||
|
id = "part2-local"
|
||||||
|
kind = "part2"
|
||||||
|
root = "/absolute/path/to/IS2"
|
||||||
|
expected_profile = "parkan-is-part2"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
FPARKAN_CORPORA_MANIFEST=/private/tmp/fparkan-corpora.toml \
|
||||||
|
cargo xtask acceptance report --suite licensed --stage 5
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contributing & Support
|
## Contributing & Support
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
//! SDL platform adapter proof behind safe `FParkan` ports.
|
//! SDL platform adapter boundary stubs behind safe `FParkan` ports.
|
||||||
|
|
||||||
use fparkan_platform::{
|
use fparkan_platform::{
|
||||||
EventSource, GraphicsContextRequest, GraphicsProfile, PhysicalSize, PlatformError,
|
EventSource, GraphicsContextRequest, GraphicsProfile, PhysicalSize, PlatformError,
|
||||||
@@ -33,20 +33,20 @@ impl Default for SdlAdapterCapabilities {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns adapter readiness status for the safe project-owned layer.
|
/// Returns whether the project-owned adapter boundary avoids `unsafe`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn safe_adapter_ready() -> bool {
|
pub fn project_owned_layer_unsafe_free() -> bool {
|
||||||
SdlAdapterCapabilities::default().project_owned_unsafe_free
|
SdlAdapterCapabilities::default().project_owned_unsafe_free
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-memory event source used by adapter smoke tests and composition roots
|
/// In-memory event source used by adapter smoke tests before a concrete SDL
|
||||||
/// before a concrete SDL runtime is injected.
|
/// runtime is selected.
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct SdlEventSourceProof {
|
pub struct SdlEventSourceStub {
|
||||||
pending: Vec<PlatformEvent>,
|
pending: Vec<PlatformEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SdlEventSourceProof {
|
impl SdlEventSourceStub {
|
||||||
/// Creates an event source with deterministic pending events.
|
/// Creates an event source with deterministic pending events.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(pending: Vec<PlatformEvent>) -> Self {
|
pub fn new(pending: Vec<PlatformEvent>) -> Self {
|
||||||
@@ -54,22 +54,22 @@ impl SdlEventSourceProof {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventSource for SdlEventSourceProof {
|
impl EventSource for SdlEventSourceStub {
|
||||||
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError> {
|
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError> {
|
||||||
out.append(&mut self.pending);
|
out.append(&mut self.pending);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Safe window-port proof with SDL-compatible drawable-size semantics.
|
/// Safe window-port stub with SDL-compatible drawable-size semantics.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct SdlWindowProof {
|
pub struct SdlWindowStub {
|
||||||
size: PhysicalSize,
|
size: PhysicalSize,
|
||||||
presents: u64,
|
presents: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SdlWindowProof {
|
impl SdlWindowStub {
|
||||||
/// Creates a proof window with a fixed drawable size.
|
/// Creates a stub window with a fixed drawable size.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(size: PhysicalSize) -> Self {
|
pub fn new(size: PhysicalSize) -> Self {
|
||||||
Self { size, presents: 0 }
|
Self { size, presents: 0 }
|
||||||
@@ -82,7 +82,7 @@ impl SdlWindowProof {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WindowPort for SdlWindowProof {
|
impl WindowPort for SdlWindowStub {
|
||||||
fn drawable_size(&self) -> PhysicalSize {
|
fn drawable_size(&self) -> PhysicalSize {
|
||||||
self.size
|
self.size
|
||||||
}
|
}
|
||||||
@@ -98,20 +98,20 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adapter_reports_safe_project_layer_ready() {
|
fn adapter_boundary_is_project_owned_unsafe_free() {
|
||||||
assert!(safe_adapter_ready());
|
assert!(project_owned_layer_unsafe_free());
|
||||||
assert_eq!(SdlAdapterCapabilities::default().graphics.len(), 2);
|
assert_eq!(SdlAdapterCapabilities::default().graphics.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_source_and_window_ports_are_deterministic() -> Result<(), PlatformError> {
|
fn event_source_and_window_ports_are_deterministic() -> Result<(), PlatformError> {
|
||||||
let mut source = SdlEventSourceProof::new(vec![PlatformEvent::Quit]);
|
let mut source = SdlEventSourceStub::new(vec![PlatformEvent::Quit]);
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
source.poll(&mut events)?;
|
source.poll(&mut events)?;
|
||||||
source.poll(&mut events)?;
|
source.poll(&mut events)?;
|
||||||
assert_eq!(events, vec![PlatformEvent::Quit]);
|
assert_eq!(events, vec![PlatformEvent::Quit]);
|
||||||
|
|
||||||
let mut window = SdlWindowProof::new(PhysicalSize {
|
let mut window = SdlWindowStub::new(PhysicalSize {
|
||||||
width: 320,
|
width: 320,
|
||||||
height: 240,
|
height: 240,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
//! OpenGL render adapter proof behind safe `FParkan` render ports.
|
//! OpenGL render adapter boundary stubs behind safe `FParkan` render ports.
|
||||||
|
|
||||||
use fparkan_render::{
|
use fparkan_render::{
|
||||||
canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError,
|
canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError,
|
||||||
@@ -64,9 +64,9 @@ impl Default for GlAdapterCapabilities {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns adapter readiness status for the safe project-owned layer.
|
/// Returns whether the project-owned adapter boundary avoids `unsafe`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn safe_adapter_ready() -> bool {
|
pub fn project_owned_layer_unsafe_free() -> bool {
|
||||||
GlAdapterCapabilities::default().project_owned_unsafe_free
|
GlAdapterCapabilities::default().project_owned_unsafe_free
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ pub fn compile_shader_source(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Safe render backend facade used for adapter-level command validation.
|
/// Safe render backend stub used for adapter-level command validation.
|
||||||
///
|
///
|
||||||
/// A concrete OpenGL implementation can be injected behind the same
|
/// A concrete OpenGL implementation can be injected behind the same
|
||||||
/// [`RenderBackend`] port once an audited safe GL facade is selected. This type
|
/// [`RenderBackend`] port once an audited safe GL facade is selected. This type
|
||||||
@@ -147,8 +147,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adapter_reports_safe_project_layer_ready() {
|
fn adapter_boundary_is_project_owned_unsafe_free() {
|
||||||
assert!(safe_adapter_ready());
|
assert!(project_owned_layer_unsafe_free());
|
||||||
assert_eq!(GlAdapterCapabilities::default().profiles.len(), 2);
|
assert_eq!(GlAdapterCapabilities::default().profiles.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,25 @@
|
|||||||
|
|
||||||
Status: provisional
|
Status: provisional
|
||||||
|
|
||||||
Workspace-owned code forbids `unsafe`. SDL/OpenGL adapters must use maintained external crates behind a safe project API. The current repository still contains legacy demo code scheduled for replacement; new adapter crates are placeholders until a fully audited safe facade is selected and proven on all target profiles.
|
Workspace-owned code forbids `unsafe`. SDL/OpenGL adapters must use maintained
|
||||||
|
external crates behind a safe project API; local Objective-C/CGL/SDL/OpenGL FFI
|
||||||
|
inside FParkan is not an acceptable implementation strategy.
|
||||||
|
|
||||||
|
The current adapter crates are safe boundary stubs. They compile the intended
|
||||||
|
ports and deterministic command contracts, but they do not create SDL windows,
|
||||||
|
GL contexts, GPU resources, shaders, draw calls, swapchains, or presents. They
|
||||||
|
must not be treated as backend readiness evidence.
|
||||||
|
|
||||||
|
To close the macOS backend requirement, choose and vendor/lock a maintained
|
||||||
|
safe facade stack, then implement:
|
||||||
|
|
||||||
|
- SDL event source, window creation, GL context lifecycle, drawable size and
|
||||||
|
present;
|
||||||
|
- GL shader compile/link, buffer/texture upload, render state, draw calls and
|
||||||
|
diagnostics;
|
||||||
|
- game/viewer composition roots using those adapters;
|
||||||
|
- hidden-window/offscreen macOS smoke tests and licensed local model/terrain
|
||||||
|
frame captures.
|
||||||
|
|
||||||
|
Until those are implemented, Desktop GL evidence may document external probes
|
||||||
|
only; it does not satisfy the permanent adapter requirement.
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
|||||||
let root = parse_root(&rest)?;
|
let root = parse_root(&rest)?;
|
||||||
let manifest =
|
let manifest =
|
||||||
discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
|
discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
|
||||||
let report = report(&root, &manifest);
|
let report = report(&root, &manifest).map_err(|e| e.to_string())?;
|
||||||
println!("{}", render_report_json(&report));
|
println!("{}", render_report_json(&report));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -41,10 +41,13 @@ fn run(args: &[String]) -> Result<(), String> {
|
|||||||
let root = parse_root(&rest)?;
|
let root = parse_root(&rest)?;
|
||||||
let manifest =
|
let manifest =
|
||||||
discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
|
discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
|
||||||
let report = report(&root, &manifest);
|
let report = report(&root, &manifest).map_err(|e| e.to_string())?;
|
||||||
if report.casefold_collisions > 0 {
|
if report.casefold_collisions > 0 {
|
||||||
return Err("casefold collisions found".to_string());
|
return Err("casefold collisions found".to_string());
|
||||||
}
|
}
|
||||||
|
if report.failures > 0 {
|
||||||
|
return Err(format!("corpus report found {} failures", report.failures));
|
||||||
|
}
|
||||||
println!("{}", render_report_json(&report));
|
println!("{}", render_report_json(&report));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,21 +262,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn selected_is_and_is2_missions_produce_approved_render_captures() {
|
fn selected_is_and_is2_missions_produce_approved_render_captures() {
|
||||||
for case in [
|
for case in [
|
||||||
RenderCase {
|
RenderCase {
|
||||||
root: "IS",
|
root: "IS",
|
||||||
mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma",
|
mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma",
|
||||||
expected: "{\"mission\":\"MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma\",\"objects\":33,\"frames\":1,\"tick\":1,\"draws\":33,\"captures\":1,\"last_capture_bytes\":810,\"hash\":\"8584c4307bc911fc82bf909018662f392f3982bf909018666298bde408fe4242\"}",
|
expected: "{\"mission\":\"MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma\",\"objects\":33,\"frames\":1,\"tick\":1,\"draws\":33,\"captures\":1,\"last_capture_bytes\":810,\"hash\":\"ca17cc76e55c45e83c1c9c1c088e84bf1a698be91a7730943210fe27596af841\"}",
|
||||||
},
|
},
|
||||||
RenderCase {
|
RenderCase {
|
||||||
root: "IS2",
|
root: "IS2",
|
||||||
mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
|
mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
|
||||||
expected: "{\"mission\":\"MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma\",\"objects\":10,\"frames\":1,\"tick\":1,\"draws\":10,\"captures\":1,\"last_capture_bytes\":235,\"hash\":\"c52267cb14f699cb73b958e46c99c23ec23e73b958e46c99b3650afbcce56291\"}",
|
expected: "{\"mission\":\"MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma\",\"objects\":10,\"frames\":1,\"tick\":1,\"draws\":10,\"captures\":1,\"last_capture_bytes\":235,\"hash\":\"5d720b3ab690076a398a79a404850bbeaee2e33811b5bb570ec8a96d4a7a2fc4\"}",
|
||||||
},
|
},
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
run(&render_args(&workspace_root().join("testdata").join(case.root), case.mission)),
|
run(&render_args(&licensed_root(case.root), case.mission)),
|
||||||
Ok(case.expected.to_string())
|
Ok(case.expected.to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -312,11 +313,20 @@ mod tests {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_root() -> PathBuf {
|
fn licensed_root(name: &str) -> PathBuf {
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
let variable = match name {
|
||||||
.parent()
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
||||||
.and_then(Path::parent)
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
||||||
.expect("workspace root")
|
_ => panic!("unknown licensed corpus part: {name}"),
|
||||||
.to_path_buf()
|
};
|
||||||
|
let root = std::env::var_os(variable)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
|
||||||
|
assert!(
|
||||||
|
root.is_dir(),
|
||||||
|
"licensed corpus root is missing: {}",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
root
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
#![allow(clippy::cast_precision_loss)]
|
#![allow(clippy::cast_precision_loss)]
|
||||||
//! Deterministic animation sampling contracts.
|
//! Deterministic animation sampling contracts.
|
||||||
|
//!
|
||||||
|
//! The current sampler is a portable reference path. Compatibility profiles
|
||||||
|
//! that require runtime-captured x87 parity remain explicit evidence gaps.
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
/// Numeric profile.
|
/// Numeric profile.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum NumericProfile {
|
pub enum NumericProfile {
|
||||||
/// Portable reference.
|
/// Portable reference sampler implemented by this crate.
|
||||||
PortableReference,
|
PortableReference,
|
||||||
/// X87-compatible compatibility profile for captured parity vectors.
|
/// Reserved profile for future runtime-captured x87 parity vectors.
|
||||||
X87Compatibility,
|
X87Compatibility,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,8 +427,11 @@ impl PoseTrack {
|
|||||||
&self.keys
|
&self.keys
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Samples the pose track with linear translation and normalized quaternion
|
/// Samples the pose track with the portable reference path.
|
||||||
/// interpolation.
|
///
|
||||||
|
/// `NumericProfile::X87Compatibility` is accepted so callers can keep the
|
||||||
|
/// compatibility contract explicit, but it does not yet select an
|
||||||
|
/// independently captured x87 runtime path.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use fparkan_msh::{decode_msh, validate_msh};
|
|||||||
use fparkan_nres::{decode as decode_nres, ReadProfile};
|
use fparkan_nres::{decode as decode_nres, ReadProfile};
|
||||||
use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName};
|
use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName};
|
||||||
use fparkan_prototype::{EffectivePrototype, PrototypeGeometry, PrototypeGraph};
|
use fparkan_prototype::{EffectivePrototype, PrototypeGeometry, PrototypeGraph};
|
||||||
use fparkan_resource::{ResourceKey, ResourceRepository};
|
use fparkan_resource::{ResourceError, ResourceKey, ResourceRepository};
|
||||||
use fparkan_texm::decode_texm;
|
use fparkan_texm::decode_texm;
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
@@ -279,17 +279,13 @@ pub fn prepare_visual_with_repository<R: ResourceRepository>(
|
|||||||
material_count += 1;
|
material_count += 1;
|
||||||
|
|
||||||
for texture in material.document.texture_requests() {
|
for texture in material.document.texture_requests() {
|
||||||
resolve_texm(repository, &texture, &[TEXTURES_ARCHIVE, LIGHTMAP_ARCHIVE])?;
|
resolve_texture(repository, &texture)?;
|
||||||
texture_count += 1;
|
texture_count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for lightmap in &wear.lightmaps {
|
for lightmap in &wear.lightmaps {
|
||||||
resolve_texm(
|
resolve_lightmap(repository, &lightmap.lightmap)?;
|
||||||
repository,
|
|
||||||
&lightmap.lightmap,
|
|
||||||
&[LIGHTMAP_ARCHIVE, TEXTURES_ARCHIVE],
|
|
||||||
)?;
|
|
||||||
lightmap_count += 1;
|
lightmap_count += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,28 +321,59 @@ fn read_key<R: ResourceRepository>(
|
|||||||
Ok(Arc::from(bytes.into_owned()))
|
Ok(Arc::from(bytes.into_owned()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_texture<R: ResourceRepository>(
|
||||||
|
repository: &R,
|
||||||
|
name: &ResourceName,
|
||||||
|
) -> Result<(), AssetError> {
|
||||||
|
resolve_texm(repository, name, TEXTURES_ARCHIVE, "texture")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_lightmap<R: ResourceRepository>(
|
||||||
|
repository: &R,
|
||||||
|
name: &ResourceName,
|
||||||
|
) -> Result<(), AssetError> {
|
||||||
|
resolve_texm(repository, name, LIGHTMAP_ARCHIVE, "lightmap")
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_texm<R: ResourceRepository>(
|
fn resolve_texm<R: ResourceRepository>(
|
||||||
repository: &R,
|
repository: &R,
|
||||||
name: &ResourceName,
|
name: &ResourceName,
|
||||||
archives: &[&str],
|
archive: &str,
|
||||||
|
label: &'static str,
|
||||||
) -> Result<(), AssetError> {
|
) -> Result<(), AssetError> {
|
||||||
for archive in archives {
|
let key = ResourceKey {
|
||||||
let key = ResourceKey {
|
archive: parse_path(archive)?,
|
||||||
archive: parse_path(archive)?,
|
name: name.clone(),
|
||||||
name: name.clone(),
|
type_id: None,
|
||||||
type_id: None,
|
};
|
||||||
};
|
let Some(bytes) = read_optional_key(repository, &key, Some(label))? else {
|
||||||
match read_key(repository, &key, Some("texm")) {
|
return Err(AssetError::MissingDependency(format!("{label} {name:?}")));
|
||||||
Ok(bytes) => {
|
};
|
||||||
decode_texm(bytes).map_err(|err| AssetError::Texture(err.to_string()))?;
|
decode_texm(bytes)
|
||||||
return Ok(());
|
.map(|_| ())
|
||||||
}
|
.map_err(|err| AssetError::Texture(err.to_string()))
|
||||||
Err(AssetError::MissingDependency(_) | AssetError::Resource(_)) => {}
|
}
|
||||||
Err(err) => return Err(err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(AssetError::MissingDependency(format!("{name:?}")))
|
fn read_optional_key<R: ResourceRepository>(
|
||||||
|
repository: &R,
|
||||||
|
key: &ResourceKey,
|
||||||
|
label: Option<&str>,
|
||||||
|
) -> Result<Option<Arc<[u8]>>, AssetError> {
|
||||||
|
let archive = match repository.open_archive(&key.archive) {
|
||||||
|
Ok(archive) => archive,
|
||||||
|
Err(ResourceError::MissingArchive | ResourceError::MissingEntry) => return Ok(None),
|
||||||
|
Err(err) => return Err(AssetError::Resource(format!("{label:?} {key:?}: {err}"))),
|
||||||
|
};
|
||||||
|
let Some(handle) = repository
|
||||||
|
.find(archive, &key.name)
|
||||||
|
.map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let bytes = repository
|
||||||
|
.read(handle)
|
||||||
|
.map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))?;
|
||||||
|
Ok(Some(Arc::from(bytes.into_owned())))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, AssetError> {
|
fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, AssetError> {
|
||||||
@@ -412,7 +439,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use fparkan_prototype::build_prototype_graph;
|
use fparkan_prototype::build_prototype_graph;
|
||||||
use fparkan_resource::{resource_name, CachedResourceRepository};
|
use fparkan_resource::{resource_name, CachedResourceRepository};
|
||||||
use fparkan_vfs::{DirectoryVfs, Vfs};
|
use fparkan_vfs::{DirectoryVfs, MemoryVfs, Vfs};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -426,6 +453,48 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
fn texture_resolver_does_not_fallback_to_lightmap_archive() {
|
||||||
|
let texm = texm_payload();
|
||||||
|
let repo = repository_with_archives(&[(
|
||||||
|
LIGHTMAP_ARCHIVE,
|
||||||
|
&[(b"TEX_ONLY".as_slice(), texm.as_slice())],
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let err = resolve_texture(&repo, &resource_name(b"TEX_ONLY")).expect_err("missing texture");
|
||||||
|
|
||||||
|
assert!(matches!(err, AssetError::MissingDependency(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lightmap_resolver_does_not_fallback_to_texture_archive() {
|
||||||
|
let texm = texm_payload();
|
||||||
|
let repo = repository_with_archives(&[(
|
||||||
|
TEXTURES_ARCHIVE,
|
||||||
|
&[(b"LM_ONLY".as_slice(), texm.as_slice())],
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let err =
|
||||||
|
resolve_lightmap(&repo, &resource_name(b"LM_ONLY")).expect_err("missing lightmap");
|
||||||
|
|
||||||
|
assert!(matches!(err, AssetError::MissingDependency(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texture_resolver_does_not_continue_after_malformed_texture() {
|
||||||
|
let malformed = b"not texm".as_slice();
|
||||||
|
let texm = texm_payload();
|
||||||
|
let repo = repository_with_archives(&[
|
||||||
|
(TEXTURES_ARCHIVE, &[(b"BAD".as_slice(), malformed)]),
|
||||||
|
(LIGHTMAP_ARCHIVE, &[(b"BAD".as_slice(), texm.as_slice())]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let err = resolve_texture(&repo, &resource_name(b"BAD")).expect_err("malformed texture");
|
||||||
|
|
||||||
|
assert!(matches!(err, AssetError::Texture(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn prepares_real_unit_asset_plan() {
|
fn prepares_real_unit_asset_plan() {
|
||||||
let root = fixture_root("IS");
|
let root = fixture_root("IS");
|
||||||
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
|
||||||
@@ -448,6 +517,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn repository_plan_deduplicates_duplicate_visuals_but_graph_preserves_requests() {
|
fn repository_plan_deduplicates_duplicate_visuals_but_graph_preserves_requests() {
|
||||||
let root = fixture_root("IS");
|
let root = fixture_root("IS");
|
||||||
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
|
||||||
@@ -473,9 +543,80 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn fixture_root(part: &str) -> PathBuf {
|
fn fixture_root(part: &str) -> PathBuf {
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
let variable = match part {
|
||||||
.join("../..")
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
||||||
.join("testdata")
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
||||||
.join(part)
|
_ => panic!("unknown licensed corpus part: {part}"),
|
||||||
|
};
|
||||||
|
let root = std::env::var_os(variable)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
|
||||||
|
assert!(
|
||||||
|
root.is_dir(),
|
||||||
|
"licensed corpus root is missing: {}",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repository_with_archives(
|
||||||
|
archives: &[(&str, &[(&[u8], &[u8])])],
|
||||||
|
) -> CachedResourceRepository {
|
||||||
|
let mut vfs = MemoryVfs::default();
|
||||||
|
for (archive, entries) in archives {
|
||||||
|
let path = parse_path(archive).expect("archive path");
|
||||||
|
vfs.insert(path, Arc::from(build_nres(entries).into_boxed_slice()));
|
||||||
|
}
|
||||||
|
CachedResourceRepository::new(Arc::new(vfs))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn texm_payload() -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
out.extend_from_slice(&0x6d78_6554_u32.to_le_bytes());
|
||||||
|
out.extend_from_slice(&1_u32.to_le_bytes());
|
||||||
|
out.extend_from_slice(&1_u32.to_le_bytes());
|
||||||
|
out.extend_from_slice(&1_u32.to_le_bytes());
|
||||||
|
out.extend_from_slice(&0_u32.to_le_bytes());
|
||||||
|
out.extend_from_slice(&0_u32.to_le_bytes());
|
||||||
|
out.extend_from_slice(&0_u32.to_le_bytes());
|
||||||
|
out.extend_from_slice(&565_u32.to_le_bytes());
|
||||||
|
out.extend_from_slice(&0xffff_u16.to_le_bytes());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_nres(entries: &[(&[u8], &[u8])]) -> Vec<u8> {
|
||||||
|
let mut out = vec![0; 16];
|
||||||
|
let mut offsets = Vec::with_capacity(entries.len());
|
||||||
|
for (_, payload) in entries {
|
||||||
|
offsets.push(u32::try_from(out.len()).expect("offset"));
|
||||||
|
out.extend_from_slice(payload);
|
||||||
|
let padding = (8 - (out.len() % 8)) % 8;
|
||||||
|
out.resize(out.len() + padding, 0);
|
||||||
|
}
|
||||||
|
let mut order: Vec<usize> = (0..entries.len()).collect();
|
||||||
|
order.sort_by(|left, right| entries[*left].0.cmp(entries[*right].0));
|
||||||
|
for (idx, (name, payload)) in entries.iter().enumerate() {
|
||||||
|
push_u32(&mut out, 0);
|
||||||
|
push_u32(&mut out, 0);
|
||||||
|
push_u32(&mut out, 0);
|
||||||
|
push_u32(&mut out, u32::try_from(payload.len()).expect("payload"));
|
||||||
|
push_u32(&mut out, 0);
|
||||||
|
let mut name_raw = [0; 36];
|
||||||
|
let len = name_raw.len().saturating_sub(1).min(name.len());
|
||||||
|
name_raw[..len].copy_from_slice(&name[..len]);
|
||||||
|
out.extend_from_slice(&name_raw);
|
||||||
|
push_u32(&mut out, offsets[idx]);
|
||||||
|
push_u32(&mut out, u32::try_from(order[idx]).expect("sort index"));
|
||||||
|
}
|
||||||
|
out[0..4].copy_from_slice(b"NRes");
|
||||||
|
out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
|
||||||
|
out[8..12].copy_from_slice(&u32::try_from(entries.len()).expect("count").to_le_bytes());
|
||||||
|
let total_size = u32::try_from(out.len()).expect("total size");
|
||||||
|
out[12..16].copy_from_slice(&total_size.to_le_bytes());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_u32(out: &mut Vec<u8>, value: u32) {
|
||||||
|
out.extend_from_slice(&value.to_le_bytes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
|
/// SHA-256 digest bytes.
|
||||||
|
pub type Sha256Digest = [u8; 32];
|
||||||
|
|
||||||
/// Parser limits shared by binary formats.
|
/// Parser limits shared by binary formats.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub struct Limits {
|
pub struct Limits {
|
||||||
@@ -262,6 +265,183 @@ pub fn read_lp_bytes(cursor: &mut Cursor<'_>, max: u32) -> Result<Vec<u8>, Decod
|
|||||||
Ok(cursor.read_exact(len)?.to_vec())
|
Ok(cursor.read_exact(len)?.to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Computes a SHA-256 content digest without external dependencies.
|
||||||
|
#[must_use]
|
||||||
|
pub fn sha256(bytes: &[u8]) -> Sha256Digest {
|
||||||
|
const K: [u32; 64] = [
|
||||||
|
0x428a_2f98,
|
||||||
|
0x7137_4491,
|
||||||
|
0xb5c0_fbcf,
|
||||||
|
0xe9b5_dba5,
|
||||||
|
0x3956_c25b,
|
||||||
|
0x59f1_11f1,
|
||||||
|
0x923f_82a4,
|
||||||
|
0xab1c_5ed5,
|
||||||
|
0xd807_aa98,
|
||||||
|
0x1283_5b01,
|
||||||
|
0x2431_85be,
|
||||||
|
0x550c_7dc3,
|
||||||
|
0x72be_5d74,
|
||||||
|
0x80de_b1fe,
|
||||||
|
0x9bdc_06a7,
|
||||||
|
0xc19b_f174,
|
||||||
|
0xe49b_69c1,
|
||||||
|
0xefbe_4786,
|
||||||
|
0x0fc1_9dc6,
|
||||||
|
0x240c_a1cc,
|
||||||
|
0x2de9_2c6f,
|
||||||
|
0x4a74_84aa,
|
||||||
|
0x5cb0_a9dc,
|
||||||
|
0x76f9_88da,
|
||||||
|
0x983e_5152,
|
||||||
|
0xa831_c66d,
|
||||||
|
0xb003_27c8,
|
||||||
|
0xbf59_7fc7,
|
||||||
|
0xc6e0_0bf3,
|
||||||
|
0xd5a7_9147,
|
||||||
|
0x06ca_6351,
|
||||||
|
0x1429_2967,
|
||||||
|
0x27b7_0a85,
|
||||||
|
0x2e1b_2138,
|
||||||
|
0x4d2c_6dfc,
|
||||||
|
0x5338_0d13,
|
||||||
|
0x650a_7354,
|
||||||
|
0x766a_0abb,
|
||||||
|
0x81c2_c92e,
|
||||||
|
0x9272_2c85,
|
||||||
|
0xa2bf_e8a1,
|
||||||
|
0xa81a_664b,
|
||||||
|
0xc24b_8b70,
|
||||||
|
0xc76c_51a3,
|
||||||
|
0xd192_e819,
|
||||||
|
0xd699_0624,
|
||||||
|
0xf40e_3585,
|
||||||
|
0x106a_a070,
|
||||||
|
0x19a4_c116,
|
||||||
|
0x1e37_6c08,
|
||||||
|
0x2748_774c,
|
||||||
|
0x34b0_bcb5,
|
||||||
|
0x391c_0cb3,
|
||||||
|
0x4ed8_aa4a,
|
||||||
|
0x5b9c_ca4f,
|
||||||
|
0x682e_6ff3,
|
||||||
|
0x748f_82ee,
|
||||||
|
0x78a5_636f,
|
||||||
|
0x84c8_7814,
|
||||||
|
0x8cc7_0208,
|
||||||
|
0x90be_fffa,
|
||||||
|
0xa450_6ceb,
|
||||||
|
0xbef9_a3f7,
|
||||||
|
0xc671_78f2,
|
||||||
|
];
|
||||||
|
let mut h = [
|
||||||
|
0x6a09_e667,
|
||||||
|
0xbb67_ae85,
|
||||||
|
0x3c6e_f372,
|
||||||
|
0xa54f_f53a,
|
||||||
|
0x510e_527f,
|
||||||
|
0x9b05_688c,
|
||||||
|
0x1f83_d9ab,
|
||||||
|
0x5be0_cd19,
|
||||||
|
];
|
||||||
|
|
||||||
|
let bit_len = (bytes.len() as u64).wrapping_mul(8);
|
||||||
|
let mut chunks = bytes.chunks_exact(64);
|
||||||
|
for chunk in &mut chunks {
|
||||||
|
compress_sha256_chunk(&mut h, chunk, &K);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tail = chunks.remainder();
|
||||||
|
let mut block = [0u8; 128];
|
||||||
|
block[..tail.len()].copy_from_slice(tail);
|
||||||
|
block[tail.len()] = 0x80;
|
||||||
|
let padded_len = if tail.len() < 56 { 64 } else { 128 };
|
||||||
|
block[padded_len - 8..padded_len].copy_from_slice(&bit_len.to_be_bytes());
|
||||||
|
for chunk in block[..padded_len].chunks_exact(64) {
|
||||||
|
compress_sha256_chunk(&mut h, chunk, &K);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = [0u8; 32];
|
||||||
|
for (idx, word) in h.iter().enumerate() {
|
||||||
|
out[idx * 4..idx * 4 + 4].copy_from_slice(&word.to_be_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a SHA-256 digest as lowercase hexadecimal.
|
||||||
|
#[must_use]
|
||||||
|
pub fn sha256_hex(digest: &Sha256Digest) -> String {
|
||||||
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||||
|
let mut out = String::with_capacity(64);
|
||||||
|
for byte in digest {
|
||||||
|
out.push(char::from(HEX[usize::from(byte >> 4)]));
|
||||||
|
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::many_single_char_names)]
|
||||||
|
fn compress_sha256_chunk(h: &mut [u32; 8], chunk: &[u8], k: &[u32; 64]) {
|
||||||
|
let mut w = [0u32; 64];
|
||||||
|
for (idx, word) in w.iter_mut().take(16).enumerate() {
|
||||||
|
let base = idx * 4;
|
||||||
|
*word = u32::from_be_bytes([
|
||||||
|
chunk[base],
|
||||||
|
chunk[base + 1],
|
||||||
|
chunk[base + 2],
|
||||||
|
chunk[base + 3],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
for idx in 16..64 {
|
||||||
|
let s0 = w[idx - 15].rotate_right(7) ^ w[idx - 15].rotate_right(18) ^ (w[idx - 15] >> 3);
|
||||||
|
let s1 = w[idx - 2].rotate_right(17) ^ w[idx - 2].rotate_right(19) ^ (w[idx - 2] >> 10);
|
||||||
|
w[idx] = w[idx - 16]
|
||||||
|
.wrapping_add(s0)
|
||||||
|
.wrapping_add(w[idx - 7])
|
||||||
|
.wrapping_add(s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut a = h[0];
|
||||||
|
let mut b = h[1];
|
||||||
|
let mut c = h[2];
|
||||||
|
let mut d = h[3];
|
||||||
|
let mut e = h[4];
|
||||||
|
let mut f = h[5];
|
||||||
|
let mut g = h[6];
|
||||||
|
let mut hh = h[7];
|
||||||
|
|
||||||
|
for idx in 0..64 {
|
||||||
|
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
|
||||||
|
let ch = (e & f) ^ ((!e) & g);
|
||||||
|
let temp1 = hh
|
||||||
|
.wrapping_add(s1)
|
||||||
|
.wrapping_add(ch)
|
||||||
|
.wrapping_add(k[idx])
|
||||||
|
.wrapping_add(w[idx]);
|
||||||
|
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
|
||||||
|
let maj = (a & b) ^ (a & c) ^ (b & c);
|
||||||
|
let temp2 = s0.wrapping_add(maj);
|
||||||
|
|
||||||
|
hh = g;
|
||||||
|
g = f;
|
||||||
|
f = e;
|
||||||
|
e = d.wrapping_add(temp1);
|
||||||
|
d = c;
|
||||||
|
c = b;
|
||||||
|
b = a;
|
||||||
|
a = temp1.wrapping_add(temp2);
|
||||||
|
}
|
||||||
|
|
||||||
|
h[0] = h[0].wrapping_add(a);
|
||||||
|
h[1] = h[1].wrapping_add(b);
|
||||||
|
h[2] = h[2].wrapping_add(c);
|
||||||
|
h[3] = h[3].wrapping_add(d);
|
||||||
|
h[4] = h[4].wrapping_add(e);
|
||||||
|
h[5] = h[5].wrapping_add(f);
|
||||||
|
h[6] = h[6].wrapping_add(g);
|
||||||
|
h[7] = h[7].wrapping_add(hh);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -305,4 +485,16 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(cursor.offset(), 4);
|
assert_eq!(cursor.offset(), 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sha256_matches_known_vectors() {
|
||||||
|
assert_eq!(
|
||||||
|
sha256_hex(&sha256(b"")),
|
||||||
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sha256_hex(&sha256(b"abc")),
|
||||||
|
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
fparkan-path = { path = "../fparkan-path" }
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
|
|||||||
+361
-132
@@ -1,12 +1,14 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
//! Licensed corpus discovery and aggregate reports.
|
//! Licensed corpus discovery and aggregate reports.
|
||||||
|
|
||||||
|
use fparkan_binary::{sha256, sha256_hex, Sha256Digest};
|
||||||
use fparkan_path::{ascii_lookup_key, normalize_relative, PathPolicy};
|
use fparkan_path::{ascii_lookup_key, normalize_relative, PathPolicy};
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// Corpus kind.
|
/// Corpus kind.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -39,8 +41,8 @@ pub struct ManifestEntry {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
/// File size in bytes.
|
/// File size in bytes.
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
/// Stable content fingerprint.
|
/// SHA-256 content fingerprint.
|
||||||
pub hash: u64,
|
pub hash: Sha256Digest,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Corpus manifest.
|
/// Corpus manifest.
|
||||||
@@ -70,7 +72,35 @@ pub struct CorpusReport {
|
|||||||
/// Casefold collision count.
|
/// Casefold collision count.
|
||||||
pub casefold_collisions: usize,
|
pub casefold_collisions: usize,
|
||||||
/// Manifest fingerprint.
|
/// Manifest fingerprint.
|
||||||
pub fingerprint: u64,
|
pub fingerprint: Sha256Digest,
|
||||||
|
/// Per-file status records.
|
||||||
|
pub records: Vec<CorpusFileRecord>,
|
||||||
|
/// Number of files with report errors.
|
||||||
|
pub failures: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-file report status.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum CorpusFileStatus {
|
||||||
|
/// File was inspected successfully.
|
||||||
|
Ok,
|
||||||
|
/// File was inspected but produced a non-fatal warning.
|
||||||
|
Warning,
|
||||||
|
/// File could not be inspected.
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-file report record.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct CorpusFileRecord {
|
||||||
|
/// Normalized relative path.
|
||||||
|
pub path: String,
|
||||||
|
/// Inspection status.
|
||||||
|
pub status: CorpusFileStatus,
|
||||||
|
/// Detected file variant.
|
||||||
|
pub variant: String,
|
||||||
|
/// Optional status message.
|
||||||
|
pub message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Corpus error.
|
/// Corpus error.
|
||||||
@@ -87,6 +117,13 @@ pub enum CorpusError {
|
|||||||
InvalidRoot(PathBuf),
|
InvalidRoot(PathBuf),
|
||||||
/// Invalid path.
|
/// Invalid path.
|
||||||
InvalidPath(String),
|
InvalidPath(String),
|
||||||
|
/// Aggregate report failure.
|
||||||
|
Report {
|
||||||
|
/// Path where reporting failed.
|
||||||
|
path: String,
|
||||||
|
/// Failure message.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for CorpusError {
|
impl fmt::Display for CorpusError {
|
||||||
@@ -95,6 +132,7 @@ impl fmt::Display for CorpusError {
|
|||||||
Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
|
Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
|
||||||
Self::InvalidRoot(path) => write!(f, "invalid corpus root: {}", path.display()),
|
Self::InvalidRoot(path) => write!(f, "invalid corpus root: {}", path.display()),
|
||||||
Self::InvalidPath(path) => write!(f, "invalid corpus path: {path}"),
|
Self::InvalidPath(path) => write!(f, "invalid corpus path: {path}"),
|
||||||
|
Self::Report { path, message } => write!(f, "{path}: {message}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +141,7 @@ impl std::error::Error for CorpusError {
|
|||||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
match self {
|
match self {
|
||||||
Self::Io { source, .. } => Some(source),
|
Self::Io { source, .. } => Some(source),
|
||||||
Self::InvalidRoot(_) | Self::InvalidPath(_) => None,
|
Self::InvalidRoot(_) | Self::InvalidPath(_) | Self::Report { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +225,7 @@ fn walk(
|
|||||||
out.push(ManifestEntry {
|
out.push(ManifestEntry {
|
||||||
path: normalized.as_str().to_string(),
|
path: normalized.as_str().to_string(),
|
||||||
size: metadata.len(),
|
size: metadata.len(),
|
||||||
hash: stable_hash(&bytes),
|
hash: sha256(&bytes),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -229,8 +267,39 @@ fn detect_casefold_collisions(files: &[ManifestEntry]) -> Vec<Vec<String>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Builds aggregate report.
|
/// Builds aggregate report.
|
||||||
#[must_use]
|
///
|
||||||
pub fn report(root: &Path, manifest: &CorpusManifest) -> CorpusReport {
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`CorpusError`] when the aggregate report cannot be constructed.
|
||||||
|
/// Per-file inspection failures are represented in [`CorpusReport::records`]
|
||||||
|
/// and counted in [`CorpusReport::failures`].
|
||||||
|
pub fn report(root: &Path, manifest: &CorpusManifest) -> Result<CorpusReport, CorpusError> {
|
||||||
|
let mut metrics = empty_report_metrics();
|
||||||
|
let mut records = Vec::with_capacity(manifest.files.len());
|
||||||
|
let mut failures = 0usize;
|
||||||
|
|
||||||
|
for entry in &manifest.files {
|
||||||
|
let record = inspect_report_file(root, entry, &mut metrics);
|
||||||
|
if record.status == CorpusFileStatus::Error {
|
||||||
|
failures = failures.saturating_add(1);
|
||||||
|
}
|
||||||
|
records.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CorpusReport {
|
||||||
|
schema: 1,
|
||||||
|
kind: manifest.kind,
|
||||||
|
files: manifest.files.len(),
|
||||||
|
bytes: manifest.files.iter().map(|f| f.size).sum(),
|
||||||
|
metrics,
|
||||||
|
casefold_collisions: manifest.casefold_collisions.len(),
|
||||||
|
fingerprint: fingerprint(manifest),
|
||||||
|
records,
|
||||||
|
failures,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_report_metrics() -> BTreeMap<String, u64> {
|
||||||
let mut metrics = BTreeMap::new();
|
let mut metrics = BTreeMap::new();
|
||||||
metrics.insert("nres_files".to_string(), 0);
|
metrics.insert("nres_files".to_string(), 0);
|
||||||
metrics.insert("nres_entries".to_string(), 0);
|
metrics.insert("nres_entries".to_string(), 0);
|
||||||
@@ -244,67 +313,97 @@ pub fn report(root: &Path, manifest: &CorpusManifest) -> CorpusReport {
|
|||||||
metrics.insert("texm_entries".to_string(), 0);
|
metrics.insert("texm_entries".to_string(), 0);
|
||||||
metrics.insert("fxid_entries".to_string(), 0);
|
metrics.insert("fxid_entries".to_string(), 0);
|
||||||
metrics.insert("wear_entries".to_string(), 0);
|
metrics.insert("wear_entries".to_string(), 0);
|
||||||
|
metrics
|
||||||
|
}
|
||||||
|
|
||||||
for entry in &manifest.files {
|
fn inspect_report_file(
|
||||||
let lower = entry.path.to_ascii_lowercase();
|
root: &Path,
|
||||||
if lower.ends_with("data.tma") {
|
entry: &ManifestEntry,
|
||||||
bump(&mut metrics, "tma_files", 1);
|
metrics: &mut BTreeMap<String, u64>,
|
||||||
|
) -> CorpusFileRecord {
|
||||||
|
let lower = entry.path.to_ascii_lowercase();
|
||||||
|
let mut variant = inspect_path_metrics(&lower, metrics);
|
||||||
|
let path = root.join(&entry.path);
|
||||||
|
let bytes = match fs::read(&path) {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(source) => {
|
||||||
|
return CorpusFileRecord {
|
||||||
|
path: entry.path.clone(),
|
||||||
|
status: CorpusFileStatus::Error,
|
||||||
|
variant,
|
||||||
|
message: Some(source.to_string()),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if lower.ends_with("land.msh") {
|
};
|
||||||
bump(&mut metrics, "land_msh_files", 1);
|
if bytes.starts_with(b"NRes") {
|
||||||
}
|
variant = "nres".to_string();
|
||||||
if lower.ends_with("land.map") {
|
bump(metrics, "nres_files", 1);
|
||||||
bump(&mut metrics, "land_map_files", 1);
|
if let Err(message) = inspect_nres_metrics(bytes, metrics) {
|
||||||
}
|
return CorpusFileRecord {
|
||||||
if has_extension(&lower, "dat")
|
path: entry.path.clone(),
|
||||||
&& (lower.starts_with("units/") || lower.contains("/units/"))
|
status: CorpusFileStatus::Error,
|
||||||
{
|
variant,
|
||||||
bump(&mut metrics, "unit_dat_files", 1);
|
message: Some(message),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
} else if bytes.starts_with(b"NL") {
|
||||||
|
variant = "rsli".to_string();
|
||||||
|
bump(metrics, "rsli_files", 1);
|
||||||
|
}
|
||||||
|
CorpusFileRecord {
|
||||||
|
path: entry.path.clone(),
|
||||||
|
status: CorpusFileStatus::Ok,
|
||||||
|
variant,
|
||||||
|
message: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let path = root.join(&entry.path);
|
fn inspect_path_metrics(lower: &str, metrics: &mut BTreeMap<String, u64>) -> String {
|
||||||
if let Ok(bytes) = fs::read(path) {
|
let mut variant = "file";
|
||||||
if bytes.starts_with(b"NRes") {
|
if lower.ends_with("data.tma") {
|
||||||
bump(&mut metrics, "nres_files", 1);
|
bump(metrics, "tma_files", 1);
|
||||||
if let Some(entries) = inspect_nres_entries(&bytes) {
|
variant = "tma";
|
||||||
bump(&mut metrics, "nres_entries", entries.len() as u64);
|
}
|
||||||
for entry in entries {
|
if lower.ends_with("land.msh") {
|
||||||
let name = entry.name.to_ascii_lowercase();
|
bump(metrics, "land_msh_files", 1);
|
||||||
if has_extension(&name, "msh") {
|
variant = "land_msh";
|
||||||
bump(&mut metrics, "msh_entries", 1);
|
}
|
||||||
}
|
if lower.ends_with("land.map") {
|
||||||
match entry.kind {
|
bump(metrics, "land_map_files", 1);
|
||||||
0x3054_414D => {
|
variant = "land_map";
|
||||||
bump(&mut metrics, "mat0_entries", 1);
|
}
|
||||||
}
|
if has_extension(lower, "dat") && (lower.starts_with("units/") || lower.contains("/units/")) {
|
||||||
0x6D78_6554 => {
|
bump(metrics, "unit_dat_files", 1);
|
||||||
bump(&mut metrics, "texm_entries", 1);
|
variant = "unit_dat";
|
||||||
}
|
}
|
||||||
0x4449_5846 => {
|
variant.to_string()
|
||||||
bump(&mut metrics, "fxid_entries", 1);
|
}
|
||||||
}
|
|
||||||
0x5241_4557 => {
|
fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) -> Result<(), String> {
|
||||||
bump(&mut metrics, "wear_entries", 1);
|
let entries = inspect_nres_entries(bytes)?;
|
||||||
}
|
bump(metrics, "nres_entries", entries.len() as u64);
|
||||||
_ => {}
|
for entry in entries {
|
||||||
}
|
let name = String::from_utf8_lossy(entry.name_bytes()).to_ascii_lowercase();
|
||||||
}
|
if has_extension(&name, "msh") {
|
||||||
}
|
bump(metrics, "msh_entries", 1);
|
||||||
} else if bytes.starts_with(b"NL") {
|
}
|
||||||
bump(&mut metrics, "rsli_files", 1);
|
match entry.meta().type_id {
|
||||||
|
0x3054_414D => {
|
||||||
|
bump(metrics, "mat0_entries", 1);
|
||||||
}
|
}
|
||||||
|
0x6D78_6554 => {
|
||||||
|
bump(metrics, "texm_entries", 1);
|
||||||
|
}
|
||||||
|
0x4449_5846 => {
|
||||||
|
bump(metrics, "fxid_entries", 1);
|
||||||
|
}
|
||||||
|
0x5241_4557 => {
|
||||||
|
bump(metrics, "wear_entries", 1);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
CorpusReport {
|
|
||||||
schema: 1,
|
|
||||||
kind: manifest.kind,
|
|
||||||
files: manifest.files.len(),
|
|
||||||
bytes: manifest.files.iter().map(|f| f.size).sum(),
|
|
||||||
metrics,
|
|
||||||
casefold_collisions: manifest.casefold_collisions.len(),
|
|
||||||
fingerprint: fingerprint(manifest),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bump(metrics: &mut BTreeMap<String, u64>, key: &str, delta: u64) {
|
fn bump(metrics: &mut BTreeMap<String, u64>, key: &str, delta: u64) {
|
||||||
@@ -319,60 +418,26 @@ fn has_extension(path: &str, expected: &str) -> bool {
|
|||||||
.is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
|
.is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
fn inspect_nres_entries(bytes: Vec<u8>) -> Result<Vec<fparkan_nres::NresEntry>, String> {
|
||||||
struct NresEntryBrief {
|
let document = fparkan_nres::decode(
|
||||||
kind: u32,
|
Arc::from(bytes.into_boxed_slice()),
|
||||||
name: String,
|
fparkan_nres::ReadProfile::Compatible,
|
||||||
}
|
)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
fn inspect_nres_entries(bytes: &[u8]) -> Option<Vec<NresEntryBrief>> {
|
Ok(document.entries().to_vec())
|
||||||
if bytes.len() < 16 || !bytes.starts_with(b"NRes") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let count = i32::from_le_bytes(bytes.get(8..12)?.try_into().ok()?);
|
|
||||||
if count < 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let count = usize::try_from(count).ok()?;
|
|
||||||
let directory_len = count.checked_mul(64)?;
|
|
||||||
let directory_offset = bytes.len().checked_sub(directory_len)?;
|
|
||||||
let mut names = Vec::with_capacity(count);
|
|
||||||
for index in 0..count {
|
|
||||||
let base = directory_offset.checked_add(index.checked_mul(64)?)?;
|
|
||||||
let kind = u32::from_le_bytes(bytes.get(base..base + 4)?.try_into().ok()?);
|
|
||||||
let raw = bytes.get(base + 20..base + 56)?;
|
|
||||||
let len = raw.iter().position(|b| *b == 0).unwrap_or(raw.len());
|
|
||||||
names.push(NresEntryBrief {
|
|
||||||
kind,
|
|
||||||
name: String::from_utf8_lossy(&raw[..len]).to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Some(names)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computes stable manifest fingerprint.
|
/// Computes stable manifest fingerprint.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn fingerprint(manifest: &CorpusManifest) -> u64 {
|
pub fn fingerprint(manifest: &CorpusManifest) -> Sha256Digest {
|
||||||
let mut state = 0xcbf2_9ce4_8422_2325;
|
let mut bytes = Vec::new();
|
||||||
for file in &manifest.files {
|
for file in &manifest.files {
|
||||||
hash_into(&mut state, file.path.as_bytes());
|
bytes.extend_from_slice(file.path.as_bytes());
|
||||||
hash_into(&mut state, &file.size.to_le_bytes());
|
bytes.push(0);
|
||||||
hash_into(&mut state, &file.hash.to_le_bytes());
|
bytes.extend_from_slice(&file.size.to_le_bytes());
|
||||||
}
|
bytes.extend_from_slice(&file.hash);
|
||||||
state
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stable_hash(bytes: &[u8]) -> u64 {
|
|
||||||
let mut state = 0xcbf2_9ce4_8422_2325;
|
|
||||||
hash_into(&mut state, bytes);
|
|
||||||
state
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_into(state: &mut u64, bytes: &[u8]) {
|
|
||||||
for byte in bytes {
|
|
||||||
*state ^= u64::from(*byte);
|
|
||||||
*state = state.wrapping_mul(0x0000_0100_0000_01b3);
|
|
||||||
}
|
}
|
||||||
|
sha256(&bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes report atomically.
|
/// Writes report atomically.
|
||||||
@@ -413,13 +478,15 @@ pub fn write_report_atomic(path: &Path, report: &CorpusReport) -> Result<(), Cor
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn render_report_json(report: &CorpusReport) -> String {
|
pub fn render_report_json(report: &CorpusReport) -> String {
|
||||||
let mut out = format!(
|
let mut out = format!(
|
||||||
"{{\"schema_version\":\"fparkan-corpus-report-v1\",\"schema\":{},\"kind\":\"{:?}\",\"files\":{},\"bytes\":{},\"casefold_collisions\":{},\"fingerprint\":\"{:016x}\",\"metrics\":{{",
|
"{{\"schema_version\":\"fparkan-corpus-report-v1\",\"schema\":{},\"kind\":\"{:?}\",\"files\":{},\"bytes\":{},\"casefold_collisions\":{},\"fingerprint\":\"{}\",\"failures\":{},\"record_count\":{},\"metrics\":{{",
|
||||||
report.schema,
|
report.schema,
|
||||||
report.kind,
|
report.kind,
|
||||||
report.files,
|
report.files,
|
||||||
report.bytes,
|
report.bytes,
|
||||||
report.casefold_collisions,
|
report.casefold_collisions,
|
||||||
report.fingerprint
|
sha256_hex(&report.fingerprint),
|
||||||
|
report.failures,
|
||||||
|
report.records.len()
|
||||||
);
|
);
|
||||||
for (idx, (key, value)) in report.metrics.iter().enumerate() {
|
for (idx, (key, value)) in report.metrics.iter().enumerate() {
|
||||||
if idx > 0 {
|
if idx > 0 {
|
||||||
@@ -442,25 +509,21 @@ mod tests {
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn report_for_testdata_roots() {
|
fn report_for_testdata_roots() {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
let root = licensed_root("IS");
|
||||||
.join("../..")
|
|
||||||
.join("testdata")
|
|
||||||
.join("IS");
|
|
||||||
if !root.is_dir() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let manifest = discover(&root, DiscoverOptions::default()).expect("manifest");
|
let manifest = discover(&root, DiscoverOptions::default()).expect("manifest");
|
||||||
let report = report(&root, &manifest);
|
let report = report(&root, &manifest).expect("report");
|
||||||
assert!(report.files > 0);
|
assert!(report.files > 0);
|
||||||
assert!(report.metrics["nres_files"] > 0);
|
assert!(report.metrics["nres_files"] > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_part1_manifest_profile_and_counts_match_baseline() {
|
fn licensed_part1_manifest_profile_and_counts_match_baseline() {
|
||||||
let root = testdata_root("IS");
|
let root = testdata_root("IS");
|
||||||
let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest");
|
let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest");
|
||||||
let report = report(&root, &manifest);
|
let report = report(&root, &manifest).expect("report");
|
||||||
|
|
||||||
assert_eq!(manifest.kind, CorpusKind::Part1);
|
assert_eq!(manifest.kind, CorpusKind::Part1);
|
||||||
assert_eq!(report.files, 1_017);
|
assert_eq!(report.files, 1_017);
|
||||||
@@ -473,10 +536,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_part2_manifest_profile_and_counts_match_baseline() {
|
fn licensed_part2_manifest_profile_and_counts_match_baseline() {
|
||||||
let root = testdata_root("IS2");
|
let root = testdata_root("IS2");
|
||||||
let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest");
|
let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest");
|
||||||
let report = report(&root, &manifest);
|
let report = report(&root, &manifest).expect("report");
|
||||||
|
|
||||||
assert_eq!(manifest.kind, CorpusKind::Part2);
|
assert_eq!(manifest.kind, CorpusKind::Part2);
|
||||||
assert_eq!(report.files, 1_302);
|
assert_eq!(report.files, 1_302);
|
||||||
@@ -489,6 +553,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_part1_has_no_casefold_relative_path_collisions() {
|
fn licensed_part1_has_no_casefold_relative_path_collisions() {
|
||||||
let root = testdata_root("IS");
|
let root = testdata_root("IS");
|
||||||
let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest");
|
let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest");
|
||||||
@@ -497,6 +562,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_part2_has_no_casefold_relative_path_collisions() {
|
fn licensed_part2_has_no_casefold_relative_path_collisions() {
|
||||||
let root = testdata_root("IS2");
|
let root = testdata_root("IS2");
|
||||||
let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest");
|
let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest");
|
||||||
@@ -505,11 +571,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_part1_paths_stay_under_root() {
|
fn licensed_part1_paths_stay_under_root() {
|
||||||
assert_discovered_paths_stay_under_root("IS");
|
assert_discovered_paths_stay_under_root("IS");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_part2_paths_stay_under_root() {
|
fn licensed_part2_paths_stay_under_root() {
|
||||||
assert_discovered_paths_stay_under_root("IS2");
|
assert_discovered_paths_stay_under_root("IS2");
|
||||||
}
|
}
|
||||||
@@ -521,20 +589,115 @@ mod tests {
|
|||||||
files: vec![ManifestEntry {
|
files: vec![ManifestEntry {
|
||||||
path: "secret/payload.bin".to_string(),
|
path: "secret/payload.bin".to_string(),
|
||||||
size: 4,
|
size: 4,
|
||||||
hash: stable_hash(b"DATA"),
|
hash: sha256(b"DATA"),
|
||||||
}],
|
}],
|
||||||
casefold_collisions: Vec::new(),
|
casefold_collisions: Vec::new(),
|
||||||
};
|
};
|
||||||
let report = report(Path::new("."), &manifest);
|
let report = report(Path::new("."), &manifest).expect("report");
|
||||||
let json = render_report_json(&report);
|
let json = render_report_json(&report);
|
||||||
|
|
||||||
assert!(json.contains("\"schema_version\":\"fparkan-corpus-report-v1\""));
|
assert!(json.contains("\"schema_version\":\"fparkan-corpus-report-v1\""));
|
||||||
assert!(json.contains("\"fingerprint\":"));
|
assert!(json.contains("\"fingerprint\":"));
|
||||||
|
assert!(json.contains("\"failures\":1"));
|
||||||
|
assert!(json.contains("\"record_count\":1"));
|
||||||
assert!(json.contains("\"metrics\":"));
|
assert!(json.contains("\"metrics\":"));
|
||||||
assert!(!json.contains("secret/payload.bin"));
|
assert!(!json.contains("secret/payload.bin"));
|
||||||
assert!(!json.contains("DATA"));
|
assert!(!json.contains("DATA"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_records_missing_manifest_files_as_failures() {
|
||||||
|
let root = temp_dir("report-missing");
|
||||||
|
let manifest = CorpusManifest {
|
||||||
|
kind: CorpusKind::Unknown,
|
||||||
|
files: vec![ManifestEntry {
|
||||||
|
path: "missing.lib".to_string(),
|
||||||
|
size: 1,
|
||||||
|
hash: sha256(b"missing"),
|
||||||
|
}],
|
||||||
|
casefold_collisions: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let report = report(&root, &manifest).expect("report");
|
||||||
|
|
||||||
|
assert_eq!(report.failures, 1);
|
||||||
|
assert_eq!(report.records.len(), 1);
|
||||||
|
assert_eq!(report.records[0].path, "missing.lib");
|
||||||
|
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_records_malformed_nres_as_failure() {
|
||||||
|
let root = temp_dir("report-bad-nres");
|
||||||
|
fs::write(root.join("bad.lib"), b"NRes").expect("bad nres");
|
||||||
|
let manifest = CorpusManifest {
|
||||||
|
kind: CorpusKind::Unknown,
|
||||||
|
files: vec![ManifestEntry {
|
||||||
|
path: "bad.lib".to_string(),
|
||||||
|
size: 4,
|
||||||
|
hash: sha256(b"NRes"),
|
||||||
|
}],
|
||||||
|
casefold_collisions: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let report = report(&root, &manifest).expect("report");
|
||||||
|
|
||||||
|
assert_eq!(report.failures, 1);
|
||||||
|
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
|
||||||
|
assert_eq!(report.records[0].variant, "nres");
|
||||||
|
assert!(report.records[0]
|
||||||
|
.message
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|message| message.contains("NRes")));
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_uses_production_nres_parser_for_entry_metrics() {
|
||||||
|
let root = temp_dir("report-nres");
|
||||||
|
let archive = build_nres(&[
|
||||||
|
TestNresEntry {
|
||||||
|
name: "mesh.msh",
|
||||||
|
type_id: 0,
|
||||||
|
payload: b"mesh",
|
||||||
|
},
|
||||||
|
TestNresEntry {
|
||||||
|
name: "mat.bin",
|
||||||
|
type_id: 0x3054_414D,
|
||||||
|
payload: b"mat0",
|
||||||
|
},
|
||||||
|
TestNresEntry {
|
||||||
|
name: "texture.bin",
|
||||||
|
type_id: 0x6D78_6554,
|
||||||
|
payload: b"texm",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
fs::write(root.join("archive.lib"), &archive).expect("archive");
|
||||||
|
let manifest = CorpusManifest {
|
||||||
|
kind: CorpusKind::Unknown,
|
||||||
|
files: vec![ManifestEntry {
|
||||||
|
path: "archive.lib".to_string(),
|
||||||
|
size: u64::try_from(archive.len()).expect("archive size"),
|
||||||
|
hash: sha256(&archive),
|
||||||
|
}],
|
||||||
|
casefold_collisions: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let report = report(&root, &manifest).expect("report");
|
||||||
|
|
||||||
|
assert_eq!(report.failures, 0);
|
||||||
|
assert_eq!(report.records.len(), 1);
|
||||||
|
assert_eq!(report.records[0].status, CorpusFileStatus::Ok);
|
||||||
|
assert_eq!(report.records[0].variant, "nres");
|
||||||
|
assert_eq!(report.metrics["nres_files"], 1);
|
||||||
|
assert_eq!(report.metrics["nres_entries"], 3);
|
||||||
|
assert_eq!(report.metrics["msh_entries"], 1);
|
||||||
|
assert_eq!(report.metrics["mat0_entries"], 1);
|
||||||
|
assert_eq!(report.metrics["texm_entries"], 1);
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deterministic_traversal_is_creation_order_independent() {
|
fn deterministic_traversal_is_creation_order_independent() {
|
||||||
let first = temp_dir("order-first");
|
let first = temp_dir("order-first");
|
||||||
@@ -597,12 +760,12 @@ mod tests {
|
|||||||
ManifestEntry {
|
ManifestEntry {
|
||||||
path: "Textures/Foo.TEX".to_string(),
|
path: "Textures/Foo.TEX".to_string(),
|
||||||
size: 1,
|
size: 1,
|
||||||
hash: 1,
|
hash: sha256(b"first"),
|
||||||
},
|
},
|
||||||
ManifestEntry {
|
ManifestEntry {
|
||||||
path: "textures/foo.tex".to_string(),
|
path: "textures/foo.tex".to_string(),
|
||||||
size: 1,
|
size: 1,
|
||||||
hash: 2,
|
hash: sha256(b"second"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
casefold_collisions: Vec::new(),
|
casefold_collisions: Vec::new(),
|
||||||
@@ -626,12 +789,12 @@ mod tests {
|
|||||||
files: vec![ManifestEntry {
|
files: vec![ManifestEntry {
|
||||||
path: "a".to_string(),
|
path: "a".to_string(),
|
||||||
size: 1,
|
size: 1,
|
||||||
hash: 1,
|
hash: sha256(b"before"),
|
||||||
}],
|
}],
|
||||||
casefold_collisions: Vec::new(),
|
casefold_collisions: Vec::new(),
|
||||||
};
|
};
|
||||||
let a = fingerprint(&manifest);
|
let a = fingerprint(&manifest);
|
||||||
manifest.files[0].hash = 2;
|
manifest.files[0].hash = sha256(b"after");
|
||||||
assert_ne!(a, fingerprint(&manifest));
|
assert_ne!(a, fingerprint(&manifest));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,13 +814,65 @@ mod tests {
|
|||||||
bytes: 0,
|
bytes: 0,
|
||||||
metrics: BTreeMap::new(),
|
metrics: BTreeMap::new(),
|
||||||
casefold_collisions: 0,
|
casefold_collisions: 0,
|
||||||
fingerprint: 0,
|
fingerprint: sha256(b"empty-report"),
|
||||||
|
records: Vec::new(),
|
||||||
|
failures: 0,
|
||||||
};
|
};
|
||||||
write_report_atomic(&tmp, &report).expect("write");
|
write_report_atomic(&tmp, &report).expect("write");
|
||||||
assert!(tmp.is_file());
|
assert!(tmp.is_file());
|
||||||
let _ = fs::remove_file(tmp);
|
let _ = fs::remove_file(tmp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct TestNresEntry<'a> {
|
||||||
|
name: &'a str,
|
||||||
|
type_id: u32,
|
||||||
|
payload: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_nres(entries: &[TestNresEntry<'_>]) -> Vec<u8> {
|
||||||
|
let mut out = vec![0; 16];
|
||||||
|
let mut offsets = Vec::with_capacity(entries.len());
|
||||||
|
for entry in entries {
|
||||||
|
offsets.push(u32::try_from(out.len()).expect("offset"));
|
||||||
|
out.extend_from_slice(entry.payload);
|
||||||
|
let padding = (8 - (out.len() % 8)) % 8;
|
||||||
|
out.resize(out.len() + padding, 0);
|
||||||
|
}
|
||||||
|
let mut order: Vec<usize> = (0..entries.len()).collect();
|
||||||
|
order.sort_by(|left, right| {
|
||||||
|
entries[*left]
|
||||||
|
.name
|
||||||
|
.as_bytes()
|
||||||
|
.cmp(entries[*right].name.as_bytes())
|
||||||
|
});
|
||||||
|
for (index, entry) in entries.iter().enumerate() {
|
||||||
|
push_u32(&mut out, entry.type_id);
|
||||||
|
push_u32(&mut out, 0);
|
||||||
|
push_u32(&mut out, 0);
|
||||||
|
push_u32(
|
||||||
|
&mut out,
|
||||||
|
u32::try_from(entry.payload.len()).expect("payload size"),
|
||||||
|
);
|
||||||
|
push_u32(&mut out, 0);
|
||||||
|
let mut name = [0; 36];
|
||||||
|
let name_bytes = entry.name.as_bytes();
|
||||||
|
name[..name_bytes.len()].copy_from_slice(name_bytes);
|
||||||
|
out.extend_from_slice(&name);
|
||||||
|
push_u32(&mut out, offsets[index]);
|
||||||
|
push_u32(&mut out, u32::try_from(order[index]).expect("sort index"));
|
||||||
|
}
|
||||||
|
out[0..4].copy_from_slice(b"NRes");
|
||||||
|
out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
|
||||||
|
out[8..12].copy_from_slice(&u32::try_from(entries.len()).expect("count").to_le_bytes());
|
||||||
|
let total_size = u32::try_from(out.len()).expect("total size");
|
||||||
|
out[12..16].copy_from_slice(&total_size.to_le_bytes());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_u32(out: &mut Vec<u8>, value: u32) {
|
||||||
|
out.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
fn temp_dir(name: &str) -> PathBuf {
|
fn temp_dir(name: &str) -> PathBuf {
|
||||||
let path = std::env::temp_dir().join(format!(
|
let path = std::env::temp_dir().join(format!(
|
||||||
"fparkan-corpus-{name}-{}",
|
"fparkan-corpus-{name}-{}",
|
||||||
@@ -671,10 +886,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn testdata_root(part: &str) -> PathBuf {
|
fn testdata_root(part: &str) -> PathBuf {
|
||||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
licensed_root(part)
|
||||||
.join("../..")
|
}
|
||||||
.join("testdata")
|
|
||||||
.join(part)
|
fn licensed_root(part: &str) -> PathBuf {
|
||||||
|
let variable = match part {
|
||||||
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
||||||
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
||||||
|
_ => panic!("unknown licensed corpus part: {part}"),
|
||||||
|
};
|
||||||
|
let root = std::env::var_os(variable)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
|
||||||
|
assert!(
|
||||||
|
root.is_dir(),
|
||||||
|
"licensed corpus root is missing: {}",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
root
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_discovered_paths_stay_under_root(part: &str) {
|
fn assert_discovered_paths_stay_under_root(part: &str) {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
//! FXID effect contracts.
|
//! FXID effect contracts.
|
||||||
|
//!
|
||||||
|
//! FXID decoding and command framing are implemented as compatibility
|
||||||
|
//! contracts. The create/update/emit lifecycle below is a deterministic
|
||||||
|
//! reference stub until opcode timing, gates, RNG, and command-body semantics
|
||||||
|
//! are backed by runtime-captured evidence.
|
||||||
|
|
||||||
use fparkan_binary::{Cursor, DecodeError};
|
use fparkan_binary::{Cursor, DecodeError};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -121,7 +126,7 @@ impl Default for Transform {
|
|||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
pub struct GameTime(pub u64);
|
pub struct GameTime(pub u64);
|
||||||
|
|
||||||
/// FX runtime state.
|
/// FX reference-stub runtime state.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct FxState {
|
pub struct FxState {
|
||||||
/// Instance id.
|
/// Instance id.
|
||||||
@@ -322,12 +327,12 @@ pub fn decode_fxid(bytes: Arc<[u8]>) -> Result<FxDocument, FxError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates an FX instance.
|
/// Creates a deterministic FX reference-stub instance.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Currently returns [`FxError`] only for future resource/lifecycle validation
|
/// Currently returns [`FxError`] only for future resource/lifecycle validation
|
||||||
/// hooks; creation is deterministic for a decoded document.
|
/// hooks. This function does not claim original runtime parity.
|
||||||
pub fn create_instance(
|
pub fn create_instance(
|
||||||
document: Arc<FxDocument>,
|
document: Arc<FxDocument>,
|
||||||
seed: FxSeed,
|
seed: FxSeed,
|
||||||
@@ -344,21 +349,21 @@ pub fn create_instance(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates FX simulation time without emitting side effects.
|
/// Updates reference-stub FX simulation time without emitting side effects.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Reserved for future runtime validation.
|
/// Reserved for future runtime-captured compatibility validation.
|
||||||
pub fn update(state: &mut FxState, time: GameTime) -> Result<(), FxError> {
|
pub fn update(state: &mut FxState, time: GameTime) -> Result<(), FxError> {
|
||||||
state.time = time;
|
state.time = time;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emits active commands without advancing state.
|
/// Emits reference-stub active commands without advancing state.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Reserved for future resource/runtime validation.
|
/// Reserved for future resource/runtime-captured compatibility validation.
|
||||||
pub fn emit(state: &FxState, out: &mut Vec<FxEmission>) -> Result<(), FxError> {
|
pub fn emit(state: &FxState, out: &mut Vec<FxEmission>) -> Result<(), FxError> {
|
||||||
if state.lifecycle != FxLifecycle::Running {
|
if state.lifecycle != FxLifecycle::Running {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -838,11 +843,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpus_fxid_exact_eof_and_distribution() {
|
fn licensed_corpus_fxid_exact_eof_and_distribution() {
|
||||||
for (corpus, expected_count) in [("IS", 923_usize), ("IS2", 1065_usize)] {
|
for (corpus, expected_count) in [("IS", 923_usize), ("IS2", 1065_usize)] {
|
||||||
let Some(root) = corpus_root(corpus) else {
|
let root = corpus_root(corpus);
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut count = 0usize;
|
let mut count = 0usize;
|
||||||
let mut opcodes = BTreeMap::<FxOpcode, usize>::new();
|
let mut opcodes = BTreeMap::<FxOpcode, usize>::new();
|
||||||
let mut time_modes = BTreeMap::<u32, usize>::new();
|
let mut time_modes = BTreeMap::<u32, usize>::new();
|
||||||
@@ -886,14 +890,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpus_fxid_emission_captures_are_approved() {
|
fn licensed_corpus_fxid_emission_captures_are_approved() {
|
||||||
for (corpus, expected_count, expected_emitting, expected_hash) in [
|
for (corpus, expected_count, expected_emitting, expected_hash) in [
|
||||||
("IS", 923_usize, 467_usize, 10_553_431_922_547_057_702_u64),
|
("IS", 923_usize, 467_usize, 10_553_431_922_547_057_702_u64),
|
||||||
("IS2", 1065_usize, 532_usize, 9_217_284_592_334_143_531_u64),
|
("IS2", 1065_usize, 532_usize, 9_217_284_592_334_143_531_u64),
|
||||||
] {
|
] {
|
||||||
let Some(root) = corpus_root(corpus) else {
|
let root = corpus_root(corpus);
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut count = 0usize;
|
let mut count = 0usize;
|
||||||
let mut emitting = 0usize;
|
let mut emitting = 0usize;
|
||||||
let mut hash = FNV_OFFSET;
|
let mut hash = FNV_OFFSET;
|
||||||
@@ -985,12 +988,21 @@ mod tests {
|
|||||||
dst[..len].copy_from_slice(&src[..len]);
|
dst[..len].copy_from_slice(&src[..len]);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn corpus_root(name: &str) -> Option<PathBuf> {
|
fn corpus_root(name: &str) -> PathBuf {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
let variable = match name {
|
||||||
.join("../..")
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
||||||
.join("testdata")
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
||||||
.join(name);
|
_ => panic!("unknown licensed corpus part: {name}"),
|
||||||
root.is_dir().then_some(root)
|
};
|
||||||
|
let root = std::env::var_os(variable)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
|
||||||
|
assert!(
|
||||||
|
root.is_dir(),
|
||||||
|
"licensed corpus root is missing: {}",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
root
|
||||||
}
|
}
|
||||||
|
|
||||||
fn files_under(root: &Path) -> Vec<PathBuf> {
|
fn files_under(root: &Path) -> Vec<PathBuf> {
|
||||||
|
|||||||
@@ -417,15 +417,8 @@ pub fn resolve_material(
|
|||||||
{
|
{
|
||||||
return Ok(resolved);
|
return Ok(resolved);
|
||||||
}
|
}
|
||||||
if let Some(first) = table.entries.first() {
|
if let Some(resolved) = load_first_material_entry(repository, archive)? {
|
||||||
if let Some(resolved) = load_material_entry(
|
return Ok(resolved);
|
||||||
repository,
|
|
||||||
archive,
|
|
||||||
&first.material,
|
|
||||||
MaterialFallback::FirstEntry,
|
|
||||||
)? {
|
|
||||||
return Ok(resolved);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(MaterialError::MissingMaterial(
|
Err(MaterialError::MissingMaterial(
|
||||||
String::from_utf8_lossy(&entry.material.0).into_owned(),
|
String::from_utf8_lossy(&entry.material.0).into_owned(),
|
||||||
@@ -610,6 +603,26 @@ fn load_material_entry(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn load_first_material_entry(
|
||||||
|
repository: &dyn ResourceRepository,
|
||||||
|
archive: fparkan_resource::ArchiveId,
|
||||||
|
) -> Result<Option<ResolvedMaterial>, MaterialError> {
|
||||||
|
let Some(handle) = repository.first_entry(archive)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let info = repository.entry_info(handle)?;
|
||||||
|
if info.key.type_id != Some(MAT0_KIND) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let bytes = repository.read(handle)?.into_owned();
|
||||||
|
let document = decode_mat0(&bytes, info.attr2)?;
|
||||||
|
Ok(Some(ResolvedMaterial {
|
||||||
|
name: info.key.name,
|
||||||
|
fallback: MaterialFallback::FirstEntry,
|
||||||
|
document,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_lightmaps(lines: &[&str]) -> Result<Vec<LightmapEntry>, MaterialError> {
|
fn parse_lightmaps(lines: &[&str]) -> Result<Vec<LightmapEntry>, MaterialError> {
|
||||||
if lines.is_empty() || lines.iter().all(|line| line.trim().is_empty()) {
|
if lines.is_empty() || lines.iter().all(|line| line.trim().is_empty()) {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -926,6 +939,24 @@ mod tests {
|
|||||||
assert_eq!(resolved.fallback, MaterialFallback::FirstEntry);
|
assert_eq!(resolved.fallback, MaterialFallback::FirstEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_material_first_entry_uses_material_archive_not_wear_row_zero() {
|
||||||
|
let repo = material_repo(&[
|
||||||
|
material_entry(b"MAT_ARCHIVE_FIRST", &mat0_with_texture(b"TEX_ARCHIVE")),
|
||||||
|
material_entry(b"MAT_WEAR_FIRST", &mat0_with_texture(b"TEX_WEAR")),
|
||||||
|
]);
|
||||||
|
let table = decode_wear(b"2\n0 MAT_WEAR_FIRST\n1 MISSING\n").expect("wear");
|
||||||
|
|
||||||
|
let resolved = resolve_material(&repo, &table, 1).expect("resolved");
|
||||||
|
|
||||||
|
assert_eq!(resolved.name.0, b"MAT_ARCHIVE_FIRST");
|
||||||
|
assert_eq!(resolved.fallback, MaterialFallback::FirstEntry);
|
||||||
|
assert_eq!(
|
||||||
|
resolved.document.primary_texture().expect("texture").0,
|
||||||
|
b"TEX_ARCHIVE"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_material_empty_texture_means_untextured() {
|
fn resolve_material_empty_texture_means_untextured() {
|
||||||
let repo = material_repo(&[material_entry(b"MAT_EMPTY", &mat0_with_texture(b""))]);
|
let repo = material_repo(&[material_entry(b"MAT_EMPTY", &mat0_with_texture(b""))]);
|
||||||
@@ -1092,14 +1123,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpus_mat0_and_wear_parse() {
|
fn licensed_corpus_mat0_and_wear_parse() {
|
||||||
for (corpus, expected_mat0, expected_archive_wear, expected_standalone_wear) in [
|
for (corpus, expected_mat0, expected_archive_wear, expected_standalone_wear) in [
|
||||||
("IS", 905_usize, 439_usize, 95_usize),
|
("IS", 905_usize, 439_usize, 95_usize),
|
||||||
("IS2", 1127_usize, 515_usize, 95_usize),
|
("IS2", 1127_usize, 515_usize, 95_usize),
|
||||||
] {
|
] {
|
||||||
let Some(root) = corpus_root(corpus) else {
|
let root = corpus_root(corpus);
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut mat0_count = 0usize;
|
let mut mat0_count = 0usize;
|
||||||
let mut archive_wear_count = 0usize;
|
let mut archive_wear_count = 0usize;
|
||||||
let mut standalone_wear_count = 0usize;
|
let mut standalone_wear_count = 0usize;
|
||||||
@@ -1153,12 +1183,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn corpus_root(name: &str) -> Option<PathBuf> {
|
fn corpus_root(name: &str) -> PathBuf {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
let variable = match name {
|
||||||
.join("../..")
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
||||||
.join("testdata")
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
||||||
.join(name);
|
_ => panic!("unknown licensed corpus part: {name}"),
|
||||||
root.is_dir().then_some(root)
|
};
|
||||||
|
let root = std::env::var_os(variable)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
|
||||||
|
assert!(
|
||||||
|
root.is_dir(),
|
||||||
|
"licensed corpus root is missing: {}",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
root
|
||||||
}
|
}
|
||||||
|
|
||||||
fn files_under(root: &Path) -> Vec<PathBuf> {
|
fn files_under(root: &Path) -> Vec<PathBuf> {
|
||||||
|
|||||||
@@ -979,6 +979,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpus_tma_validate() {
|
fn licensed_corpus_tma_validate() {
|
||||||
for (
|
for (
|
||||||
corpus,
|
corpus,
|
||||||
@@ -991,9 +992,7 @@ mod tests {
|
|||||||
("IS", 29_usize, 34_usize, 101_usize, 864_usize, 28_usize),
|
("IS", 29_usize, 34_usize, 101_usize, 864_usize, 28_usize),
|
||||||
("IS2", 31_usize, 61_usize, 91_usize, 885_usize, 41_usize),
|
("IS2", 31_usize, 61_usize, 91_usize, 885_usize, 41_usize),
|
||||||
] {
|
] {
|
||||||
let Some(root) = corpus_root(corpus) else {
|
let root = corpus_root(corpus);
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut files = 0usize;
|
let mut files = 0usize;
|
||||||
let mut paths = 0usize;
|
let mut paths = 0usize;
|
||||||
let mut clans = 0usize;
|
let mut clans = 0usize;
|
||||||
@@ -1142,12 +1141,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn corpus_root(name: &str) -> Option<PathBuf> {
|
fn corpus_root(name: &str) -> PathBuf {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
let variable = match name {
|
||||||
.join("../..")
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
||||||
.join("testdata")
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
||||||
.join(name);
|
_ => panic!("unknown licensed corpus part: {name}"),
|
||||||
root.is_dir().then_some(root)
|
};
|
||||||
|
let root = std::env::var_os(variable)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
|
||||||
|
assert!(
|
||||||
|
root.is_dir(),
|
||||||
|
"licensed corpus root is missing: {}",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
root
|
||||||
}
|
}
|
||||||
|
|
||||||
fn files_under(root: &Path) -> Vec<PathBuf> {
|
fn files_under(root: &Path) -> Vec<PathBuf> {
|
||||||
|
|||||||
@@ -1236,11 +1236,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpus_msh_assets_validate() {
|
fn licensed_corpus_msh_assets_validate() {
|
||||||
for (corpus, expected) in [("IS", 435_usize), ("IS2", 511_usize)] {
|
for (corpus, expected) in [("IS", 435_usize), ("IS2", 511_usize)] {
|
||||||
let Some(root) = corpus_root(corpus) else {
|
let root = corpus_root(corpus);
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut count = 0usize;
|
let mut count = 0usize;
|
||||||
for path in files_under(&root) {
|
for path in files_under(&root) {
|
||||||
let Ok(bytes) = std::fs::read(&path) else {
|
let Ok(bytes) = std::fs::read(&path) else {
|
||||||
@@ -1279,6 +1278,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpus_animation_streams_sample_approved_pose_captures() {
|
fn licensed_corpus_animation_streams_sample_approved_pose_captures() {
|
||||||
for (
|
for (
|
||||||
corpus,
|
corpus,
|
||||||
@@ -1302,9 +1302,7 @@ mod tests {
|
|||||||
13_040_438_305_408_523_893_u64,
|
13_040_438_305_408_523_893_u64,
|
||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
let Some(root) = corpus_root(corpus) else {
|
let root = corpus_root(corpus);
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut models = 0usize;
|
let mut models = 0usize;
|
||||||
let mut animated_models = 0usize;
|
let mut animated_models = 0usize;
|
||||||
let mut node_samples = 0usize;
|
let mut node_samples = 0usize;
|
||||||
@@ -1723,12 +1721,21 @@ mod tests {
|
|||||||
name.len() >= 4 && name[name.len() - 4..].eq_ignore_ascii_case(b".msh")
|
name.len() >= 4 && name[name.len() - 4..].eq_ignore_ascii_case(b".msh")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn corpus_root(name: &str) -> Option<PathBuf> {
|
fn corpus_root(name: &str) -> PathBuf {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
let variable = match name {
|
||||||
.join("../..")
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
||||||
.join("testdata")
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
||||||
.join(name);
|
_ => panic!("unknown licensed corpus part: {name}"),
|
||||||
root.is_dir().then_some(root)
|
};
|
||||||
|
let root = std::env::var_os(variable)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
|
||||||
|
assert!(
|
||||||
|
root.is_dir(),
|
||||||
|
"licensed corpus root is missing: {}",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
root
|
||||||
}
|
}
|
||||||
|
|
||||||
fn files_under(root: &Path) -> Vec<PathBuf> {
|
fn files_under(root: &Path) -> Vec<PathBuf> {
|
||||||
|
|||||||
+217
-50
@@ -26,7 +26,7 @@ pub enum ReadProfile {
|
|||||||
/// Write profile.
|
/// Write profile.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum WriteProfile {
|
pub enum WriteProfile {
|
||||||
/// Return the original byte image when no edit model is active.
|
/// Preserve the original byte image or unindexed data-region bytes.
|
||||||
Lossless,
|
Lossless,
|
||||||
/// Repack active payloads and rebuild the lookup table.
|
/// Repack active payloads and rebuild the lookup table.
|
||||||
CanonicalCompact,
|
CanonicalCompact,
|
||||||
@@ -102,6 +102,7 @@ pub struct NresDocument {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct NresEditor {
|
pub struct NresEditor {
|
||||||
entries: Vec<EditableEntry>,
|
entries: Vec<EditableEntry>,
|
||||||
|
layout: Vec<EditableSegment>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -114,6 +115,12 @@ struct EditableEntry {
|
|||||||
payload: Vec<u8>,
|
payload: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
enum EditableSegment {
|
||||||
|
Entry(usize),
|
||||||
|
Preserved(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
/// `NRes` parse or write error.
|
/// `NRes` parse or write error.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub enum NresError {
|
pub enum NresError {
|
||||||
@@ -506,7 +513,8 @@ impl NresEditor {
|
|||||||
payload: document.payload(entry.id())?.to_vec(),
|
payload: document.payload(entry.id())?.to_vec(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(Self { entries })
|
let layout = build_edit_layout(document)?;
|
||||||
|
Ok(Self { entries, layout })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces an entry payload.
|
/// Replaces an entry payload.
|
||||||
@@ -537,13 +545,57 @@ impl NresEditor {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encodes the edited document in canonical compact form.
|
/// Encodes the edited document while preserving unindexed bytes.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns [`NresError`] when offsets or sizes exceed the on-disk `u32`
|
/// Returns [`NresError`] when offsets or sizes exceed the on-disk `u32`
|
||||||
/// representation.
|
/// representation.
|
||||||
pub fn encode(&self) -> Result<Vec<u8>, NresError> {
|
pub fn encode(&self) -> Result<Vec<u8>, NresError> {
|
||||||
|
self.encode_with_profile(WriteProfile::Lossless)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes the edited document with an explicit write profile.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`NresError`] when offsets or sizes exceed the on-disk `u32`
|
||||||
|
/// representation.
|
||||||
|
pub fn encode_with_profile(&self, profile: WriteProfile) -> Result<Vec<u8>, NresError> {
|
||||||
|
match profile {
|
||||||
|
WriteProfile::Lossless => self.encode_preserving_layout(),
|
||||||
|
WriteProfile::CanonicalCompact => self.encode_canonical_compact(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_preserving_layout(&self) -> Result<Vec<u8>, NresError> {
|
||||||
|
let mut out = vec![0; HEADER_LEN];
|
||||||
|
let mut offsets = vec![0; self.entries.len()];
|
||||||
|
let mut sizes = vec![0; self.entries.len()];
|
||||||
|
for segment in &self.layout {
|
||||||
|
match segment {
|
||||||
|
EditableSegment::Entry(index) => {
|
||||||
|
let entry = self
|
||||||
|
.entries
|
||||||
|
.get(*index)
|
||||||
|
.ok_or(DecodeError::IntegerOverflow)?;
|
||||||
|
offsets[*index] = checked_u32_len(out.len())?;
|
||||||
|
sizes[*index] = checked_u32_len(entry.payload.len())?;
|
||||||
|
out.extend_from_slice(&entry.payload);
|
||||||
|
}
|
||||||
|
EditableSegment::Preserved(bytes) => {
|
||||||
|
out.len()
|
||||||
|
.checked_add(bytes.len())
|
||||||
|
.ok_or(DecodeError::IntegerOverflow)?;
|
||||||
|
out.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write_edit_archive_header_and_directory(&mut out, &self.entries, &offsets, &sizes)?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_canonical_compact(&self) -> Result<Vec<u8>, NresError> {
|
||||||
let mut out = vec![0; HEADER_LEN];
|
let mut out = vec![0; HEADER_LEN];
|
||||||
let mut offsets = Vec::with_capacity(self.entries.len());
|
let mut offsets = Vec::with_capacity(self.entries.len());
|
||||||
let mut sizes = Vec::with_capacity(self.entries.len());
|
let mut sizes = Vec::with_capacity(self.entries.len());
|
||||||
@@ -560,23 +612,7 @@ impl NresEditor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let sort_order = build_edit_sort_order(&self.entries);
|
write_edit_archive_header_and_directory(&mut out, &self.entries, &offsets, &sizes)?;
|
||||||
for (index, entry) in self.entries.iter().enumerate() {
|
|
||||||
push_u32(&mut out, entry.type_id);
|
|
||||||
push_u32(&mut out, entry.attr1);
|
|
||||||
push_u32(&mut out, entry.attr2);
|
|
||||||
push_u32(&mut out, sizes[index]);
|
|
||||||
push_u32(&mut out, entry.attr3);
|
|
||||||
out.extend_from_slice(&entry.name_raw);
|
|
||||||
push_u32(&mut out, offsets[index]);
|
|
||||||
push_u32(&mut out, checked_u32_len(sort_order[index])?);
|
|
||||||
}
|
|
||||||
|
|
||||||
let total_size = checked_u32_len(out.len())?;
|
|
||||||
out[0..4].copy_from_slice(b"NRes");
|
|
||||||
out[4..8].copy_from_slice(&VERSION_0100.to_le_bytes());
|
|
||||||
out[8..12].copy_from_slice(&checked_u32_len(self.entries.len())?.to_le_bytes());
|
|
||||||
out[12..16].copy_from_slice(&total_size.to_le_bytes());
|
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,6 +935,76 @@ fn build_edit_sort_order(entries: &[EditableEntry]) -> Vec<usize> {
|
|||||||
order
|
order
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_edit_layout(document: &NresDocument) -> Result<Vec<EditableSegment>, NresError> {
|
||||||
|
let mut ranges: Vec<(Range<usize>, usize)> = document
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, entry)| (entry.data_range.clone(), index))
|
||||||
|
.collect();
|
||||||
|
ranges.sort_by(|(left, _), (right, _)| {
|
||||||
|
left.start
|
||||||
|
.cmp(&right.start)
|
||||||
|
.then_with(|| left.end.cmp(&right.end))
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut cursor = HEADER_LEN;
|
||||||
|
let directory_offset = usize::try_from(document.header.directory_offset)
|
||||||
|
.map_err(|_| DecodeError::IntegerOverflow)?;
|
||||||
|
let mut layout = Vec::new();
|
||||||
|
for (range, index) in ranges {
|
||||||
|
if cursor < range.start {
|
||||||
|
layout.push(EditableSegment::Preserved(
|
||||||
|
document.bytes[cursor..range.start].to_vec(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
layout.push(EditableSegment::Entry(index));
|
||||||
|
cursor = cursor.max(range.end);
|
||||||
|
}
|
||||||
|
if cursor < directory_offset {
|
||||||
|
layout.push(EditableSegment::Preserved(
|
||||||
|
document.bytes[cursor..directory_offset].to_vec(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_edit_archive_header_and_directory(
|
||||||
|
out: &mut Vec<u8>,
|
||||||
|
entries: &[EditableEntry],
|
||||||
|
offsets: &[u32],
|
||||||
|
sizes: &[u32],
|
||||||
|
) -> Result<(), NresError> {
|
||||||
|
if offsets.len() != entries.len() || sizes.len() != entries.len() {
|
||||||
|
return Err(DecodeError::IntegerOverflow.into());
|
||||||
|
}
|
||||||
|
let directory_len = ENTRY_LEN
|
||||||
|
.checked_mul(entries.len())
|
||||||
|
.ok_or(DecodeError::IntegerOverflow)?;
|
||||||
|
out.len()
|
||||||
|
.checked_add(directory_len)
|
||||||
|
.ok_or(DecodeError::IntegerOverflow)?;
|
||||||
|
|
||||||
|
let sort_order = build_edit_sort_order(entries);
|
||||||
|
for (index, entry) in entries.iter().enumerate() {
|
||||||
|
push_u32(out, entry.type_id);
|
||||||
|
push_u32(out, entry.attr1);
|
||||||
|
push_u32(out, entry.attr2);
|
||||||
|
push_u32(out, sizes[index]);
|
||||||
|
push_u32(out, entry.attr3);
|
||||||
|
out.extend_from_slice(&entry.name_raw);
|
||||||
|
push_u32(out, offsets[index]);
|
||||||
|
push_u32(out, checked_u32_len(sort_order[index])?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_size = checked_u32_len(out.len())?;
|
||||||
|
out[0..4].copy_from_slice(b"NRes");
|
||||||
|
out[4..8].copy_from_slice(&VERSION_0100.to_le_bytes());
|
||||||
|
out[8..12].copy_from_slice(&checked_u32_len(entries.len())?.to_le_bytes());
|
||||||
|
out[12..16].copy_from_slice(&total_size.to_le_bytes());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn editable_name_bytes(raw: &[u8; NAME_LEN]) -> &[u8] {
|
fn editable_name_bytes(raw: &[u8; NAME_LEN]) -> &[u8] {
|
||||||
let len = name_len(raw).unwrap_or(NAME_LEN);
|
let len = name_len(raw).unwrap_or(NAME_LEN);
|
||||||
&raw[..len]
|
&raw[..len]
|
||||||
@@ -1414,7 +1520,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preserves_nonzero_unindexed_region() {
|
fn preserves_nonzero_unindexed_region() {
|
||||||
let mut bytes = build_archive(&[SyntheticEntry {
|
let bytes = build_archive_with_nonzero_prefix_gap(&[SyntheticEntry {
|
||||||
type_id: 1,
|
type_id: 1,
|
||||||
attr1: 0,
|
attr1: 0,
|
||||||
attr2: 0,
|
attr2: 0,
|
||||||
@@ -1422,18 +1528,6 @@ mod tests {
|
|||||||
name: "payload",
|
name: "payload",
|
||||||
payload: b"data",
|
payload: b"data",
|
||||||
}]);
|
}]);
|
||||||
let directory_offset = bytes.len() - ENTRY_LEN;
|
|
||||||
bytes.splice(HEADER_LEN..HEADER_LEN, [0xAA, 0xBB, 0xCC, 0xDD]);
|
|
||||||
let total = u32::try_from(bytes.len()).expect("total size");
|
|
||||||
bytes[12..16].copy_from_slice(&total.to_le_bytes());
|
|
||||||
let offset = u32::from_le_bytes(
|
|
||||||
bytes[directory_offset + 4 + 56..directory_offset + 4 + 60]
|
|
||||||
.try_into()
|
|
||||||
.expect("shifted offset"),
|
|
||||||
);
|
|
||||||
let shifted_directory_offset = directory_offset + 4;
|
|
||||||
bytes[shifted_directory_offset + 56..shifted_directory_offset + 60]
|
|
||||||
.copy_from_slice(&(offset + 4).to_le_bytes());
|
|
||||||
|
|
||||||
let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("nres");
|
let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("nres");
|
||||||
assert!(doc.has_nonzero_preserved_region());
|
assert!(doc.has_nonzero_preserved_region());
|
||||||
@@ -1443,7 +1537,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_compact_roundtrip_preserves_entry_semantics() {
|
fn canonical_compact_roundtrip_preserves_entry_semantics() {
|
||||||
let mut bytes = build_archive(&[
|
let bytes = build_archive_with_nonzero_prefix_gap(&[
|
||||||
SyntheticEntry {
|
SyntheticEntry {
|
||||||
type_id: 7,
|
type_id: 7,
|
||||||
attr1: 10,
|
attr1: 10,
|
||||||
@@ -1461,16 +1555,6 @@ mod tests {
|
|||||||
payload: b"aaaa",
|
payload: b"aaaa",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
let directory_offset = bytes.len() - ENTRY_LEN * 2;
|
|
||||||
bytes.splice(HEADER_LEN..HEADER_LEN, [0xAA, 0xBB, 0xCC, 0xDD]);
|
|
||||||
let total = u32::try_from(bytes.len()).expect("total size");
|
|
||||||
bytes[12..16].copy_from_slice(&total.to_le_bytes());
|
|
||||||
for entry_index in 0..2 {
|
|
||||||
let field = directory_offset + 4 + entry_index * ENTRY_LEN + 56;
|
|
||||||
let offset =
|
|
||||||
u32::from_le_bytes(bytes[field..field + 4].try_into().expect("shifted offset"));
|
|
||||||
bytes[field..field + 4].copy_from_slice(&(offset + 4).to_le_bytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
let original = decode(arc(bytes), ReadProfile::Strict).expect("original");
|
let original = decode(arc(bytes), ReadProfile::Strict).expect("original");
|
||||||
let compact = decode(
|
let compact = decode(
|
||||||
@@ -1529,8 +1613,13 @@ mod tests {
|
|||||||
editor
|
editor
|
||||||
.set_payload(EntryId(0), b"replacement".to_vec())
|
.set_payload(EntryId(0), b"replacement".to_vec())
|
||||||
.expect("set payload");
|
.expect("set payload");
|
||||||
let edited =
|
let edited = decode(
|
||||||
decode(arc(editor.encode().expect("encode")), ReadProfile::Strict).expect("edited");
|
arc(editor
|
||||||
|
.encode_with_profile(WriteProfile::CanonicalCompact)
|
||||||
|
.expect("encode")),
|
||||||
|
ReadProfile::Strict,
|
||||||
|
)
|
||||||
|
.expect("edited");
|
||||||
let first = edited.entry(EntryId(0)).expect("first");
|
let first = edited.entry(EntryId(0)).expect("first");
|
||||||
let second = edited.entry(EntryId(1)).expect("second");
|
let second = edited.entry(EntryId(1)).expect("second");
|
||||||
|
|
||||||
@@ -1545,6 +1634,64 @@ mod tests {
|
|||||||
assert!(second.meta().data_offset > first.meta().data_offset + first.meta().data_size);
|
assert!(second.meta().data_offset > first.meta().data_offset + first.meta().data_size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_payload_update_preserves_nonzero_unindexed_region_by_default() {
|
||||||
|
let bytes = build_archive_with_nonzero_prefix_gap(&[
|
||||||
|
SyntheticEntry {
|
||||||
|
type_id: 1,
|
||||||
|
attr1: 0,
|
||||||
|
attr2: 0,
|
||||||
|
attr3: 0,
|
||||||
|
name: "first",
|
||||||
|
payload: b"one",
|
||||||
|
},
|
||||||
|
SyntheticEntry {
|
||||||
|
type_id: 2,
|
||||||
|
attr1: 0,
|
||||||
|
attr2: 0,
|
||||||
|
attr3: 0,
|
||||||
|
name: "second",
|
||||||
|
payload: b"two",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
let original = decode(arc(bytes), ReadProfile::Strict).expect("original");
|
||||||
|
let marker = original
|
||||||
|
.preserved_regions()
|
||||||
|
.iter()
|
||||||
|
.find(|region| !region.all_zero)
|
||||||
|
.expect("nonzero preserved region")
|
||||||
|
.range
|
||||||
|
.clone();
|
||||||
|
let marker = original.bytes[usize::try_from(marker.start).expect("start")
|
||||||
|
..usize::try_from(marker.end).expect("end")]
|
||||||
|
.to_vec();
|
||||||
|
let mut editor = original.editor().expect("editor");
|
||||||
|
|
||||||
|
editor
|
||||||
|
.set_payload(EntryId(0), b"replacement".to_vec())
|
||||||
|
.expect("set payload");
|
||||||
|
let edited_bytes = editor.encode().expect("encode");
|
||||||
|
let edited = decode(arc(edited_bytes.clone()), ReadProfile::Strict).expect("edited");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
edited.payload(EntryId(0)).expect("first payload"),
|
||||||
|
b"replacement"
|
||||||
|
);
|
||||||
|
assert!(edited.has_nonzero_preserved_region());
|
||||||
|
assert!(edited_bytes
|
||||||
|
.windows(marker.len())
|
||||||
|
.any(|window| window == marker));
|
||||||
|
|
||||||
|
let compact = decode(
|
||||||
|
arc(editor
|
||||||
|
.encode_with_profile(WriteProfile::CanonicalCompact)
|
||||||
|
.expect("compact")),
|
||||||
|
ReadProfile::Strict,
|
||||||
|
)
|
||||||
|
.expect("compact");
|
||||||
|
assert!(!compact.has_nonzero_preserved_region());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn editor_rename_rebuilds_search_mapping() {
|
fn editor_rename_rebuilds_search_mapping() {
|
||||||
let bytes = build_archive(&[
|
let bytes = build_archive(&[
|
||||||
@@ -1779,6 +1926,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpora_nres_roundtrip_gates() {
|
fn licensed_corpora_nres_roundtrip_gates() {
|
||||||
let part1 = corpus_gate("IS", 120, 6_804).expect("part 1 NRes gate");
|
let part1 = corpus_gate("IS", 120, 6_804).expect("part 1 NRes gate");
|
||||||
let part2 = corpus_gate("IS2", 134, 8_171).expect("part 2 NRes gate");
|
let part2 = corpus_gate("IS2", 134, 8_171).expect("part 2 NRes gate");
|
||||||
@@ -1800,10 +1948,14 @@ mod tests {
|
|||||||
expected_files: usize,
|
expected_files: usize,
|
||||||
expected_entries: usize,
|
expected_entries: usize,
|
||||||
) -> Result<CorpusGateResult, String> {
|
) -> Result<CorpusGateResult, String> {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
let variable = match name {
|
||||||
.join("../..")
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
||||||
.join("testdata")
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
||||||
.join(name);
|
_ => return Err(format!("unknown licensed corpus part: {name}")),
|
||||||
|
};
|
||||||
|
let root = std::env::var_os(variable)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.ok_or_else(|| format!("{variable} is required for licensed corpus tests"))?;
|
||||||
if !root.is_dir() {
|
if !root.is_dir() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"licensed corpus root is missing: {}",
|
"licensed corpus root is missing: {}",
|
||||||
@@ -1929,6 +2081,21 @@ mod tests {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_archive_with_nonzero_prefix_gap(entries: &[SyntheticEntry<'_>]) -> Vec<u8> {
|
||||||
|
let mut bytes = build_archive(entries);
|
||||||
|
let directory_offset = bytes.len() - ENTRY_LEN * entries.len();
|
||||||
|
bytes.splice(HEADER_LEN..HEADER_LEN, [0xAA, 0xBB, 0xCC, 0xDD]);
|
||||||
|
let total = u32::try_from(bytes.len()).expect("total size");
|
||||||
|
bytes[12..16].copy_from_slice(&total.to_le_bytes());
|
||||||
|
for entry_index in 0..entries.len() {
|
||||||
|
let field = directory_offset + 4 + entry_index * ENTRY_LEN + 56;
|
||||||
|
let offset =
|
||||||
|
u32::from_le_bytes(bytes[field..field + 4].try_into().expect("shifted offset"));
|
||||||
|
bytes[field..field + 4].copy_from_slice(&(offset + 4).to_le_bytes());
|
||||||
|
}
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
fn arc(bytes: Vec<u8>) -> Arc<[u8]> {
|
fn arc(bytes: Vec<u8>) -> Arc<[u8]> {
|
||||||
Arc::from(bytes.into_boxed_slice())
|
Arc::from(bytes.into_boxed_slice())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,14 @@ pub enum PathError {
|
|||||||
|
|
||||||
impl fmt::Display for PathError {
|
impl fmt::Display for PathError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{self:?}")
|
match self {
|
||||||
|
Self::Empty => write!(f, "path is empty"),
|
||||||
|
Self::EmbeddedNul => write!(f, "path contains an embedded NUL byte"),
|
||||||
|
Self::Absolute => write!(f, "path must be relative and cannot be absolute"),
|
||||||
|
Self::ParentTraversal => write!(f, "path attempts to traverse outside its root"),
|
||||||
|
Self::EscapesRoot => write!(f, "normalized path escapes the configured root"),
|
||||||
|
Self::InvalidUtf8 => write!(f, "path is not valid UTF-8 after normalization"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +117,7 @@ impl std::error::Error for PathError {}
|
|||||||
/// Returns [`PathError`] when the input is empty, absolute, contains an
|
/// Returns [`PathError`] when the input is empty, absolute, contains an
|
||||||
/// embedded NUL, attempts parent traversal, or is not valid UTF-8 after
|
/// embedded NUL, attempts parent traversal, or is not valid UTF-8 after
|
||||||
/// legacy separator normalization.
|
/// legacy separator normalization.
|
||||||
pub fn normalize_relative(raw: &[u8], _policy: PathPolicy) -> Result<NormalizedPath, PathError> {
|
pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPath, PathError> {
|
||||||
if raw.is_empty() {
|
if raw.is_empty() {
|
||||||
return Err(PathError::Empty);
|
return Err(PathError::Empty);
|
||||||
}
|
}
|
||||||
@@ -124,11 +131,17 @@ pub fn normalize_relative(raw: &[u8], _policy: PathPolicy) -> Result<NormalizedP
|
|||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
for part in text.split(['/', '\\']) {
|
for part in text.split(['/', '\\']) {
|
||||||
if part.is_empty() || part == "." {
|
if part.is_empty() || part == "." {
|
||||||
|
if policy == PathPolicy::StrictLegacy {
|
||||||
|
return Err(PathError::ParentTraversal);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if part == ".." {
|
if part == ".." {
|
||||||
return Err(PathError::ParentTraversal);
|
return Err(PathError::ParentTraversal);
|
||||||
}
|
}
|
||||||
|
if policy == PathPolicy::StrictLegacy && part.contains(':') {
|
||||||
|
return Err(PathError::Absolute);
|
||||||
|
}
|
||||||
parts.push(part);
|
parts.push(part);
|
||||||
}
|
}
|
||||||
if parts.is_empty() {
|
if parts.is_empty() {
|
||||||
@@ -223,6 +236,37 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_error_display_is_actionable() {
|
||||||
|
assert_eq!(
|
||||||
|
PathError::ParentTraversal.to_string(),
|
||||||
|
"path attempts to traverse outside its root"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
PathError::EmbeddedNul.to_string(),
|
||||||
|
"path contains an embedded NUL byte"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_legacy_rejects_host_only_segments() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_relative(b"./DATA/MAPS", PathPolicy::StrictLegacy),
|
||||||
|
Err(PathError::ParentTraversal)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_relative(b"DATA//MAPS", PathPolicy::StrictLegacy),
|
||||||
|
Err(PathError::ParentTraversal)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_relative(b"DATA/stream:name", PathPolicy::StrictLegacy),
|
||||||
|
Err(PathError::Absolute)
|
||||||
|
);
|
||||||
|
|
||||||
|
let host = normalize_relative(b"./DATA//MAPS", PathPolicy::HostCompatible).expect("host");
|
||||||
|
assert_eq!(host.as_str(), "DATA/MAPS");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn join_under_keeps_normalized_path_below_root() {
|
fn join_under_keeps_normalized_path_below_root() {
|
||||||
let rel = normalize_relative(b"DATA/MAPS/Land.map", PathPolicy::StrictLegacy)
|
let rel = normalize_relative(b"DATA/MAPS/Land.map", PathPolicy::StrictLegacy)
|
||||||
|
|||||||
@@ -1768,8 +1768,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn resolves_known_part1_registry_cases() {
|
fn resolves_known_part1_registry_cases() {
|
||||||
let root = corpus_root("IS").expect("part 1 root");
|
let root = corpus_root("IS");
|
||||||
let vfs = Arc::new(DirectoryVfs::new(&root));
|
let vfs = Arc::new(DirectoryVfs::new(&root));
|
||||||
let repo = CachedResourceRepository::new(vfs.clone());
|
let repo = CachedResourceRepository::new(vfs.clone());
|
||||||
let cases = [
|
let cases = [
|
||||||
@@ -1799,9 +1800,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn resolves_some_registry_entries_in_both_corpora() {
|
fn resolves_some_registry_entries_in_both_corpora() {
|
||||||
for corpus in ["IS", "IS2"] {
|
for corpus in ["IS", "IS2"] {
|
||||||
let root = corpus_root(corpus).expect("corpus root");
|
let root = corpus_root(corpus);
|
||||||
let objects = std::fs::read(root.join("objects.rlb")).expect("objects.rlb");
|
let objects = std::fs::read(root.join("objects.rlb")).expect("objects.rlb");
|
||||||
let document = fparkan_nres::decode(
|
let document = fparkan_nres::decode(
|
||||||
Arc::from(objects.into_boxed_slice()),
|
Arc::from(objects.into_boxed_slice()),
|
||||||
@@ -1826,10 +1828,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpora_unit_dat_parse_counts() {
|
fn licensed_corpora_unit_dat_parse_counts() {
|
||||||
let cases = [("IS", 425, 5_219), ("IS2", 676, 8_145)];
|
let cases = [("IS", 425, 5_219), ("IS2", 676, 8_145)];
|
||||||
for (corpus, expected_files, expected_records) in cases {
|
for (corpus, expected_files, expected_records) in cases {
|
||||||
let root = corpus_root(corpus).expect("corpus root");
|
let root = corpus_root(corpus);
|
||||||
let mut dat_paths = Vec::new();
|
let mut dat_paths = Vec::new();
|
||||||
collect_unit_dat_files(&root, &mut dat_paths);
|
collect_unit_dat_files(&root, &mut dat_paths);
|
||||||
dat_paths.sort();
|
dat_paths.sort();
|
||||||
@@ -1859,9 +1862,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpora_registry_payloads_are_record_aligned() {
|
fn licensed_corpora_registry_payloads_are_record_aligned() {
|
||||||
for corpus in ["IS", "IS2"] {
|
for corpus in ["IS", "IS2"] {
|
||||||
let root = corpus_root(corpus).expect("corpus root");
|
let root = corpus_root(corpus);
|
||||||
let objects = std::fs::read(root.join("objects.rlb")).expect("objects.rlb");
|
let objects = std::fs::read(root.join("objects.rlb")).expect("objects.rlb");
|
||||||
let document = fparkan_nres::decode(
|
let document = fparkan_nres::decode(
|
||||||
Arc::from(objects.into_boxed_slice()),
|
Arc::from(objects.into_boxed_slice()),
|
||||||
@@ -1907,12 +1911,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn corpus_root(name: &str) -> Option<std::path::PathBuf> {
|
fn corpus_root(name: &str) -> std::path::PathBuf {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
let variable = match name {
|
||||||
.join("../..")
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
||||||
.join("testdata")
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
||||||
.join(name);
|
_ => panic!("unknown licensed corpus part: {name}"),
|
||||||
root.is_dir().then_some(root)
|
};
|
||||||
|
let root = std::env::var_os(variable)
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
|
||||||
|
assert!(
|
||||||
|
root.is_dir(),
|
||||||
|
"licensed corpus root is missing: {}",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
root
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generated_acyclic_graph(
|
fn generated_acyclic_graph(
|
||||||
|
|||||||
@@ -139,6 +139,15 @@ pub struct RenderCommandList {
|
|||||||
pub commands: Vec<RenderCommand>,
|
pub commands: Vec<RenderCommand>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Optional render command validation limits.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct RenderValidationLimits {
|
||||||
|
/// Exclusive upper bound for GPU mesh ids.
|
||||||
|
pub mesh_count: Option<u64>,
|
||||||
|
/// Exclusive upper bound for index ranges.
|
||||||
|
pub index_count: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Frame output.
|
/// Frame output.
|
||||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
pub struct FrameOutput;
|
pub struct FrameOutput;
|
||||||
@@ -148,6 +157,13 @@ pub struct FrameOutput;
|
|||||||
pub enum RenderError {
|
pub enum RenderError {
|
||||||
/// Invalid range.
|
/// Invalid range.
|
||||||
InvalidRange,
|
InvalidRange,
|
||||||
|
/// Invalid command stream framing or ordering.
|
||||||
|
InvalidCommandStream {
|
||||||
|
/// Command index.
|
||||||
|
index: usize,
|
||||||
|
/// Contextual error message.
|
||||||
|
message: &'static str,
|
||||||
|
},
|
||||||
/// Invalid draw range with command-generation context.
|
/// Invalid draw range with command-generation context.
|
||||||
InvalidDrawRange {
|
InvalidDrawRange {
|
||||||
/// Draw id.
|
/// Draw id.
|
||||||
@@ -159,6 +175,49 @@ pub enum RenderError {
|
|||||||
/// Range count.
|
/// Range count.
|
||||||
count: u32,
|
count: u32,
|
||||||
},
|
},
|
||||||
|
/// Index range arithmetic overflow.
|
||||||
|
IndexRangeOverflow {
|
||||||
|
/// Draw id.
|
||||||
|
draw_id: DrawId,
|
||||||
|
/// Range start.
|
||||||
|
start: u32,
|
||||||
|
/// Range count.
|
||||||
|
count: u32,
|
||||||
|
},
|
||||||
|
/// Index range exceeds validation limits.
|
||||||
|
IndexRangeOutOfBounds {
|
||||||
|
/// Draw id.
|
||||||
|
draw_id: DrawId,
|
||||||
|
/// Exclusive index limit.
|
||||||
|
index_count: u32,
|
||||||
|
/// Range end.
|
||||||
|
end: u32,
|
||||||
|
},
|
||||||
|
/// Mesh id exceeds validation limits.
|
||||||
|
MeshOutOfBounds {
|
||||||
|
/// Draw id.
|
||||||
|
draw_id: DrawId,
|
||||||
|
/// Mesh id.
|
||||||
|
mesh: GpuMeshId,
|
||||||
|
/// Exclusive mesh limit.
|
||||||
|
mesh_count: u64,
|
||||||
|
},
|
||||||
|
/// Draw transform contains a non-finite value.
|
||||||
|
NonFiniteTransform {
|
||||||
|
/// Draw id.
|
||||||
|
draw_id: DrawId,
|
||||||
|
/// Matrix element index.
|
||||||
|
element: usize,
|
||||||
|
},
|
||||||
|
/// Draw commands are not ordered by phase, stable order and draw id.
|
||||||
|
PhaseOrderViolation {
|
||||||
|
/// Draw id.
|
||||||
|
draw_id: DrawId,
|
||||||
|
/// Previous phase.
|
||||||
|
previous: RenderPhase,
|
||||||
|
/// Current phase.
|
||||||
|
current: RenderPhase,
|
||||||
|
},
|
||||||
/// A batch material index did not resolve through the material table.
|
/// A batch material index did not resolve through the material table.
|
||||||
MaterialIndexOutOfBounds {
|
MaterialIndexOutOfBounds {
|
||||||
/// Draw id.
|
/// Draw id.
|
||||||
@@ -172,7 +231,75 @@ pub enum RenderError {
|
|||||||
|
|
||||||
impl std::fmt::Display for RenderError {
|
impl std::fmt::Display for RenderError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
write!(f, "{self:?}")
|
match self {
|
||||||
|
Self::InvalidRange => write!(f, "render command contains an empty index range"),
|
||||||
|
Self::InvalidCommandStream { index, message } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"render command stream is invalid at command {index}: {message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::InvalidDrawRange {
|
||||||
|
draw_id,
|
||||||
|
stable_order,
|
||||||
|
start,
|
||||||
|
count,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"draw {} has invalid index range start={} count={} at stable order {}",
|
||||||
|
draw_id.0, start, count, stable_order
|
||||||
|
),
|
||||||
|
Self::IndexRangeOverflow {
|
||||||
|
draw_id,
|
||||||
|
start,
|
||||||
|
count,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"draw {} index range overflows start={} count={}",
|
||||||
|
draw_id.0, start, count
|
||||||
|
),
|
||||||
|
Self::IndexRangeOutOfBounds {
|
||||||
|
draw_id,
|
||||||
|
index_count,
|
||||||
|
end,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"draw {} index range ends at {} but mesh has {} indices",
|
||||||
|
draw_id.0, end, index_count
|
||||||
|
),
|
||||||
|
Self::MeshOutOfBounds {
|
||||||
|
draw_id,
|
||||||
|
mesh,
|
||||||
|
mesh_count,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"draw {} references mesh {} but only {} meshes are available",
|
||||||
|
draw_id.0, mesh.0, mesh_count
|
||||||
|
),
|
||||||
|
Self::NonFiniteTransform { draw_id, element } => write!(
|
||||||
|
f,
|
||||||
|
"draw {} has non-finite transform element {}",
|
||||||
|
draw_id.0, element
|
||||||
|
),
|
||||||
|
Self::PhaseOrderViolation {
|
||||||
|
draw_id,
|
||||||
|
previous,
|
||||||
|
current,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"draw {} phase order regressed from {:?} to {:?}",
|
||||||
|
draw_id.0, previous, current
|
||||||
|
),
|
||||||
|
Self::MaterialIndexOutOfBounds {
|
||||||
|
draw_id,
|
||||||
|
material_index,
|
||||||
|
material_count,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"draw {} references material index {} but only {} material slots are available",
|
||||||
|
draw_id.0, material_index, material_count
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +333,8 @@ pub fn build_commands(
|
|||||||
count: draw.range.count,
|
count: draw.range.count,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
validate_index_range(draw.id, draw.range)?;
|
||||||
|
validate_transform(draw.id, &draw.transform)?;
|
||||||
let material = draw
|
let material = draw
|
||||||
.material_slots
|
.material_slots
|
||||||
.get(usize::from(draw.material_index))
|
.get(usize::from(draw.material_index))
|
||||||
@@ -247,7 +376,7 @@ pub struct NullBackend;
|
|||||||
|
|
||||||
impl RenderBackend for NullBackend {
|
impl RenderBackend for NullBackend {
|
||||||
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
|
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
|
||||||
validate_commands(commands)?;
|
validate_command_list(commands)?;
|
||||||
Ok(FrameOutput)
|
Ok(FrameOutput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,7 +420,7 @@ impl RenderBackend for RecordingBackend {
|
|||||||
///
|
///
|
||||||
/// Returns [`RenderError`] when a draw command contains an invalid index range.
|
/// Returns [`RenderError`] when a draw command contains an invalid index range.
|
||||||
pub fn canonical_capture(commands: &RenderCommandList) -> Result<Vec<u8>, RenderError> {
|
pub fn canonical_capture(commands: &RenderCommandList) -> Result<Vec<u8>, RenderError> {
|
||||||
validate_commands(commands)?;
|
validate_command_list(commands)?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for command in &commands.commands {
|
for command in &commands.commands {
|
||||||
match command {
|
match command {
|
||||||
@@ -311,12 +440,132 @@ pub fn canonical_capture(commands: &RenderCommandList) -> Result<Vec<u8>, Render
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_commands(commands: &RenderCommandList) -> Result<(), RenderError> {
|
/// Validates a render command list without backend-specific resource limits.
|
||||||
for command in &commands.commands {
|
///
|
||||||
if let RenderCommand::Draw(draw) = command {
|
/// # Errors
|
||||||
if draw.range.count == 0 {
|
///
|
||||||
return Err(RenderError::InvalidRange);
|
/// Returns [`RenderError`] when framing, ordering or draw data is invalid.
|
||||||
|
pub fn validate_command_list(commands: &RenderCommandList) -> Result<(), RenderError> {
|
||||||
|
validate_command_list_with_limits(commands, RenderValidationLimits::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a render command list with optional backend resource limits.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`RenderError`] when framing, ordering, draw data or resource bounds
|
||||||
|
/// are invalid.
|
||||||
|
pub fn validate_command_list_with_limits(
|
||||||
|
commands: &RenderCommandList,
|
||||||
|
limits: RenderValidationLimits,
|
||||||
|
) -> Result<(), RenderError> {
|
||||||
|
let Some(first) = commands.commands.first() else {
|
||||||
|
return Err(RenderError::InvalidCommandStream {
|
||||||
|
index: 0,
|
||||||
|
message: "empty command list",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if !matches!(first, RenderCommand::BeginFrame) {
|
||||||
|
return Err(RenderError::InvalidCommandStream {
|
||||||
|
index: 0,
|
||||||
|
message: "first command must be BeginFrame",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if commands.commands.len() < 2 {
|
||||||
|
return Err(RenderError::InvalidCommandStream {
|
||||||
|
index: 0,
|
||||||
|
message: "frame must end with EndFrame",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let end_index = commands.commands.len() - 1;
|
||||||
|
if !matches!(commands.commands[end_index], RenderCommand::EndFrame) {
|
||||||
|
return Err(RenderError::InvalidCommandStream {
|
||||||
|
index: end_index,
|
||||||
|
message: "last command must be EndFrame",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut previous_key: Option<(RenderPhase, u64, DrawId)> = None;
|
||||||
|
for (index, command) in commands.commands.iter().enumerate() {
|
||||||
|
match command {
|
||||||
|
RenderCommand::BeginFrame if index == 0 => {}
|
||||||
|
RenderCommand::BeginFrame => {
|
||||||
|
return Err(RenderError::InvalidCommandStream {
|
||||||
|
index,
|
||||||
|
message: "nested BeginFrame is not allowed",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
RenderCommand::EndFrame if index == end_index => {}
|
||||||
|
RenderCommand::EndFrame => {
|
||||||
|
return Err(RenderError::InvalidCommandStream {
|
||||||
|
index,
|
||||||
|
message: "EndFrame before final command is not allowed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
RenderCommand::Draw(draw) => {
|
||||||
|
validate_draw_command(draw, limits)?;
|
||||||
|
let key = (draw.phase, draw.stable_order, draw.id);
|
||||||
|
if let Some(previous) = previous_key {
|
||||||
|
if key < previous {
|
||||||
|
return Err(RenderError::PhaseOrderViolation {
|
||||||
|
draw_id: draw.id,
|
||||||
|
previous: previous.0,
|
||||||
|
current: draw.phase,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previous_key = Some(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_draw_command(
|
||||||
|
draw: &DrawCommand,
|
||||||
|
limits: RenderValidationLimits,
|
||||||
|
) -> Result<(), RenderError> {
|
||||||
|
if draw.range.count == 0 {
|
||||||
|
return Err(RenderError::InvalidRange);
|
||||||
|
}
|
||||||
|
let end = validate_index_range(draw.id, draw.range)?;
|
||||||
|
validate_transform(draw.id, &draw.transform)?;
|
||||||
|
if let Some(mesh_count) = limits.mesh_count {
|
||||||
|
if draw.mesh.0 >= mesh_count {
|
||||||
|
return Err(RenderError::MeshOutOfBounds {
|
||||||
|
draw_id: draw.id,
|
||||||
|
mesh: draw.mesh,
|
||||||
|
mesh_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(index_count) = limits.index_count {
|
||||||
|
if end > index_count {
|
||||||
|
return Err(RenderError::IndexRangeOutOfBounds {
|
||||||
|
draw_id: draw.id,
|
||||||
|
index_count,
|
||||||
|
end,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_index_range(draw_id: DrawId, range: IndexRange) -> Result<u32, RenderError> {
|
||||||
|
range
|
||||||
|
.start
|
||||||
|
.checked_add(range.count)
|
||||||
|
.ok_or(RenderError::IndexRangeOverflow {
|
||||||
|
draw_id,
|
||||||
|
start: range.start,
|
||||||
|
count: range.count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_transform(draw_id: DrawId, transform: &[f32; 16]) -> Result<(), RenderError> {
|
||||||
|
for (element, value) in transform.iter().enumerate() {
|
||||||
|
if !value.is_finite() {
|
||||||
|
return Err(RenderError::NonFiniteTransform { draw_id, element });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -379,16 +628,20 @@ mod tests {
|
|||||||
fn null_backend_validates_without_capture() {
|
fn null_backend_validates_without_capture() {
|
||||||
let mut backend = NullBackend;
|
let mut backend = NullBackend;
|
||||||
let invalid = RenderCommandList {
|
let invalid = RenderCommandList {
|
||||||
commands: vec![RenderCommand::Draw(DrawCommand {
|
commands: vec![
|
||||||
id: DrawId(1),
|
RenderCommand::BeginFrame,
|
||||||
phase: RenderPhase::Opaque,
|
RenderCommand::Draw(DrawCommand {
|
||||||
object_id: None,
|
id: DrawId(1),
|
||||||
mesh: GpuMeshId(2),
|
phase: RenderPhase::Opaque,
|
||||||
material: GpuMaterialId(3),
|
object_id: None,
|
||||||
transform: [0.0; 16],
|
mesh: GpuMeshId(2),
|
||||||
range: IndexRange { start: 0, count: 0 },
|
material: GpuMaterialId(3),
|
||||||
stable_order: 4,
|
transform: [0.0; 16],
|
||||||
})],
|
range: IndexRange { start: 0, count: 0 },
|
||||||
|
stable_order: 4,
|
||||||
|
}),
|
||||||
|
RenderCommand::EndFrame,
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -534,6 +787,201 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_validation_rejects_bad_frame_framing() {
|
||||||
|
let missing_begin = RenderCommandList {
|
||||||
|
commands: vec![RenderCommand::EndFrame],
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
validate_command_list(&missing_begin),
|
||||||
|
Err(RenderError::InvalidCommandStream {
|
||||||
|
index: 0,
|
||||||
|
message: "first command must be BeginFrame"
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
let nested = RenderCommandList {
|
||||||
|
commands: vec![
|
||||||
|
RenderCommand::BeginFrame,
|
||||||
|
RenderCommand::BeginFrame,
|
||||||
|
RenderCommand::EndFrame,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
validate_command_list(&nested),
|
||||||
|
Err(RenderError::InvalidCommandStream {
|
||||||
|
index: 1,
|
||||||
|
message: "nested BeginFrame is not allowed"
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_validation_rejects_nonfinite_transform_and_range_overflow() {
|
||||||
|
let mut draw = snapshot_draw(10, RenderPhase::Opaque, 0, 10);
|
||||||
|
draw.transform[5] = f32::NAN;
|
||||||
|
let nonfinite = build_commands(
|
||||||
|
&RenderSnapshot {
|
||||||
|
camera: CameraSnapshot::default(),
|
||||||
|
draws: vec![draw],
|
||||||
|
},
|
||||||
|
RenderProfile::default(),
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
nonfinite,
|
||||||
|
Err(RenderError::NonFiniteTransform {
|
||||||
|
draw_id: DrawId(10),
|
||||||
|
element: 5
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
let list = RenderCommandList {
|
||||||
|
commands: vec![
|
||||||
|
RenderCommand::BeginFrame,
|
||||||
|
RenderCommand::Draw(DrawCommand {
|
||||||
|
id: DrawId(11),
|
||||||
|
phase: RenderPhase::Opaque,
|
||||||
|
object_id: None,
|
||||||
|
mesh: GpuMeshId(2),
|
||||||
|
material: GpuMaterialId(3),
|
||||||
|
transform: identity_transform(),
|
||||||
|
range: IndexRange {
|
||||||
|
start: u32::MAX,
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
stable_order: 4,
|
||||||
|
}),
|
||||||
|
RenderCommand::EndFrame,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
validate_command_list(&list),
|
||||||
|
Err(RenderError::IndexRangeOverflow {
|
||||||
|
draw_id: DrawId(11),
|
||||||
|
start: u32::MAX,
|
||||||
|
count: 1
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_validation_checks_order_and_resource_bounds() {
|
||||||
|
let ordered = build_commands(
|
||||||
|
&RenderSnapshot {
|
||||||
|
camera: CameraSnapshot::default(),
|
||||||
|
draws: vec![snapshot_draw(1, RenderPhase::Opaque, 0, 10)],
|
||||||
|
},
|
||||||
|
RenderProfile::default(),
|
||||||
|
)
|
||||||
|
.expect("commands");
|
||||||
|
assert!(matches!(
|
||||||
|
validate_command_list_with_limits(
|
||||||
|
&ordered,
|
||||||
|
RenderValidationLimits {
|
||||||
|
mesh_count: Some(5),
|
||||||
|
index_count: Some(16)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Err(RenderError::MeshOutOfBounds {
|
||||||
|
draw_id: DrawId(1),
|
||||||
|
mesh: GpuMeshId(11),
|
||||||
|
mesh_count: 5
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
let out_of_bounds = RenderCommandList {
|
||||||
|
commands: vec![
|
||||||
|
RenderCommand::BeginFrame,
|
||||||
|
RenderCommand::Draw(DrawCommand {
|
||||||
|
id: DrawId(12),
|
||||||
|
phase: RenderPhase::Opaque,
|
||||||
|
object_id: None,
|
||||||
|
mesh: GpuMeshId(2),
|
||||||
|
material: GpuMaterialId(3),
|
||||||
|
transform: identity_transform(),
|
||||||
|
range: IndexRange {
|
||||||
|
start: 14,
|
||||||
|
count: 3,
|
||||||
|
},
|
||||||
|
stable_order: 4,
|
||||||
|
}),
|
||||||
|
RenderCommand::EndFrame,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
validate_command_list_with_limits(
|
||||||
|
&out_of_bounds,
|
||||||
|
RenderValidationLimits {
|
||||||
|
mesh_count: Some(5),
|
||||||
|
index_count: Some(16)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Err(RenderError::IndexRangeOutOfBounds {
|
||||||
|
draw_id: DrawId(12),
|
||||||
|
index_count: 16,
|
||||||
|
end: 17
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
let unordered = RenderCommandList {
|
||||||
|
commands: vec![
|
||||||
|
RenderCommand::BeginFrame,
|
||||||
|
RenderCommand::Draw(DrawCommand {
|
||||||
|
id: DrawId(1),
|
||||||
|
phase: RenderPhase::Transparent,
|
||||||
|
object_id: None,
|
||||||
|
mesh: GpuMeshId(1),
|
||||||
|
material: GpuMaterialId(1),
|
||||||
|
transform: identity_transform(),
|
||||||
|
range: IndexRange { start: 0, count: 3 },
|
||||||
|
stable_order: 0,
|
||||||
|
}),
|
||||||
|
RenderCommand::Draw(DrawCommand {
|
||||||
|
id: DrawId(2),
|
||||||
|
phase: RenderPhase::Opaque,
|
||||||
|
object_id: None,
|
||||||
|
mesh: GpuMeshId(1),
|
||||||
|
material: GpuMaterialId(1),
|
||||||
|
transform: identity_transform(),
|
||||||
|
range: IndexRange { start: 0, count: 3 },
|
||||||
|
stable_order: 0,
|
||||||
|
}),
|
||||||
|
RenderCommand::EndFrame,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
validate_command_list(&unordered),
|
||||||
|
Err(RenderError::PhaseOrderViolation {
|
||||||
|
draw_id: DrawId(2),
|
||||||
|
previous: RenderPhase::Transparent,
|
||||||
|
current: RenderPhase::Opaque
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_error_display_is_actionable() {
|
||||||
|
assert_eq!(
|
||||||
|
RenderError::InvalidDrawRange {
|
||||||
|
draw_id: DrawId(9),
|
||||||
|
stable_order: 10,
|
||||||
|
start: 4,
|
||||||
|
count: 0
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
"draw 9 has invalid index range start=4 count=0 at stable order 10"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
RenderError::MaterialIndexOutOfBounds {
|
||||||
|
draw_id: DrawId(7),
|
||||||
|
material_index: 3,
|
||||||
|
material_count: 2
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
"draw 7 references material index 3 but only 2 material slots are available"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ui_phase_is_excluded_until_requested() -> Result<(), RenderError> {
|
fn ui_phase_is_excluded_until_requested() -> Result<(), RenderError> {
|
||||||
let snapshot = RenderSnapshot {
|
let snapshot = RenderSnapshot {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
fparkan-nres = { path = "../fparkan-nres" }
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
fparkan-path = { path = "../fparkan-path" }
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
fparkan-rsli = { path = "../fparkan-rsli" }
|
fparkan-rsli = { path = "../fparkan-rsli" }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
//! Resource identity and repository ports.
|
//! Resource identity and repository ports.
|
||||||
|
|
||||||
|
use fparkan_binary::Sha256Digest;
|
||||||
use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName};
|
use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName};
|
||||||
use fparkan_vfs::{Vfs, VfsError};
|
use fparkan_vfs::{Vfs, VfsError};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
@@ -40,6 +41,8 @@ pub struct ArchiveId(pub u64);
|
|||||||
pub struct EntryHandle {
|
pub struct EntryHandle {
|
||||||
/// Archive.
|
/// Archive.
|
||||||
pub archive: ArchiveId,
|
pub archive: ArchiveId,
|
||||||
|
/// Archive generation at the time the entry was resolved.
|
||||||
|
pub generation: u64,
|
||||||
/// Local entry index.
|
/// Local entry index.
|
||||||
pub local: u32,
|
pub local: u32,
|
||||||
}
|
}
|
||||||
@@ -108,6 +111,8 @@ pub enum ResourceError {
|
|||||||
MissingEntry,
|
MissingEntry,
|
||||||
/// Stale or invalid handle.
|
/// Stale or invalid handle.
|
||||||
InvalidHandle,
|
InvalidHandle,
|
||||||
|
/// Handle belongs to an older archive generation.
|
||||||
|
StaleHandle,
|
||||||
/// Format error.
|
/// Format error.
|
||||||
Format(String),
|
Format(String),
|
||||||
/// Entry-specific read error.
|
/// Entry-specific read error.
|
||||||
@@ -123,7 +128,30 @@ pub enum ResourceError {
|
|||||||
|
|
||||||
impl std::fmt::Display for ResourceError {
|
impl std::fmt::Display for ResourceError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
write!(f, "{self:?}")
|
match self {
|
||||||
|
Self::MissingArchive => write!(f, "archive was not found"),
|
||||||
|
Self::MissingEntry => write!(f, "resource entry was not found in the archive"),
|
||||||
|
Self::InvalidHandle => write!(
|
||||||
|
f,
|
||||||
|
"resource handle does not reference an open archive entry"
|
||||||
|
),
|
||||||
|
Self::StaleHandle => {
|
||||||
|
write!(f, "resource handle belongs to an older archive generation")
|
||||||
|
}
|
||||||
|
Self::Format(message) => write!(f, "resource archive format error: {message}"),
|
||||||
|
Self::EntryRead { key, source } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"failed to read resource {}:{} from {}: {}",
|
||||||
|
key.type_id
|
||||||
|
.map_or_else(|| "-".to_string(), |type_id| type_id.to_string()),
|
||||||
|
String::from_utf8_lossy(&key.name.0),
|
||||||
|
key.archive.as_str(),
|
||||||
|
source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Poisoned => write!(f, "resource repository state lock was poisoned"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +176,12 @@ pub trait ResourceRepository {
|
|||||||
archive: ArchiveId,
|
archive: ArchiveId,
|
||||||
name: &ResourceName,
|
name: &ResourceName,
|
||||||
) -> Result<Option<EntryHandle>, ResourceError>;
|
) -> Result<Option<EntryHandle>, ResourceError>;
|
||||||
|
/// Returns the first entry in archive directory order.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`ResourceError`] when `archive` is not a valid opened archive.
|
||||||
|
fn first_entry(&self, archive: ArchiveId) -> Result<Option<EntryHandle>, ResourceError>;
|
||||||
/// Reads bytes.
|
/// Reads bytes.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@@ -169,6 +203,24 @@ pub struct CachedResourceRepository {
|
|||||||
state: Mutex<RepositoryState>,
|
state: Mutex<RepositoryState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decoded payload cache limits.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PayloadCacheLimits {
|
||||||
|
/// Maximum cached decoded payload entries.
|
||||||
|
pub max_entries: usize,
|
||||||
|
/// Maximum cached decoded payload bytes.
|
||||||
|
pub max_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PayloadCacheLimits {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_entries: 64,
|
||||||
|
max_bytes: 64 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct RepositoryState {
|
struct RepositoryState {
|
||||||
paths: BTreeMap<String, ArchiveId>,
|
paths: BTreeMap<String, ArchiveId>,
|
||||||
@@ -178,9 +230,10 @@ struct RepositoryState {
|
|||||||
|
|
||||||
struct ArchiveSlot {
|
struct ArchiveSlot {
|
||||||
path: NormalizedPath,
|
path: NormalizedPath,
|
||||||
fingerprint: u64,
|
fingerprint: Sha256Digest,
|
||||||
|
generation: u64,
|
||||||
kind: ArchiveKind,
|
kind: ArchiveKind,
|
||||||
document: ArchiveDocument,
|
document: Arc<ArchiveDocument>,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ArchiveDocument {
|
enum ArchiveDocument {
|
||||||
@@ -188,9 +241,16 @@ enum ArchiveDocument {
|
|||||||
Rsli(fparkan_rsli::RsliDocument),
|
Rsli(fparkan_rsli::RsliDocument),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PayloadDecodeTask {
|
||||||
|
document: Arc<ArchiveDocument>,
|
||||||
|
key: ResourceKey,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct DecodedPayloadCache {
|
struct DecodedPayloadCache {
|
||||||
max_entries: usize,
|
max_entries: usize,
|
||||||
|
max_bytes: usize,
|
||||||
|
current_bytes: usize,
|
||||||
generation: u64,
|
generation: u64,
|
||||||
entries: BTreeMap<EntryHandle, PayloadCacheEntry>,
|
entries: BTreeMap<EntryHandle, PayloadCacheEntry>,
|
||||||
}
|
}
|
||||||
@@ -205,16 +265,28 @@ impl CachedResourceRepository {
|
|||||||
/// Creates a cached repository.
|
/// Creates a cached repository.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(vfs: Arc<dyn Vfs>) -> Self {
|
pub fn new(vfs: Arc<dyn Vfs>) -> Self {
|
||||||
Self::with_payload_cache_budget(vfs, 64)
|
Self::with_payload_cache_limits(vfs, PayloadCacheLimits::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a cached repository with a decoded payload entry budget.
|
/// Creates a cached repository with a decoded payload entry budget.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_payload_cache_budget(vfs: Arc<dyn Vfs>, max_payload_entries: usize) -> Self {
|
pub fn with_payload_cache_budget(vfs: Arc<dyn Vfs>, max_payload_entries: usize) -> Self {
|
||||||
|
Self::with_payload_cache_limits(
|
||||||
|
vfs,
|
||||||
|
PayloadCacheLimits {
|
||||||
|
max_entries: max_payload_entries,
|
||||||
|
..PayloadCacheLimits::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a cached repository with decoded payload entry and byte budgets.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_payload_cache_limits(vfs: Arc<dyn Vfs>, limits: PayloadCacheLimits) -> Self {
|
||||||
Self {
|
Self {
|
||||||
vfs,
|
vfs,
|
||||||
state: Mutex::new(RepositoryState {
|
state: Mutex::new(RepositoryState {
|
||||||
payload_cache: DecodedPayloadCache::new(max_payload_entries),
|
payload_cache: DecodedPayloadCache::new(limits),
|
||||||
..RepositoryState::default()
|
..RepositoryState::default()
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -250,12 +322,13 @@ impl ResourceRepository for CachedResourceRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let bytes = self.vfs.read(path).map_err(resource_error_from_vfs)?;
|
let bytes = self.vfs.read(path).map_err(resource_error_from_vfs)?;
|
||||||
let slot = decode_archive(path.clone(), bytes, fingerprint)?;
|
let mut slot = decode_archive(path.clone(), bytes, fingerprint)?;
|
||||||
let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
||||||
if let Some(id) = state.paths.get(path.as_str()).copied() {
|
if let Some(id) = state.paths.get(path.as_str()).copied() {
|
||||||
if state.archive(id)?.fingerprint == fingerprint {
|
if state.archive(id)?.fingerprint == fingerprint {
|
||||||
return Ok(id);
|
return Ok(id);
|
||||||
}
|
}
|
||||||
|
slot.generation = state.archive(id)?.generation.saturating_add(1);
|
||||||
*state.archive_mut(id)? = slot;
|
*state.archive_mut(id)? = slot;
|
||||||
state.payload_cache.remove_archive(id);
|
state.payload_cache.remove_archive(id);
|
||||||
return Ok(id);
|
return Ok(id);
|
||||||
@@ -275,37 +348,61 @@ impl ResourceRepository for CachedResourceRepository {
|
|||||||
) -> Result<Option<EntryHandle>, ResourceError> {
|
) -> Result<Option<EntryHandle>, ResourceError> {
|
||||||
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
||||||
let slot = state.archive(archive)?;
|
let slot = state.archive(archive)?;
|
||||||
let local = match &slot.document {
|
let local = match slot.document.as_ref() {
|
||||||
ArchiveDocument::Nres(document) => document.find_bytes(&name.0).map(|id| id.0),
|
ArchiveDocument::Nres(document) => document.find_bytes(&name.0).map(|id| id.0),
|
||||||
ArchiveDocument::Rsli(document) => document.find_bytes(&name.0).map(|id| id.0),
|
ArchiveDocument::Rsli(document) => document.find_bytes(&name.0).map(|id| id.0),
|
||||||
};
|
};
|
||||||
Ok(local.map(|local| EntryHandle { archive, local }))
|
Ok(local.map(|local| EntryHandle {
|
||||||
|
archive,
|
||||||
|
generation: slot.generation,
|
||||||
|
local,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_entry(&self, archive: ArchiveId) -> Result<Option<EntryHandle>, ResourceError> {
|
||||||
|
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
||||||
|
let slot = state.archive(archive)?;
|
||||||
|
let local = match slot.document.as_ref() {
|
||||||
|
ArchiveDocument::Nres(document) => document.entries().first().map(|entry| entry.id().0),
|
||||||
|
ArchiveDocument::Rsli(document) => document.entry(fparkan_rsli::EntryId(0)).map(|_| 0),
|
||||||
|
};
|
||||||
|
Ok(local.map(|local| EntryHandle {
|
||||||
|
archive,
|
||||||
|
generation: slot.generation,
|
||||||
|
local,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read(&self, entry: EntryHandle) -> Result<ResourceBytes, ResourceError> {
|
fn read(&self, entry: EntryHandle) -> Result<ResourceBytes, ResourceError> {
|
||||||
|
let task = {
|
||||||
|
let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
||||||
|
if let Some(bytes) = state.payload_cache.get(entry) {
|
||||||
|
return Ok(ResourceBytes::Shared(bytes));
|
||||||
|
}
|
||||||
|
state.payload_decode_task(entry)?
|
||||||
|
};
|
||||||
|
let payload =
|
||||||
|
task.document
|
||||||
|
.read_payload(entry.local)
|
||||||
|
.map_err(|source| ResourceError::EntryRead {
|
||||||
|
key: task.key,
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
let shared = Arc::from(payload.into_boxed_slice());
|
||||||
|
|
||||||
let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
||||||
if let Some(bytes) = state.payload_cache.get(entry) {
|
if let Some(bytes) = state.payload_cache.get(entry) {
|
||||||
return Ok(ResourceBytes::Shared(bytes));
|
return Ok(ResourceBytes::Shared(bytes));
|
||||||
}
|
}
|
||||||
|
state.entry_archive(entry)?;
|
||||||
let payload = {
|
|
||||||
let slot = state.archive(entry.archive)?;
|
|
||||||
let key = slot.entry_key(entry.local)?;
|
|
||||||
slot.read_payload(entry.local)
|
|
||||||
.map_err(|source| ResourceError::EntryRead {
|
|
||||||
key: key.clone(),
|
|
||||||
source,
|
|
||||||
})?
|
|
||||||
};
|
|
||||||
let shared = Arc::from(payload.into_boxed_slice());
|
|
||||||
state.payload_cache.insert(entry, Arc::clone(&shared));
|
state.payload_cache.insert(entry, Arc::clone(&shared));
|
||||||
Ok(ResourceBytes::Shared(shared))
|
Ok(ResourceBytes::Shared(shared))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn entry_info(&self, entry: EntryHandle) -> Result<ResourceEntryInfo, ResourceError> {
|
fn entry_info(&self, entry: EntryHandle) -> Result<ResourceEntryInfo, ResourceError> {
|
||||||
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
||||||
let slot = state.archive(entry.archive)?;
|
let slot = state.entry_archive(entry)?;
|
||||||
match &slot.document {
|
match slot.document.as_ref() {
|
||||||
ArchiveDocument::Nres(document) => {
|
ArchiveDocument::Nres(document) => {
|
||||||
let local =
|
let local =
|
||||||
usize::try_from(entry.local).map_err(|_| ResourceError::InvalidHandle)?;
|
usize::try_from(entry.local).map_err(|_| ResourceError::InvalidHandle)?;
|
||||||
@@ -348,7 +445,7 @@ impl CachedResourceRepository {
|
|||||||
fn cached_id(
|
fn cached_id(
|
||||||
&self,
|
&self,
|
||||||
path: &NormalizedPath,
|
path: &NormalizedPath,
|
||||||
fingerprint: u64,
|
fingerprint: Sha256Digest,
|
||||||
) -> Result<Option<ArchiveId>, ResourceError> {
|
) -> Result<Option<ArchiveId>, ResourceError> {
|
||||||
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
||||||
let Some(id) = state.paths.get(path.as_str()).copied() else {
|
let Some(id) = state.paths.get(path.as_str()).copied() else {
|
||||||
@@ -363,9 +460,11 @@ impl CachedResourceRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DecodedPayloadCache {
|
impl DecodedPayloadCache {
|
||||||
fn new(max_entries: usize) -> Self {
|
fn new(limits: PayloadCacheLimits) -> Self {
|
||||||
Self {
|
Self {
|
||||||
max_entries,
|
max_entries: limits.max_entries,
|
||||||
|
max_bytes: limits.max_bytes,
|
||||||
|
current_bytes: 0,
|
||||||
generation: 0,
|
generation: 0,
|
||||||
entries: BTreeMap::new(),
|
entries: BTreeMap::new(),
|
||||||
}
|
}
|
||||||
@@ -379,18 +478,39 @@ impl DecodedPayloadCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn insert(&mut self, handle: EntryHandle, bytes: Arc<[u8]>) {
|
fn insert(&mut self, handle: EntryHandle, bytes: Arc<[u8]>) {
|
||||||
if self.max_entries == 0 {
|
let len = bytes.len();
|
||||||
|
if self.max_entries == 0 || len > self.max_bytes {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.generation = self.generation.saturating_add(1);
|
self.generation = self.generation.saturating_add(1);
|
||||||
self.entries.insert(
|
if let Some(previous) = self.entries.insert(
|
||||||
handle,
|
handle,
|
||||||
PayloadCacheEntry {
|
PayloadCacheEntry {
|
||||||
bytes,
|
bytes,
|
||||||
last_access: self.generation,
|
last_access: self.generation,
|
||||||
},
|
},
|
||||||
);
|
) {
|
||||||
while self.entries.len() > self.max_entries {
|
self.current_bytes = self.current_bytes.saturating_sub(previous.bytes.len());
|
||||||
|
}
|
||||||
|
self.current_bytes = self.current_bytes.saturating_add(len);
|
||||||
|
self.evict_until_within_budget();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_archive(&mut self, archive: ArchiveId) {
|
||||||
|
let mut removed_bytes = 0usize;
|
||||||
|
self.entries.retain(|handle, entry| {
|
||||||
|
if handle.archive == archive {
|
||||||
|
removed_bytes = removed_bytes.saturating_add(entry.bytes.len());
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.current_bytes = self.current_bytes.saturating_sub(removed_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evict_until_within_budget(&mut self) {
|
||||||
|
while self.entries.len() > self.max_entries || self.current_bytes > self.max_bytes {
|
||||||
let Some(victim) = self
|
let Some(victim) = self
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
@@ -399,13 +519,11 @@ impl DecodedPayloadCache {
|
|||||||
else {
|
else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
self.entries.remove(&victim);
|
if let Some(removed) = self.entries.remove(&victim) {
|
||||||
|
self.current_bytes = self.current_bytes.saturating_sub(removed.bytes.len());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_archive(&mut self, archive: ArchiveId) {
|
|
||||||
self.entries.retain(|handle, _| handle.archive != archive);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RepositoryState {
|
impl RepositoryState {
|
||||||
@@ -420,11 +538,27 @@ impl RepositoryState {
|
|||||||
.get_mut(index)
|
.get_mut(index)
|
||||||
.ok_or(ResourceError::InvalidHandle)
|
.ok_or(ResourceError::InvalidHandle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn entry_archive(&self, entry: EntryHandle) -> Result<&ArchiveSlot, ResourceError> {
|
||||||
|
let slot = self.archive(entry.archive)?;
|
||||||
|
if slot.generation != entry.generation {
|
||||||
|
return Err(ResourceError::StaleHandle);
|
||||||
|
}
|
||||||
|
Ok(slot)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_decode_task(&self, entry: EntryHandle) -> Result<PayloadDecodeTask, ResourceError> {
|
||||||
|
let slot = self.entry_archive(entry)?;
|
||||||
|
Ok(PayloadDecodeTask {
|
||||||
|
document: Arc::clone(&slot.document),
|
||||||
|
key: slot.entry_key(entry.local)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ArchiveSlot {
|
impl ArchiveSlot {
|
||||||
fn entry_key(&self, local: u32) -> Result<ResourceKey, ResourceError> {
|
fn entry_key(&self, local: u32) -> Result<ResourceKey, ResourceError> {
|
||||||
match &self.document {
|
match self.document.as_ref() {
|
||||||
ArchiveDocument::Nres(document) => {
|
ArchiveDocument::Nres(document) => {
|
||||||
let local = usize::try_from(local).map_err(|_| ResourceError::InvalidHandle)?;
|
let local = usize::try_from(local).map_err(|_| ResourceError::InvalidHandle)?;
|
||||||
let entry = document
|
let entry = document
|
||||||
@@ -449,9 +583,11 @@ impl ArchiveSlot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArchiveDocument {
|
||||||
fn read_payload(&self, local: u32) -> Result<Vec<u8>, String> {
|
fn read_payload(&self, local: u32) -> Result<Vec<u8>, String> {
|
||||||
match &self.document {
|
match self {
|
||||||
ArchiveDocument::Nres(document) => document
|
ArchiveDocument::Nres(document) => document
|
||||||
.payload(fparkan_nres::EntryId(local))
|
.payload(fparkan_nres::EntryId(local))
|
||||||
.map(<[u8]>::to_vec)
|
.map(<[u8]>::to_vec)
|
||||||
@@ -466,7 +602,7 @@ impl ArchiveSlot {
|
|||||||
fn decode_archive(
|
fn decode_archive(
|
||||||
path: NormalizedPath,
|
path: NormalizedPath,
|
||||||
bytes: Arc<[u8]>,
|
bytes: Arc<[u8]>,
|
||||||
fingerprint: u64,
|
fingerprint: Sha256Digest,
|
||||||
) -> Result<ArchiveSlot, ResourceError> {
|
) -> Result<ArchiveSlot, ResourceError> {
|
||||||
if bytes.starts_with(b"NRes") {
|
if bytes.starts_with(b"NRes") {
|
||||||
let document = fparkan_nres::decode(bytes, fparkan_nres::ReadProfile::Compatible)
|
let document = fparkan_nres::decode(bytes, fparkan_nres::ReadProfile::Compatible)
|
||||||
@@ -474,8 +610,9 @@ fn decode_archive(
|
|||||||
return Ok(ArchiveSlot {
|
return Ok(ArchiveSlot {
|
||||||
path,
|
path,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
|
generation: 0,
|
||||||
kind: ArchiveKind::Nres,
|
kind: ArchiveKind::Nres,
|
||||||
document: ArchiveDocument::Nres(document),
|
document: Arc::new(ArchiveDocument::Nres(document)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if bytes.get(0..4) == Some(b"NL\0\x01") {
|
if bytes.get(0..4) == Some(b"NL\0\x01") {
|
||||||
@@ -484,8 +621,9 @@ fn decode_archive(
|
|||||||
return Ok(ArchiveSlot {
|
return Ok(ArchiveSlot {
|
||||||
path,
|
path,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
|
generation: 0,
|
||||||
kind: ArchiveKind::Rsli,
|
kind: ArchiveKind::Rsli,
|
||||||
document: ArchiveDocument::Rsli(document),
|
document: Arc::new(ArchiveDocument::Rsli(document)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(ResourceError::Format(
|
Err(ResourceError::Format(
|
||||||
@@ -528,7 +666,7 @@ fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use fparkan_vfs::{DirectoryVfs, MemoryVfs};
|
use fparkan_vfs::{DirectoryVfs, MemoryVfs};
|
||||||
use std::path::Path;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cached_repository_reads_synthetic_nres() {
|
fn cached_repository_reads_synthetic_nres() {
|
||||||
@@ -554,6 +692,7 @@ mod tests {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
repo.read(EntryHandle {
|
repo.read(EntryHandle {
|
||||||
archive: ArchiveId(99),
|
archive: ArchiveId(99),
|
||||||
|
generation: 0,
|
||||||
local: 0
|
local: 0
|
||||||
}),
|
}),
|
||||||
Err(ResourceError::InvalidHandle)
|
Err(ResourceError::InvalidHandle)
|
||||||
@@ -632,6 +771,77 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoded_payload_cache_evicts_by_byte_budget() {
|
||||||
|
let path = archive_path(b"cache/bytes.lib").expect("path");
|
||||||
|
let bytes = build_nres(&[
|
||||||
|
("a.bin", b"1234".as_slice()),
|
||||||
|
("b.bin", b"5678".as_slice()),
|
||||||
|
("c.bin", b"90".as_slice()),
|
||||||
|
]);
|
||||||
|
let mut vfs = MemoryVfs::default();
|
||||||
|
vfs.insert(path.clone(), Arc::from(bytes.into_boxed_slice()));
|
||||||
|
let repo = CachedResourceRepository::with_payload_cache_limits(
|
||||||
|
Arc::new(vfs),
|
||||||
|
PayloadCacheLimits {
|
||||||
|
max_entries: 64,
|
||||||
|
max_bytes: 6,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let archive = repo.open_archive(&path).expect("open archive");
|
||||||
|
let first = repo
|
||||||
|
.find(archive, &resource_name(b"a.bin"))
|
||||||
|
.expect("find a")
|
||||||
|
.expect("a");
|
||||||
|
let second = repo
|
||||||
|
.find(archive, &resource_name(b"b.bin"))
|
||||||
|
.expect("find b")
|
||||||
|
.expect("b");
|
||||||
|
let third = repo
|
||||||
|
.find(archive, &resource_name(b"c.bin"))
|
||||||
|
.expect("find c")
|
||||||
|
.expect("c");
|
||||||
|
|
||||||
|
assert_eq!(repo.read(first).expect("read a").as_slice(), b"1234");
|
||||||
|
assert_eq!(repo.read(second).expect("read b").as_slice(), b"5678");
|
||||||
|
assert_eq!(repo.read(third).expect("read c").as_slice(), b"90");
|
||||||
|
|
||||||
|
let state = repo.state.lock().expect("state");
|
||||||
|
assert_eq!(state.payload_cache.current_bytes, 6);
|
||||||
|
assert_eq!(state.payload_cache.entries.len(), 2);
|
||||||
|
assert!(!state.payload_cache.entries.contains_key(&first));
|
||||||
|
assert!(state.payload_cache.entries.contains_key(&second));
|
||||||
|
assert!(state.payload_cache.entries.contains_key(&third));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoded_payload_cache_does_not_store_payload_larger_than_budget() {
|
||||||
|
let path = archive_path(b"cache/oversized.lib").expect("path");
|
||||||
|
let bytes = build_nres(&[("big.bin", b"1234567".as_slice())]);
|
||||||
|
let mut vfs = MemoryVfs::default();
|
||||||
|
vfs.insert(path.clone(), Arc::from(bytes.into_boxed_slice()));
|
||||||
|
let repo = CachedResourceRepository::with_payload_cache_limits(
|
||||||
|
Arc::new(vfs),
|
||||||
|
PayloadCacheLimits {
|
||||||
|
max_entries: 64,
|
||||||
|
max_bytes: 6,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let archive = repo.open_archive(&path).expect("open archive");
|
||||||
|
let handle = repo
|
||||||
|
.find(archive, &resource_name(b"big.bin"))
|
||||||
|
.expect("find big")
|
||||||
|
.expect("big");
|
||||||
|
|
||||||
|
assert_eq!(repo.read(handle).expect("read big").as_slice(), b"1234567");
|
||||||
|
|
||||||
|
let state = repo.state.lock().expect("state");
|
||||||
|
assert_eq!(state.payload_cache.current_bytes, 0);
|
||||||
|
assert!(state.payload_cache.entries.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn archive_cache_invalidates_when_vfs_bytes_change() {
|
fn archive_cache_invalidates_when_vfs_bytes_change() {
|
||||||
let root = temp_dir("archive-invalidate");
|
let root = temp_dir("archive-invalidate");
|
||||||
@@ -652,7 +862,7 @@ mod tests {
|
|||||||
b"before"
|
b"before"
|
||||||
);
|
);
|
||||||
|
|
||||||
std::fs::write(&host_path, build_nres(&[("a.bin", b"after".as_slice())]))
|
std::fs::write(&host_path, build_nres(&[("a.bin", b"after!".as_slice())]))
|
||||||
.expect("updated archive");
|
.expect("updated archive");
|
||||||
let reopened = repo.open_archive(&path).expect("open updated archive");
|
let reopened = repo.open_archive(&path).expect("open updated archive");
|
||||||
let second = repo
|
let second = repo
|
||||||
@@ -661,9 +871,11 @@ mod tests {
|
|||||||
.expect("updated handle");
|
.expect("updated handle");
|
||||||
|
|
||||||
assert_eq!(reopened, archive);
|
assert_eq!(reopened, archive);
|
||||||
|
assert_ne!(first, second);
|
||||||
|
assert!(matches!(repo.read(first), Err(ResourceError::StaleHandle)));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
repo.read(second).expect("read updated").as_slice(),
|
repo.read(second).expect("read updated").as_slice(),
|
||||||
b"after"
|
b"after!"
|
||||||
);
|
);
|
||||||
let _ = std::fs::remove_dir_all(root);
|
let _ = std::fs::remove_dir_all(root);
|
||||||
}
|
}
|
||||||
@@ -696,16 +908,43 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
fn resource_error_display_is_actionable() {
|
||||||
|
let path = archive_path(b"bad/rsli.lib").expect("path");
|
||||||
|
let err = ResourceError::EntryRead {
|
||||||
|
key: ResourceKey {
|
||||||
|
archive: path,
|
||||||
|
name: resource_name(b"BROKEN.TEX"),
|
||||||
|
type_id: None,
|
||||||
|
},
|
||||||
|
source: "unsupported packing method 0x1e0".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
err.to_string(),
|
||||||
|
"failed to read resource -:BROKEN.TEX from bad/rsli.lib: unsupported packing method 0x1e0"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ResourceError::StaleHandle.to_string(),
|
||||||
|
"resource handle belongs to an older archive generation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpora_repository_reads_nres_and_rsli() {
|
fn licensed_corpora_repository_reads_nres_and_rsli() {
|
||||||
licensed_repository_gate("IS").expect("part 1 repository gate");
|
licensed_repository_gate("IS").expect("part 1 repository gate");
|
||||||
licensed_repository_gate("IS2").expect("part 2 repository gate");
|
licensed_repository_gate("IS2").expect("part 2 repository gate");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn licensed_repository_gate(corpus: &str) -> Result<(), String> {
|
fn licensed_repository_gate(corpus: &str) -> Result<(), String> {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
let variable = match corpus {
|
||||||
.join("../..")
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
||||||
.join("testdata")
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
||||||
.join(corpus);
|
_ => return Err(format!("unknown licensed corpus part: {corpus}")),
|
||||||
|
};
|
||||||
|
let root = std::env::var_os(variable)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.ok_or_else(|| format!("{variable} is required for licensed corpus tests"))?;
|
||||||
if !root.is_dir() {
|
if !root.is_dir() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"licensed corpus root is missing: {}",
|
"licensed corpus root is missing: {}",
|
||||||
|
|||||||
+177
-15
@@ -14,6 +14,44 @@ pub enum ReadProfile {
|
|||||||
Compatible,
|
Compatible,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detailed read profile.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum RsliReadProfile {
|
||||||
|
/// Reject compatibility quirks.
|
||||||
|
Strict,
|
||||||
|
/// Accept selected retail compatibility quirks.
|
||||||
|
Compatible(RsliCompatibilityProfile),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ReadProfile> for RsliReadProfile {
|
||||||
|
fn from(value: ReadProfile) -> Self {
|
||||||
|
match value {
|
||||||
|
ReadProfile::Strict => Self::Strict,
|
||||||
|
ReadProfile::Compatible => Self::Compatible(RsliCompatibilityProfile::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RsliReadProfile {
|
||||||
|
/// Strict profile with every compatibility quirk disabled.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn strict() -> Self {
|
||||||
|
Self::Strict
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retail-compatible profile with the default approved quirk set.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn compatible() -> Self {
|
||||||
|
Self::Compatible(RsliCompatibilityProfile::retail())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retail-compatible profile with a caller-provided quirk set.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn compatible_with(profile: RsliCompatibilityProfile) -> Self {
|
||||||
|
Self::Compatible(profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Write profile.
|
/// Write profile.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum WriteProfile {
|
pub enum WriteProfile {
|
||||||
@@ -34,12 +72,30 @@ pub struct RsliCompatibilityProfile {
|
|||||||
|
|
||||||
impl Default for RsliCompatibilityProfile {
|
impl Default for RsliCompatibilityProfile {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
Self::retail()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RsliCompatibilityProfile {
|
||||||
|
/// Retail-compatible profile with every approved quirk enabled.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn retail() -> Self {
|
||||||
Self {
|
Self {
|
||||||
allow_ao_trailer: true,
|
allow_ao_trailer: true,
|
||||||
allow_deflate_eof_plus_one: true,
|
allow_deflate_eof_plus_one: true,
|
||||||
allow_invalid_presorted_fallback: true,
|
allow_invalid_presorted_fallback: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Profile with every compatibility quirk disabled.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn none() -> Self {
|
||||||
|
Self {
|
||||||
|
allow_ao_trailer: false,
|
||||||
|
allow_deflate_eof_plus_one: false,
|
||||||
|
allow_invalid_presorted_fallback: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `RsLi` packing method.
|
/// `RsLi` packing method.
|
||||||
@@ -197,6 +253,11 @@ pub enum RsliError {
|
|||||||
/// Archive byte length.
|
/// Archive byte length.
|
||||||
file_len: u64,
|
file_len: u64,
|
||||||
},
|
},
|
||||||
|
/// Registered `AO` overlay is rejected by the selected profile.
|
||||||
|
AoTrailerQuirkRejected {
|
||||||
|
/// Overlay byte offset.
|
||||||
|
overlay: u32,
|
||||||
|
},
|
||||||
/// Unsupported packing method.
|
/// Unsupported packing method.
|
||||||
UnsupportedMethod {
|
UnsupportedMethod {
|
||||||
/// Raw method bits.
|
/// Raw method bits.
|
||||||
@@ -266,6 +327,9 @@ impl fmt::Display for RsliError {
|
|||||||
"media overlay out of bounds: overlay={overlay}, file={file_len}"
|
"media overlay out of bounds: overlay={overlay}, file={file_len}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Self::AoTrailerQuirkRejected { overlay } => {
|
||||||
|
write!(f, "AO trailer quirk rejected: overlay={overlay}")
|
||||||
|
}
|
||||||
Self::UnsupportedMethod { raw } => write!(f, "unsupported packing method: {raw:#x}"),
|
Self::UnsupportedMethod { raw } => write!(f, "unsupported packing method: {raw:#x}"),
|
||||||
Self::PackedSizePastEof {
|
Self::PackedSizePastEof {
|
||||||
id,
|
id,
|
||||||
@@ -298,20 +362,31 @@ impl std::error::Error for RsliError {}
|
|||||||
/// compatibility quirks, or packed payloads are invalid for the selected
|
/// compatibility quirks, or packed payloads are invalid for the selected
|
||||||
/// profile.
|
/// profile.
|
||||||
pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<RsliDocument, RsliError> {
|
pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<RsliDocument, RsliError> {
|
||||||
|
decode_with_profile(bytes, profile.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes an `RsLi` document with explicit compatibility switches.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`RsliError`] when the header, table, payload ranges, registered
|
||||||
|
/// compatibility quirks, or packed payloads are invalid for the selected
|
||||||
|
/// profile.
|
||||||
|
pub fn decode_with_profile(
|
||||||
|
bytes: Arc<[u8]>,
|
||||||
|
profile: RsliReadProfile,
|
||||||
|
) -> Result<RsliDocument, RsliError> {
|
||||||
let options = match profile {
|
let options = match profile {
|
||||||
ReadProfile::Strict => ParseOptions {
|
RsliReadProfile::Strict => ParseOptions {
|
||||||
allow_ao_trailer: false,
|
allow_ao_trailer: false,
|
||||||
allow_deflate_eof_plus_one: false,
|
allow_deflate_eof_plus_one: false,
|
||||||
allow_invalid_presorted_fallback: false,
|
allow_invalid_presorted_fallback: false,
|
||||||
},
|
},
|
||||||
ReadProfile::Compatible => {
|
RsliReadProfile::Compatible(profile) => ParseOptions {
|
||||||
let profile = RsliCompatibilityProfile::default();
|
allow_ao_trailer: profile.allow_ao_trailer,
|
||||||
ParseOptions {
|
allow_deflate_eof_plus_one: profile.allow_deflate_eof_plus_one,
|
||||||
allow_ao_trailer: profile.allow_ao_trailer,
|
allow_invalid_presorted_fallback: profile.allow_invalid_presorted_fallback,
|
||||||
allow_deflate_eof_plus_one: profile.allow_deflate_eof_plus_one,
|
},
|
||||||
allow_invalid_presorted_fallback: profile.allow_invalid_presorted_fallback,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let ParsedRsli {
|
let ParsedRsli {
|
||||||
header,
|
header,
|
||||||
@@ -691,7 +766,7 @@ fn rebuild_sorted_mapping(records: &mut [EntryRecord]) -> Result<(), RsliError>
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_ao_trailer(bytes: &[u8], allow: bool) -> Result<(u32, Option<[u8; 6]>), RsliError> {
|
fn parse_ao_trailer(bytes: &[u8], allow: bool) -> Result<(u32, Option<[u8; 6]>), RsliError> {
|
||||||
if !allow || bytes.len() < 6 || &bytes[bytes.len() - 6..bytes.len() - 4] != b"AO" {
|
if bytes.len() < 6 || &bytes[bytes.len() - 6..bytes.len() - 4] != b"AO" {
|
||||||
return Ok((0, None));
|
return Ok((0, None));
|
||||||
}
|
}
|
||||||
let mut raw = [0u8; 6];
|
let mut raw = [0u8; 6];
|
||||||
@@ -703,6 +778,9 @@ fn parse_ao_trailer(bytes: &[u8], allow: bool) -> Result<(u32, Option<[u8; 6]>),
|
|||||||
file_len: u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?,
|
file_len: u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if !allow {
|
||||||
|
return Err(RsliError::AoTrailerQuirkRejected { overlay });
|
||||||
|
}
|
||||||
Ok((overlay, Some(raw)))
|
Ok((overlay, Some(raw)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1330,6 +1408,39 @@ mod tests {
|
|||||||
assert_eq!(doc.find("B"), Some(EntryId(0)));
|
assert_eq!(doc.find("B"), Some(EntryId(0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_profile_controls_invalid_presorted_fallback() {
|
||||||
|
let bytes = synthetic_rsli(
|
||||||
|
&[
|
||||||
|
SyntheticEntry::stored(b"B", 0, b"bee"),
|
||||||
|
SyntheticEntry::stored(b"A", 0, b"aye"),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
0x0102,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let profile = RsliCompatibilityProfile {
|
||||||
|
allow_invalid_presorted_fallback: false,
|
||||||
|
..RsliCompatibilityProfile::retail()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
decode_with_profile(
|
||||||
|
arc(bytes.clone()),
|
||||||
|
RsliReadProfile::compatible_with(profile)
|
||||||
|
),
|
||||||
|
Err(RsliError::CorruptEntryTable(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
let profile = RsliCompatibilityProfile {
|
||||||
|
allow_invalid_presorted_fallback: true,
|
||||||
|
..RsliCompatibilityProfile::none()
|
||||||
|
};
|
||||||
|
let doc = decode_with_profile(arc(bytes), RsliReadProfile::compatible_with(profile))
|
||||||
|
.expect("presorted fallback only");
|
||||||
|
assert_eq!(doc.find("A"), Some(EntryId(1)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stored_method_uses_exact_size() {
|
fn stored_method_uses_exact_size() {
|
||||||
let bytes = synthetic_rsli(
|
let bytes = synthetic_rsli(
|
||||||
@@ -1510,6 +1621,16 @@ mod tests {
|
|||||||
decode(arc(approved.clone()), ReadProfile::Strict),
|
decode(arc(approved.clone()), ReadProfile::Strict),
|
||||||
Err(RsliError::DeflateEofPlusOneQuirkRejected { id: 0 })
|
Err(RsliError::DeflateEofPlusOneQuirkRejected { id: 0 })
|
||||||
));
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
decode_with_profile(
|
||||||
|
arc(approved.clone()),
|
||||||
|
RsliReadProfile::compatible_with(RsliCompatibilityProfile {
|
||||||
|
allow_deflate_eof_plus_one: false,
|
||||||
|
..RsliCompatibilityProfile::retail()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
Err(RsliError::DeflateEofPlusOneQuirkRejected { id: 0 })
|
||||||
|
));
|
||||||
let doc = decode(arc(approved), ReadProfile::Compatible).expect("approved EOF+1 quirk");
|
let doc = decode(arc(approved), ReadProfile::Compatible).expect("approved EOF+1 quirk");
|
||||||
assert_eq!(doc.load(EntryId(0)).expect("approved payload"), b"raw");
|
assert_eq!(doc.load(EntryId(0)).expect("approved payload"), b"raw");
|
||||||
|
|
||||||
@@ -1606,11 +1727,22 @@ mod tests {
|
|||||||
Some(4),
|
Some(4),
|
||||||
);
|
);
|
||||||
|
|
||||||
let doc = decode(arc(bytes), ReadProfile::Compatible).expect("AO overlay");
|
let doc = decode(arc(bytes.clone()), ReadProfile::Compatible).expect("AO overlay");
|
||||||
let meta = doc.entry(EntryId(0)).expect("AO meta");
|
let meta = doc.entry(EntryId(0)).expect("AO meta");
|
||||||
assert_eq!(meta.data_offset, 64);
|
assert_eq!(meta.data_offset, 64);
|
||||||
assert_eq!(meta.data_offset_raw, 60);
|
assert_eq!(meta.data_offset_raw, 60);
|
||||||
assert_eq!(doc.load(EntryId(0)).expect("AO payload"), b"media");
|
assert_eq!(doc.load(EntryId(0)).expect("AO payload"), b"media");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
decode_with_profile(
|
||||||
|
arc(bytes),
|
||||||
|
RsliReadProfile::compatible_with(RsliCompatibilityProfile {
|
||||||
|
allow_ao_trailer: false,
|
||||||
|
..RsliCompatibilityProfile::retail()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
Err(RsliError::AoTrailerQuirkRejected { overlay: 4 })
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1625,6 +1757,28 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_profile_distinguishes_valid_ao_quirk_from_malformed_ao() {
|
||||||
|
let valid = synthetic_rsli(
|
||||||
|
&[SyntheticEntry::stored(b"A", 0, b"media")],
|
||||||
|
true,
|
||||||
|
0x3333,
|
||||||
|
Some(4),
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
decode_with_profile(arc(valid), RsliReadProfile::strict()),
|
||||||
|
Err(RsliError::AoTrailerQuirkRejected { overlay: 4 })
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut malformed = synthetic_rsli(&[], false, 0, None);
|
||||||
|
malformed.extend_from_slice(b"AO");
|
||||||
|
malformed.extend_from_slice(&1000u32.to_le_bytes());
|
||||||
|
assert!(matches!(
|
||||||
|
decode_with_profile(arc(malformed), RsliReadProfile::strict()),
|
||||||
|
Err(RsliError::MediaOverlayOutOfBounds { overlay: 1000, .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_header_bytes_are_lossless() {
|
fn unknown_header_bytes_are_lossless() {
|
||||||
let mut bytes = synthetic_rsli(
|
let mut bytes = synthetic_rsli(
|
||||||
@@ -1742,6 +1896,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpora_rsli_roundtrip_gates() {
|
fn licensed_corpora_rsli_roundtrip_gates() {
|
||||||
let part1 = corpus_gate("IS", 2).expect("part 1 RsLi gate");
|
let part1 = corpus_gate("IS", 2).expect("part 1 RsLi gate");
|
||||||
let part2 = corpus_gate("IS2", 2).expect("part 2 RsLi gate");
|
let part2 = corpus_gate("IS2", 2).expect("part 2 RsLi gate");
|
||||||
@@ -1751,6 +1906,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_part1_rsli_method_distribution_baseline() {
|
fn licensed_part1_rsli_method_distribution_baseline() {
|
||||||
let stats = corpus_gate("IS", 2).expect("part 1 RsLi gate");
|
let stats = corpus_gate("IS", 2).expect("part 1 RsLi gate");
|
||||||
|
|
||||||
@@ -1770,6 +1926,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_part2_rsli_method_distribution_baseline() {
|
fn licensed_part2_rsli_method_distribution_baseline() {
|
||||||
let stats = corpus_gate("IS2", 2).expect("part 2 RsLi gate");
|
let stats = corpus_gate("IS2", 2).expect("part 2 RsLi gate");
|
||||||
|
|
||||||
@@ -1789,6 +1946,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpora_rsli_quirk_is_only_approved_interf8_tex() {
|
fn licensed_corpora_rsli_quirk_is_only_approved_interf8_tex() {
|
||||||
let part1 = corpus_gate("IS", 2).expect("part 1 RsLi gate");
|
let part1 = corpus_gate("IS", 2).expect("part 1 RsLi gate");
|
||||||
let part2 = corpus_gate("IS2", 2).expect("part 2 RsLi gate");
|
let part2 = corpus_gate("IS2", 2).expect("part 2 RsLi gate");
|
||||||
@@ -1908,10 +2066,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn corpus_files(name: &str) -> Result<Vec<PathBuf>, String> {
|
fn corpus_files(name: &str) -> Result<Vec<PathBuf>, String> {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
let variable = match name {
|
||||||
.join("../..")
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
||||||
.join("testdata")
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
||||||
.join(name);
|
_ => return Err(format!("unknown licensed corpus part: {name}")),
|
||||||
|
};
|
||||||
|
let root = std::env::var_os(variable)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.ok_or_else(|| format!("{variable} is required for licensed corpus tests"))?;
|
||||||
if !root.is_dir() {
|
if !root.is_dir() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"licensed corpus root is missing: {}",
|
"licensed corpus root is missing: {}",
|
||||||
|
|||||||
@@ -695,8 +695,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn load_trace_records_preparation_before_registration_and_raw_transforms() {
|
fn load_trace_records_preparation_before_registration_and_raw_transforms() {
|
||||||
let root = workspace_root().join("testdata").join("IS");
|
let root = licensed_root("IS");
|
||||||
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
|
||||||
let mut engine = create(
|
let mut engine = create(
|
||||||
EngineConfig {
|
EngineConfig {
|
||||||
@@ -736,8 +737,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn missing_map_and_missing_reachable_resource_fail_before_registration() {
|
fn missing_map_and_missing_reachable_resource_fail_before_registration() {
|
||||||
let root = workspace_root().join("testdata").join("IS");
|
let root = licensed_root("IS");
|
||||||
for (denied, mission) in [
|
for (denied, mission) in [
|
||||||
(
|
(
|
||||||
DenyRule::Suffix("Land.map"),
|
DenyRule::Suffix("Land.map"),
|
||||||
@@ -779,8 +781,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn registration_phase_failure_uses_normal_teardown_and_keeps_engine_world() {
|
fn registration_phase_failure_uses_normal_teardown_and_keeps_engine_world() {
|
||||||
let root = workspace_root().join("testdata").join("IS");
|
let root = licensed_root("IS");
|
||||||
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
|
||||||
let mut engine = create(
|
let mut engine = create(
|
||||||
EngineConfig {
|
EngineConfig {
|
||||||
@@ -816,6 +819,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn selected_is_and_is2_missions_execute_10000_deterministic_ticks() {
|
fn selected_is_and_is2_missions_execute_10000_deterministic_ticks() {
|
||||||
for case in [
|
for case in [
|
||||||
HeadlessCase {
|
HeadlessCase {
|
||||||
@@ -823,9 +827,9 @@ mod tests {
|
|||||||
mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma",
|
mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma",
|
||||||
object_count: 33,
|
object_count: 33,
|
||||||
expected_hash: [
|
expected_hash: [
|
||||||
0x19, 0xdc, 0xd3, 0x9b, 0x35, 0xad, 0x90, 0x6c, 0x92, 0x2d, 0x83, 0x7b, 0x7a,
|
0xc7, 0xb0, 0x6e, 0x0a, 0x31, 0x1f, 0x5d, 0x8c, 0xde, 0x64, 0xa5, 0x33, 0x1f,
|
||||||
0xb3, 0xa6, 0x15, 0xa6, 0x15, 0x92, 0x2d, 0x83, 0x7b, 0x7a, 0xb3, 0xe9, 0xcd,
|
0x2c, 0xd0, 0x2c, 0x21, 0x44, 0x2f, 0x34, 0x5d, 0x16, 0xe8, 0x94, 0xaf, 0xa2,
|
||||||
0x9a, 0x56, 0x48, 0xb6, 0x0c, 0xee,
|
0x2b, 0xa9, 0xd4, 0x24, 0xd2, 0xf9,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
HeadlessCase {
|
HeadlessCase {
|
||||||
@@ -833,9 +837,9 @@ mod tests {
|
|||||||
mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
|
mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
|
||||||
object_count: 10,
|
object_count: 10,
|
||||||
expected_hash: [
|
expected_hash: [
|
||||||
0x59, 0x6e, 0x88, 0xcc, 0xd0, 0x3a, 0xd9, 0x68, 0x1b, 0x2d, 0xcb, 0x0d, 0x91,
|
0x3c, 0xe5, 0xa6, 0x39, 0x47, 0x86, 0x76, 0xe1, 0xb2, 0x1a, 0x8e, 0x96, 0x3d,
|
||||||
0x19, 0x5a, 0x27, 0x5a, 0x27, 0x1b, 0x2d, 0xcb, 0x0d, 0x91, 0x19, 0x44, 0x66,
|
0x60, 0x6e, 0xc6, 0x8c, 0xe2, 0x28, 0x4f, 0x57, 0xd9, 0xe1, 0xe4, 0xb5, 0x95,
|
||||||
0x68, 0x9d, 0x6c, 0xb4, 0x2c, 0x37,
|
0xdf, 0x88, 0xd3, 0x2f, 0x4a, 0x4d,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
] {
|
] {
|
||||||
@@ -849,9 +853,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpora_load_all_mission_foundations() {
|
fn licensed_corpora_load_all_mission_foundations() {
|
||||||
let root = workspace_root();
|
let part1 = load_all(&licensed_root("IS"));
|
||||||
let part1 = load_all(&root.join("testdata").join("IS"));
|
|
||||||
assert_eq!(part1.missions, 29);
|
assert_eq!(part1.missions, 29);
|
||||||
assert_eq!(part1.paths, 34);
|
assert_eq!(part1.paths, 34);
|
||||||
assert_eq!(part1.clans, 101);
|
assert_eq!(part1.clans, 101);
|
||||||
@@ -871,7 +875,7 @@ mod tests {
|
|||||||
assert_eq!(part1.texture_requests, part1.texture_resolved);
|
assert_eq!(part1.texture_requests, part1.texture_resolved);
|
||||||
assert_eq!(part1.lightmap_requests, part1.lightmap_resolved);
|
assert_eq!(part1.lightmap_requests, part1.lightmap_resolved);
|
||||||
|
|
||||||
let part2 = load_all(&root.join("testdata").join("IS2"));
|
let part2 = load_all(&licensed_root("IS2"));
|
||||||
assert_eq!(part2.missions, 31);
|
assert_eq!(part2.missions, 31);
|
||||||
assert_eq!(part2.paths, 61);
|
assert_eq!(part2.paths, 61);
|
||||||
assert_eq!(part2.clans, 91);
|
assert_eq!(part2.clans, 91);
|
||||||
@@ -923,7 +927,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_headless_case(case: HeadlessCase) -> WorldSnapshot {
|
fn run_headless_case(case: HeadlessCase) -> WorldSnapshot {
|
||||||
let root = workspace_root().join("testdata").join(case.root);
|
let root = licensed_root(case.root);
|
||||||
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
|
||||||
let mut engine = create(
|
let mut engine = create(
|
||||||
EngineConfig {
|
EngineConfig {
|
||||||
@@ -1043,12 +1047,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_root() -> PathBuf {
|
fn licensed_root(name: &str) -> PathBuf {
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
let variable = match name {
|
||||||
.parent()
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
||||||
.and_then(Path::parent)
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
||||||
.expect("workspace root")
|
_ => panic!("unknown licensed corpus part: {name}"),
|
||||||
.to_path_buf()
|
};
|
||||||
|
let root = std::env::var_os(variable)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
|
||||||
|
assert!(
|
||||||
|
root.is_dir(),
|
||||||
|
"licensed corpus root is missing: {}",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
root
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
|
|||||||
@@ -1488,14 +1488,13 @@ Generator 1
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpus_land_msh_validate() {
|
fn licensed_corpus_land_msh_validate() {
|
||||||
for (corpus, expected_files, expected_vertices, expected_faces) in [
|
for (corpus, expected_files, expected_vertices, expected_faces) in [
|
||||||
("IS", 33_usize, 299_450_usize, 275_882_usize),
|
("IS", 33_usize, 299_450_usize, 275_882_usize),
|
||||||
("IS2", 32_usize, 188_024_usize, 184_454_usize),
|
("IS2", 32_usize, 188_024_usize, 184_454_usize),
|
||||||
] {
|
] {
|
||||||
let Some(root) = corpus_root(corpus) else {
|
let root = corpus_root(corpus);
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut files = 0usize;
|
let mut files = 0usize;
|
||||||
let mut vertices = 0usize;
|
let mut vertices = 0usize;
|
||||||
let mut faces = 0usize;
|
let mut faces = 0usize;
|
||||||
@@ -1536,11 +1535,10 @@ Generator 1
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpus_build_dat_validate() {
|
fn licensed_corpus_build_dat_validate() {
|
||||||
for (corpus, expected_ai_prefix) in [("IS", false), ("IS2", true)] {
|
for (corpus, expected_ai_prefix) in [("IS", false), ("IS2", true)] {
|
||||||
let Some(root) = corpus_root(corpus) else {
|
let root = corpus_root(corpus);
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let path = root.join("BuildDat.lst");
|
let path = root.join("BuildDat.lst");
|
||||||
let bytes = std::fs::read(&path).expect("read BuildDat.lst");
|
let bytes = std::fs::read(&path).expect("read BuildDat.lst");
|
||||||
let categories =
|
let categories =
|
||||||
@@ -1583,14 +1581,13 @@ Generator 1
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpus_land_map_validate() {
|
fn licensed_corpus_land_map_validate() {
|
||||||
for (corpus, expected_files, expected_areals, expected_vertices, expected_max_hits) in [
|
for (corpus, expected_files, expected_areals, expected_vertices, expected_max_hits) in [
|
||||||
("IS", 33_usize, 34_662_usize, 197_698_usize, 20_usize),
|
("IS", 33_usize, 34_662_usize, 197_698_usize, 20_usize),
|
||||||
("IS2", 32_usize, 18_984_usize, 114_968_usize, 14_usize),
|
("IS2", 32_usize, 18_984_usize, 114_968_usize, 14_usize),
|
||||||
] {
|
] {
|
||||||
let Some(root) = corpus_root(corpus) else {
|
let root = corpus_root(corpus);
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut files = 0usize;
|
let mut files = 0usize;
|
||||||
let mut areals = 0usize;
|
let mut areals = 0usize;
|
||||||
let mut vertices = 0usize;
|
let mut vertices = 0usize;
|
||||||
@@ -1880,12 +1877,21 @@ Generator 1
|
|||||||
out.extend_from_slice(&value.to_le_bytes());
|
out.extend_from_slice(&value.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn corpus_root(name: &str) -> Option<PathBuf> {
|
fn corpus_root(name: &str) -> PathBuf {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
let variable = match name {
|
||||||
.join("../..")
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
||||||
.join("testdata")
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
||||||
.join(name);
|
_ => panic!("unknown licensed corpus part: {name}"),
|
||||||
root.is_dir().then_some(root)
|
};
|
||||||
|
let root = std::env::var_os(variable)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
|
||||||
|
assert!(
|
||||||
|
root.is_dir(),
|
||||||
|
"licensed corpus root is missing: {}",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
root
|
||||||
}
|
}
|
||||||
|
|
||||||
fn files_under(root: &Path) -> Vec<PathBuf> {
|
fn files_under(root: &Path) -> Vec<PathBuf> {
|
||||||
|
|||||||
@@ -794,14 +794,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpus_land_maps_build_navigation_worlds() {
|
fn licensed_corpus_land_maps_build_navigation_worlds() {
|
||||||
for (corpus, expected_files, expected_areals) in [
|
for (corpus, expected_files, expected_areals) in [
|
||||||
("IS", 33_usize, 34_662_usize),
|
("IS", 33_usize, 34_662_usize),
|
||||||
("IS2", 32_usize, 18_984_usize),
|
("IS2", 32_usize, 18_984_usize),
|
||||||
] {
|
] {
|
||||||
let Some(root) = corpus_root(corpus) else {
|
let root = corpus_root(corpus);
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut files = 0usize;
|
let mut files = 0usize;
|
||||||
let mut areals = 0usize;
|
let mut areals = 0usize;
|
||||||
let mut located_centers = 0usize;
|
let mut located_centers = 0usize;
|
||||||
@@ -849,14 +848,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpus_land_meshes_build_surface_worlds() {
|
fn licensed_corpus_land_meshes_build_surface_worlds() {
|
||||||
for (corpus, expected_files, expected_faces) in [
|
for (corpus, expected_files, expected_faces) in [
|
||||||
("IS", 33_usize, 275_882_usize),
|
("IS", 33_usize, 275_882_usize),
|
||||||
("IS2", 32_usize, 184_454_usize),
|
("IS2", 32_usize, 184_454_usize),
|
||||||
] {
|
] {
|
||||||
let Some(root) = corpus_root(corpus) else {
|
let root = corpus_root(corpus);
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut files = 0usize;
|
let mut files = 0usize;
|
||||||
let mut faces = 0usize;
|
let mut faces = 0usize;
|
||||||
for path in files_under(&root) {
|
for path in files_under(&root) {
|
||||||
@@ -1049,12 +1047,21 @@ mod tests {
|
|||||||
vertices.first().copied()
|
vertices.first().copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn corpus_root(name: &str) -> Option<PathBuf> {
|
fn corpus_root(name: &str) -> PathBuf {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
let variable = match name {
|
||||||
.join("../..")
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
||||||
.join("testdata")
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
||||||
.join(name);
|
_ => panic!("unknown licensed corpus part: {name}"),
|
||||||
root.is_dir().then_some(root)
|
};
|
||||||
|
let root = std::env::var_os(variable)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
|
||||||
|
assert!(
|
||||||
|
root.is_dir(),
|
||||||
|
"licensed corpus root is missing: {}",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
root
|
||||||
}
|
}
|
||||||
|
|
||||||
fn files_under(root: &Path) -> Vec<PathBuf> {
|
fn files_under(root: &Path) -> Vec<PathBuf> {
|
||||||
|
|||||||
@@ -1071,11 +1071,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpus_texm_assets_validate_and_decode_mip0() {
|
fn licensed_corpus_texm_assets_validate_and_decode_mip0() {
|
||||||
for (corpus, expected) in [("IS", 518_usize), ("IS2", 631_usize)] {
|
for (corpus, expected) in [("IS", 518_usize), ("IS2", 631_usize)] {
|
||||||
let Some(root) = corpus_root(corpus) else {
|
let root = corpus_root(corpus);
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut count = 0usize;
|
let mut count = 0usize;
|
||||||
for path in files_under(&root) {
|
for path in files_under(&root) {
|
||||||
let Ok(bytes) = std::fs::read(&path) else {
|
let Ok(bytes) = std::fs::read(&path) else {
|
||||||
@@ -1157,12 +1156,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn corpus_root(name: &str) -> Option<PathBuf> {
|
fn corpus_root(name: &str) -> PathBuf {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
let variable = match name {
|
||||||
.join("../..")
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
||||||
.join("testdata")
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
||||||
.join(name);
|
_ => panic!("unknown licensed corpus part: {name}"),
|
||||||
root.is_dir().then_some(root)
|
};
|
||||||
|
let root = std::env::var_os(variable)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
|
||||||
|
assert!(
|
||||||
|
root.is_dir(),
|
||||||
|
"licensed corpus root is missing: {}",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
root
|
||||||
}
|
}
|
||||||
|
|
||||||
fn files_under(root: &Path) -> Vec<PathBuf> {
|
fn files_under(root: &Path) -> Vec<PathBuf> {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
fparkan-path = { path = "../fparkan-path" }
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
|
|||||||
+211
-52
@@ -1,19 +1,21 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
//! Virtual filesystem ports for resource loading.
|
//! Virtual filesystem ports for resource loading.
|
||||||
|
|
||||||
use fparkan_path::{join_under, NormalizedPath};
|
use fparkan_binary::{sha256, Sha256Digest};
|
||||||
|
use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
/// VFS metadata.
|
/// VFS metadata.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct VfsMetadata {
|
pub struct VfsMetadata {
|
||||||
/// Byte length.
|
/// Byte length.
|
||||||
pub len: u64,
|
pub len: u64,
|
||||||
/// Stable-enough source fingerprint for cache invalidation.
|
/// SHA-256 content fingerprint for cache invalidation.
|
||||||
pub fingerprint: u64,
|
pub fingerprint: Sha256Digest,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// VFS entry.
|
/// VFS entry.
|
||||||
@@ -80,6 +82,7 @@ pub trait Vfs: Send + Sync {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct DirectoryVfs {
|
pub struct DirectoryVfs {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
|
fingerprint_cache: Arc<Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DirectoryVfs {
|
impl DirectoryVfs {
|
||||||
@@ -88,26 +91,43 @@ impl DirectoryVfs {
|
|||||||
pub fn new(root: impl AsRef<Path>) -> Self {
|
pub fn new(root: impl AsRef<Path>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
root: root.as_ref().to_path_buf(),
|
root: root.as_ref().to_path_buf(),
|
||||||
|
fingerprint_cache: Arc::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> {
|
fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> {
|
||||||
let exact = join_under(&self.root, path).map_err(|_| VfsError::Path)?;
|
join_under(&self.root, path).map_err(|_| VfsError::Path)?;
|
||||||
if exact.exists() {
|
|
||||||
return Ok(exact);
|
|
||||||
}
|
|
||||||
resolve_casefolded(&self.root, path.as_str())
|
resolve_casefolded(&self.root, path.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn metadata_from_host_file(&self, path: &Path) -> Result<VfsMetadata, VfsError> {
|
||||||
|
let metadata = fs::symlink_metadata(path).map_err(VfsError::Io)?;
|
||||||
|
metadata_from_host_file_with_cache(path, &metadata, &self.fingerprint_cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
struct CachedHostFingerprint {
|
||||||
|
len: u64,
|
||||||
|
modified: Option<SystemTime>,
|
||||||
|
fingerprint: Sha256Digest,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Vfs for DirectoryVfs {
|
impl Vfs for DirectoryVfs {
|
||||||
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
||||||
let meta = fs::metadata(self.host_path(path)?).map_err(VfsError::Io)?;
|
self.metadata_from_host_file(&self.host_path(path)?)
|
||||||
Ok(metadata_from_fs(&meta))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
||||||
let bytes = fs::read(self.host_path(path)?).map_err(VfsError::Io)?;
|
let host = self.host_path(path)?;
|
||||||
|
if fs::symlink_metadata(&host)
|
||||||
|
.map_err(VfsError::Io)?
|
||||||
|
.file_type()
|
||||||
|
.is_symlink()
|
||||||
|
{
|
||||||
|
return Err(VfsError::Path);
|
||||||
|
}
|
||||||
|
let bytes = fs::read(host).map_err(VfsError::Io)?;
|
||||||
Ok(Arc::from(bytes.into_boxed_slice()))
|
Ok(Arc::from(bytes.into_boxed_slice()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,14 +135,18 @@ impl Vfs for DirectoryVfs {
|
|||||||
let base = self.host_path(prefix)?;
|
let base = self.host_path(prefix)?;
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
if base.is_file() {
|
if base.is_file() {
|
||||||
let metadata = fs::metadata(&base).map_err(VfsError::Io)?;
|
let metadata = fs::symlink_metadata(&base).map_err(VfsError::Io)?;
|
||||||
entries.push(VfsEntry {
|
entries.push(VfsEntry {
|
||||||
path: prefix.clone(),
|
path: prefix.clone(),
|
||||||
metadata: metadata_from_fs(&metadata),
|
metadata: metadata_from_host_file_with_cache(
|
||||||
|
&base,
|
||||||
|
&metadata,
|
||||||
|
&self.fingerprint_cache,
|
||||||
|
)?,
|
||||||
});
|
});
|
||||||
return Ok(entries);
|
return Ok(entries);
|
||||||
}
|
}
|
||||||
list_recursive(&self.root, &base, &mut entries)?;
|
list_recursive(&self.root, &base, &self.fingerprint_cache, &mut entries)?;
|
||||||
entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
|
entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
@@ -140,6 +164,9 @@ fn resolve_casefolded(root: &Path, normalized: &str) -> Result<PathBuf, VfsError
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if name.eq_ignore_ascii_case(segment) {
|
if name.eq_ignore_ascii_case(segment) {
|
||||||
|
if entry.file_type().map_err(VfsError::Io)?.is_symlink() {
|
||||||
|
return Err(VfsError::Path);
|
||||||
|
}
|
||||||
matches.push(entry.path());
|
matches.push(entry.path());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +193,12 @@ fn select_casefolded_match(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_recursive(root: &Path, dir: &Path, out: &mut Vec<VfsEntry>) -> Result<(), VfsError> {
|
fn list_recursive(
|
||||||
|
root: &Path,
|
||||||
|
dir: &Path,
|
||||||
|
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
|
||||||
|
out: &mut Vec<VfsEntry>,
|
||||||
|
) -> Result<(), VfsError> {
|
||||||
let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?;
|
let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?;
|
||||||
let mut children = Vec::new();
|
let mut children = Vec::new();
|
||||||
for entry in read_dir {
|
for entry in read_dir {
|
||||||
@@ -175,9 +207,12 @@ fn list_recursive(root: &Path, dir: &Path, out: &mut Vec<VfsEntry>) -> Result<()
|
|||||||
}
|
}
|
||||||
children.sort();
|
children.sort();
|
||||||
for child in children {
|
for child in children {
|
||||||
let metadata = fs::metadata(&child).map_err(VfsError::Io)?;
|
let metadata = fs::symlink_metadata(&child).map_err(VfsError::Io)?;
|
||||||
|
if metadata.file_type().is_symlink() {
|
||||||
|
return Err(VfsError::Path);
|
||||||
|
}
|
||||||
if metadata.is_dir() {
|
if metadata.is_dir() {
|
||||||
list_recursive(root, &child, out)?;
|
list_recursive(root, &child, fingerprint_cache, out)?;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !metadata.is_file() {
|
if !metadata.is_file() {
|
||||||
@@ -192,56 +227,111 @@ fn list_recursive(root: &Path, dir: &Path, out: &mut Vec<VfsEntry>) -> Result<()
|
|||||||
.map_err(|_| VfsError::Path)?;
|
.map_err(|_| VfsError::Path)?;
|
||||||
out.push(VfsEntry {
|
out.push(VfsEntry {
|
||||||
path,
|
path,
|
||||||
metadata: metadata_from_fs(&metadata),
|
metadata: metadata_from_host_file_with_cache(&child, &metadata, fingerprint_cache)?,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn metadata_from_fs(metadata: &fs::Metadata) -> VfsMetadata {
|
fn metadata_from_host_file_with_cache(
|
||||||
let mut fingerprint = 0xcbf2_9ce4_8422_2325;
|
path: &Path,
|
||||||
hash_u64(&mut fingerprint, metadata.len());
|
metadata: &fs::Metadata,
|
||||||
if let Ok(modified) = metadata.modified() {
|
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
|
||||||
if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
|
) -> Result<VfsMetadata, VfsError> {
|
||||||
hash_u64(&mut fingerprint, duration.as_secs());
|
if !metadata.is_file() {
|
||||||
hash_u64(&mut fingerprint, u64::from(duration.subsec_nanos()));
|
return Err(VfsError::Path);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
VfsMetadata {
|
let len = metadata.len();
|
||||||
len: metadata.len(),
|
let modified = metadata.modified().ok();
|
||||||
fingerprint,
|
if let Some(cached) = fingerprint_cache
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| VfsError::Path)?
|
||||||
|
.get(path)
|
||||||
|
.cloned()
|
||||||
|
.filter(|cached| cached.len == len && cached.modified == modified)
|
||||||
|
{
|
||||||
|
return Ok(VfsMetadata {
|
||||||
|
len,
|
||||||
|
fingerprint: cached.fingerprint,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let bytes = fs::read(path).map_err(VfsError::Io)?;
|
||||||
|
let fingerprint = sha256(&bytes);
|
||||||
|
fingerprint_cache
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| VfsError::Path)?
|
||||||
|
.insert(
|
||||||
|
path.to_path_buf(),
|
||||||
|
CachedHostFingerprint {
|
||||||
|
len,
|
||||||
|
modified,
|
||||||
|
fingerprint,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(VfsMetadata { len, fingerprint })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-memory VFS.
|
/// In-memory VFS.
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct MemoryVfs {
|
pub struct MemoryVfs {
|
||||||
files: BTreeMap<String, Arc<[u8]>>,
|
files: BTreeMap<String, Arc<[u8]>>,
|
||||||
|
lookup: BTreeMap<Vec<u8>, Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryVfs {
|
impl MemoryVfs {
|
||||||
/// Inserts a file.
|
/// Inserts a file.
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) {
|
pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) {
|
||||||
self.files.insert(path.as_str().to_string(), bytes);
|
let path = path.as_str().to_string();
|
||||||
|
self.files.insert(path, bytes);
|
||||||
|
self.rebuild_lookup();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_lookup(&mut self) {
|
||||||
|
self.lookup.clear();
|
||||||
|
for path in self.files.keys() {
|
||||||
|
self.lookup
|
||||||
|
.entry(ascii_lookup_key(path.as_bytes()).0)
|
||||||
|
.or_default()
|
||||||
|
.push(path.clone());
|
||||||
|
}
|
||||||
|
for paths in self.lookup.values_mut() {
|
||||||
|
paths.sort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_path(&self, path: &NormalizedPath) -> Result<&str, VfsError> {
|
||||||
|
let key = ascii_lookup_key(path.as_str().as_bytes()).0;
|
||||||
|
let matches = self
|
||||||
|
.lookup
|
||||||
|
.get(&key)
|
||||||
|
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
|
||||||
|
match matches.as_slice() {
|
||||||
|
[single] => Ok(single.as_str()),
|
||||||
|
[] => Err(VfsError::NotFound(path.as_str().to_string())),
|
||||||
|
_ => Err(VfsError::Ambiguous(path.as_str().to_string())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Vfs for MemoryVfs {
|
impl Vfs for MemoryVfs {
|
||||||
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
||||||
|
let resolved = self.resolve_path(path)?;
|
||||||
let bytes = self
|
let bytes = self
|
||||||
.files
|
.files
|
||||||
.get(path.as_str())
|
.get(resolved)
|
||||||
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
|
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
|
||||||
Ok(VfsMetadata {
|
Ok(VfsMetadata {
|
||||||
len: bytes.len() as u64,
|
len: bytes.len() as u64,
|
||||||
fingerprint: stable_hash(bytes),
|
fingerprint: sha256(bytes),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
||||||
|
let resolved = self.resolve_path(path)?;
|
||||||
self.files
|
self.files
|
||||||
.get(path.as_str())
|
.get(resolved)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))
|
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))
|
||||||
}
|
}
|
||||||
@@ -263,7 +353,7 @@ impl Vfs for MemoryVfs {
|
|||||||
path: normalized,
|
path: normalized,
|
||||||
metadata: VfsMetadata {
|
metadata: VfsMetadata {
|
||||||
len: bytes.len() as u64,
|
len: bytes.len() as u64,
|
||||||
fingerprint: stable_hash(bytes),
|
fingerprint: sha256(bytes),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -272,22 +362,6 @@ impl Vfs for MemoryVfs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stable_hash(bytes: &[u8]) -> u64 {
|
|
||||||
let mut state = 0xcbf2_9ce4_8422_2325;
|
|
||||||
for byte in bytes {
|
|
||||||
state ^= u64::from(*byte);
|
|
||||||
state = state.wrapping_mul(0x0000_0100_0000_01b3);
|
|
||||||
}
|
|
||||||
state
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_u64(state: &mut u64, value: u64) {
|
|
||||||
for byte in value.to_le_bytes() {
|
|
||||||
*state ^= u64::from(byte);
|
|
||||||
*state = state.wrapping_mul(0x0000_0100_0000_01b3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Layered VFS with deterministic first-layer precedence.
|
/// Layered VFS with deterministic first-layer precedence.
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct OverlayVfs {
|
pub struct OverlayVfs {
|
||||||
@@ -384,6 +458,36 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(root).expect("cleanup");
|
std::fs::remove_dir_all(root).expect("cleanup");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn directory_vfs_reports_casefold_ambiguity_even_for_exact_host_path() {
|
||||||
|
let root = unique_test_dir("casefold-ambiguous");
|
||||||
|
std::fs::create_dir_all(root.join("Data")).expect("mkdir first");
|
||||||
|
std::fs::create_dir_all(root.join("data")).expect("mkdir second");
|
||||||
|
std::fs::write(root.join("Data").join("File.bin"), b"first").expect("write first");
|
||||||
|
std::fs::write(root.join("data").join("File.bin"), b"second").expect("write second");
|
||||||
|
let collision_count = std::fs::read_dir(&root)
|
||||||
|
.expect("read root")
|
||||||
|
.flatten()
|
||||||
|
.filter(|entry| {
|
||||||
|
entry
|
||||||
|
.file_name()
|
||||||
|
.to_str()
|
||||||
|
.is_some_and(|name| name.eq_ignore_ascii_case("data"))
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
if collision_count < 2 {
|
||||||
|
std::fs::remove_dir_all(root).expect("cleanup");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let vfs = DirectoryVfs::new(&root);
|
||||||
|
let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path");
|
||||||
|
|
||||||
|
assert!(matches!(vfs.read(&path), Err(VfsError::Ambiguous(_))));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).expect("cleanup");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn directory_vfs_lists_files_below_prefix() {
|
fn directory_vfs_lists_files_below_prefix() {
|
||||||
let root = unique_test_dir("list");
|
let root = unique_test_dir("list");
|
||||||
@@ -403,6 +507,45 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(root).expect("cleanup");
|
std::fs::remove_dir_all(root).expect("cleanup");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn directory_vfs_fingerprint_changes_for_same_length_content() {
|
||||||
|
let root = unique_test_dir("content-fingerprint");
|
||||||
|
std::fs::create_dir_all(root.join("DATA")).expect("mkdir");
|
||||||
|
std::fs::write(root.join("DATA").join("File.bin"), b"before").expect("write before");
|
||||||
|
|
||||||
|
let vfs = DirectoryVfs::new(&root);
|
||||||
|
let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path");
|
||||||
|
let before = vfs.metadata(&path).expect("before metadata");
|
||||||
|
std::fs::write(root.join("DATA").join("File.bin"), b"after!").expect("write after");
|
||||||
|
let after = vfs.metadata(&path).expect("after metadata");
|
||||||
|
|
||||||
|
assert_eq!(before.len, after.len);
|
||||||
|
assert_ne!(before.fingerprint, after.fingerprint);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).expect("cleanup");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn directory_vfs_rejects_symlink_escape() {
|
||||||
|
let root = unique_test_dir("symlink-escape");
|
||||||
|
let outside = unique_test_dir("symlink-outside");
|
||||||
|
std::fs::create_dir_all(&root).expect("mkdir root");
|
||||||
|
std::fs::create_dir_all(&outside).expect("mkdir outside");
|
||||||
|
std::fs::write(outside.join("secret.bin"), b"secret").expect("write outside");
|
||||||
|
std::os::unix::fs::symlink(&outside, root.join("DATA")).expect("symlink");
|
||||||
|
|
||||||
|
let vfs = DirectoryVfs::new(&root);
|
||||||
|
let path = normalize_relative(b"DATA/secret.bin", PathPolicy::StrictLegacy).expect("path");
|
||||||
|
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
|
||||||
|
|
||||||
|
assert!(matches!(vfs.read(&path), Err(VfsError::Path)));
|
||||||
|
assert!(matches!(vfs.list(&prefix), Err(VfsError::Path)));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).expect("cleanup root");
|
||||||
|
std::fs::remove_dir_all(outside).expect("cleanup outside");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn casefold_selector_reports_ambiguous_segments() {
|
fn casefold_selector_reports_ambiguous_segments() {
|
||||||
let err = select_casefolded_match(
|
let err = select_casefolded_match(
|
||||||
@@ -417,7 +560,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn memory_vfs_uses_exact_lookup() {
|
fn memory_vfs_uses_ascii_casefold_lookup() {
|
||||||
let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path");
|
let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path");
|
||||||
let mut vfs = MemoryVfs::default();
|
let mut vfs = MemoryVfs::default();
|
||||||
vfs.insert(path.clone(), Arc::from(b"payload".as_slice()));
|
vfs.insert(path.clone(), Arc::from(b"payload".as_slice()));
|
||||||
@@ -427,7 +570,23 @@ mod tests {
|
|||||||
|
|
||||||
let other_case =
|
let other_case =
|
||||||
normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("path");
|
normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("path");
|
||||||
assert!(matches!(vfs.read(&other_case), Err(VfsError::NotFound(_))));
|
assert_eq!(
|
||||||
|
vfs.read(&other_case).expect("casefold read").as_ref(),
|
||||||
|
b"payload"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn memory_vfs_reports_casefold_ambiguity() {
|
||||||
|
let first = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("first");
|
||||||
|
let second =
|
||||||
|
normalize_relative(b"DATA/file.BIN", PathPolicy::StrictLegacy).expect("second");
|
||||||
|
let query = normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("query");
|
||||||
|
let mut vfs = MemoryVfs::default();
|
||||||
|
vfs.insert(first, Arc::from(b"first".as_slice()));
|
||||||
|
vfs.insert(second, Arc::from(b"second".as_slice()));
|
||||||
|
|
||||||
|
assert!(matches!(vfs.read(&query), Err(VfsError::Ambiguous(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
+306
-57
@@ -1,8 +1,11 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
//! Deterministic world identity, queue, lifecycle, and snapshots.
|
//! Deterministic world identity, queue, lifecycle, and snapshots.
|
||||||
|
|
||||||
|
use fparkan_binary::sha256;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v2\0";
|
||||||
|
|
||||||
/// Object handle with generation.
|
/// Object handle with generation.
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
pub struct ObjectHandle {
|
pub struct ObjectHandle {
|
||||||
@@ -105,6 +108,8 @@ pub struct FixedStepClock {
|
|||||||
tick: Tick,
|
tick: Tick,
|
||||||
paused: bool,
|
paused: bool,
|
||||||
platform_event_collections: u64,
|
platform_event_collections: u64,
|
||||||
|
dropped_presentation_millis: u64,
|
||||||
|
dropped_presentation_frames: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fixed-step configuration.
|
/// Fixed-step configuration.
|
||||||
@@ -112,11 +117,16 @@ pub struct FixedStepClock {
|
|||||||
pub struct FixedStepConfig {
|
pub struct FixedStepConfig {
|
||||||
/// Milliseconds per simulation tick.
|
/// Milliseconds per simulation tick.
|
||||||
pub step_millis: u32,
|
pub step_millis: u32,
|
||||||
|
/// Maximum simulation ticks executed for a single presentation frame.
|
||||||
|
pub max_steps_per_frame: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for FixedStepConfig {
|
impl Default for FixedStepConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self { step_millis: 16 }
|
Self {
|
||||||
|
step_millis: 16,
|
||||||
|
max_steps_per_frame: 8,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +179,17 @@ pub enum WorldError {
|
|||||||
|
|
||||||
impl std::fmt::Display for WorldError {
|
impl std::fmt::Display for WorldError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
write!(f, "{self:?}")
|
match self {
|
||||||
|
Self::InvalidHandle => write!(f, "object handle does not reference a known slot"),
|
||||||
|
Self::StaleHandle => write!(f, "object handle belongs to an older slot generation"),
|
||||||
|
Self::Deleted => write!(f, "object has already been deleted"),
|
||||||
|
Self::DuplicateOriginalObjectId(id) => {
|
||||||
|
write!(f, "original object id {} is already registered", id.0)
|
||||||
|
}
|
||||||
|
Self::InvalidFixedStep => {
|
||||||
|
write!(f, "fixed-step configuration values must be non-zero")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,68 +377,90 @@ pub fn step_with_handler<F>(
|
|||||||
where
|
where
|
||||||
F: FnMut(&mut World, &WorldCommand) -> Result<(), WorldError>,
|
F: FnMut(&mut World, &WorldCommand) -> Result<(), WorldError>,
|
||||||
{
|
{
|
||||||
|
let before = world.clone();
|
||||||
world.phase = WorldPhase::Calculating;
|
world.phase = WorldPhase::Calculating;
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
while let Some(command) = world.queue.pop_front() {
|
let result = (|| {
|
||||||
if let Some(handle) = command.target {
|
while let Some(command) = world.queue.pop_front() {
|
||||||
if world.deferred_delete.contains(&handle) {
|
if let Some(handle) = command.target {
|
||||||
continue;
|
if world.deferred_delete.contains(&handle) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
checked_slot(world, handle)?;
|
||||||
}
|
}
|
||||||
checked_slot(world, handle)?;
|
handler(world, &command)?;
|
||||||
|
events.push(WorldEvent {
|
||||||
|
sequence: command.sequence,
|
||||||
|
target: command.target,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
handler(world, &command)?;
|
world.phase = WorldPhase::ApplyingDeferred;
|
||||||
events.push(WorldEvent {
|
let deletes = std::mem::take(&mut world.deferred_delete);
|
||||||
sequence: command.sequence,
|
for handle in deletes {
|
||||||
target: command.target,
|
delete_now(world, handle)?;
|
||||||
});
|
}
|
||||||
|
world.tick.0 = world.tick.0.saturating_add(1);
|
||||||
|
world.phase = WorldPhase::PublishingSnapshot;
|
||||||
|
let snapshot = WorldSnapshot {
|
||||||
|
tick: world.tick,
|
||||||
|
objects: live_registered(world),
|
||||||
|
events,
|
||||||
|
hash: canonical_state_hash(world),
|
||||||
|
};
|
||||||
|
world.phase = WorldPhase::Idle;
|
||||||
|
Ok(snapshot)
|
||||||
|
})();
|
||||||
|
if let Err(err) = result {
|
||||||
|
*world = before;
|
||||||
|
world.phase = WorldPhase::Idle;
|
||||||
|
return Err(err);
|
||||||
}
|
}
|
||||||
world.phase = WorldPhase::ApplyingDeferred;
|
result
|
||||||
let deletes = std::mem::take(&mut world.deferred_delete);
|
|
||||||
for handle in deletes {
|
|
||||||
let _ = delete_now(world, handle);
|
|
||||||
}
|
|
||||||
world.tick.0 = world.tick.0.saturating_add(1);
|
|
||||||
world.phase = WorldPhase::PublishingSnapshot;
|
|
||||||
let snapshot = WorldSnapshot {
|
|
||||||
tick: world.tick,
|
|
||||||
objects: live_registered(world),
|
|
||||||
events,
|
|
||||||
hash: canonical_state_hash(world),
|
|
||||||
};
|
|
||||||
world.phase = WorldPhase::Idle;
|
|
||||||
Ok(snapshot)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computes canonical state hash.
|
/// Computes canonical state hash.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn canonical_state_hash(world: &World) -> StateHash {
|
pub fn canonical_state_hash(world: &World) -> StateHash {
|
||||||
let mut state = 0xcbf2_9ce4_8422_2325_u64;
|
StateHash(sha256(&canonical_state_bytes(world)))
|
||||||
hash_u64(&mut state, world.tick.0);
|
}
|
||||||
|
|
||||||
|
fn canonical_state_bytes(world: &World) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
out.extend_from_slice(WORLD_STATE_HASH_SCHEMA);
|
||||||
|
push_u64(&mut out, world.tick.0);
|
||||||
|
push_u64(&mut out, world.next_sequence);
|
||||||
|
push_u64(&mut out, world.next_registration_sequence);
|
||||||
|
push_len(&mut out, world.slots.len());
|
||||||
for (idx, slot) in world.slots.iter().enumerate() {
|
for (idx, slot) in world.slots.iter().enumerate() {
|
||||||
hash_u64(&mut state, idx as u64);
|
push_len(&mut out, idx);
|
||||||
hash_u64(&mut state, u64::from(slot.generation));
|
push_u32(&mut out, slot.generation);
|
||||||
hash_u64(&mut state, u64::from(u8::from(slot.live)));
|
push_bool(&mut out, slot.live);
|
||||||
hash_u64(&mut state, u64::from(u8::from(slot.registered)));
|
push_bool(&mut out, slot.registered);
|
||||||
hash_u64(&mut state, slot.original_id.map_or(0, |id| u64::from(id.0)));
|
push_optional_u32(&mut out, slot.original_id.map(|id| id.0));
|
||||||
hash_u64(&mut state, slot.mirror_id.map_or(0, |id| u64::from(id.0)));
|
push_optional_u32(&mut out, slot.mirror_id.map(|id| id.0));
|
||||||
hash_u64(&mut state, slot.owner_id.map_or(0, |id| u64::from(id.0)));
|
push_optional_u16(&mut out, slot.owner_id.map(|id| id.0));
|
||||||
hash_u64(&mut state, slot.registration_sequence.unwrap_or(u64::MAX));
|
push_optional_u64(&mut out, slot.registration_sequence);
|
||||||
}
|
}
|
||||||
let mut out = [0; 32];
|
push_len(&mut out, world.queue.len());
|
||||||
out[..8].copy_from_slice(&state.to_le_bytes());
|
for command in &world.queue {
|
||||||
out[8..16].copy_from_slice(&state.rotate_left(13).to_le_bytes());
|
push_u64(&mut out, command.sequence);
|
||||||
out[16..24].copy_from_slice(&state.rotate_left(29).to_le_bytes());
|
push_optional_handle(&mut out, command.target);
|
||||||
out[24..32].copy_from_slice(&state.rotate_left(47).to_le_bytes());
|
}
|
||||||
StateHash(out)
|
push_len(&mut out, world.deferred_delete.len());
|
||||||
|
for handle in &world.deferred_delete {
|
||||||
|
push_handle(&mut out, *handle);
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a fixed-step clock.
|
/// Creates a fixed-step clock.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns [`WorldError::InvalidFixedStep`] when the configured step is zero.
|
/// Returns [`WorldError::InvalidFixedStep`] when the configured step or
|
||||||
|
/// per-frame catch-up limit is zero.
|
||||||
pub fn fixed_step_clock(config: FixedStepConfig) -> Result<FixedStepClock, WorldError> {
|
pub fn fixed_step_clock(config: FixedStepConfig) -> Result<FixedStepClock, WorldError> {
|
||||||
if config.step_millis == 0 {
|
if config.step_millis == 0 || config.max_steps_per_frame == 0 {
|
||||||
return Err(WorldError::InvalidFixedStep);
|
return Err(WorldError::InvalidFixedStep);
|
||||||
}
|
}
|
||||||
Ok(FixedStepClock {
|
Ok(FixedStepClock {
|
||||||
@@ -426,6 +468,8 @@ pub fn fixed_step_clock(config: FixedStepConfig) -> Result<FixedStepClock, World
|
|||||||
tick: Tick(0),
|
tick: Tick(0),
|
||||||
paused: false,
|
paused: false,
|
||||||
platform_event_collections: 0,
|
platform_event_collections: 0,
|
||||||
|
dropped_presentation_millis: 0,
|
||||||
|
dropped_presentation_frames: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,13 +489,14 @@ pub fn set_paused(clock: &mut FixedStepClock, paused: bool) {
|
|||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns [`WorldError::InvalidFixedStep`] when the configured step is zero.
|
/// Returns [`WorldError::InvalidFixedStep`] when the configured step or
|
||||||
|
/// per-frame catch-up limit is zero.
|
||||||
pub fn advance_fixed_step(
|
pub fn advance_fixed_step(
|
||||||
clock: &mut FixedStepClock,
|
clock: &mut FixedStepClock,
|
||||||
config: FixedStepConfig,
|
config: FixedStepConfig,
|
||||||
elapsed_millis: u64,
|
elapsed_millis: u64,
|
||||||
) -> Result<u32, WorldError> {
|
) -> Result<u32, WorldError> {
|
||||||
if config.step_millis == 0 {
|
if config.step_millis == 0 || config.max_steps_per_frame == 0 {
|
||||||
return Err(WorldError::InvalidFixedStep);
|
return Err(WorldError::InvalidFixedStep);
|
||||||
}
|
}
|
||||||
if clock.paused {
|
if clock.paused {
|
||||||
@@ -459,12 +504,20 @@ pub fn advance_fixed_step(
|
|||||||
}
|
}
|
||||||
clock.accumulated_millis = clock.accumulated_millis.saturating_add(elapsed_millis);
|
clock.accumulated_millis = clock.accumulated_millis.saturating_add(elapsed_millis);
|
||||||
let step = u64::from(config.step_millis);
|
let step = u64::from(config.step_millis);
|
||||||
let mut ticks = 0_u32;
|
let available_steps = clock.accumulated_millis / step;
|
||||||
while clock.accumulated_millis >= step {
|
let ticks_u64 = available_steps.min(u64::from(config.max_steps_per_frame));
|
||||||
clock.accumulated_millis -= step;
|
let consumed = ticks_u64.saturating_mul(step);
|
||||||
clock.tick.0 = clock.tick.0.saturating_add(1);
|
if available_steps > u64::from(config.max_steps_per_frame) {
|
||||||
ticks = ticks.saturating_add(1);
|
let dropped = clock.accumulated_millis.saturating_sub(consumed);
|
||||||
|
clock.dropped_presentation_millis =
|
||||||
|
clock.dropped_presentation_millis.saturating_add(dropped);
|
||||||
|
clock.dropped_presentation_frames = clock.dropped_presentation_frames.saturating_add(1);
|
||||||
|
clock.accumulated_millis = 0;
|
||||||
|
} else {
|
||||||
|
clock.accumulated_millis = clock.accumulated_millis.saturating_sub(consumed);
|
||||||
}
|
}
|
||||||
|
let ticks = u32::try_from(ticks_u64).unwrap_or(u32::MAX);
|
||||||
|
clock.tick.0 = clock.tick.0.saturating_add(ticks_u64);
|
||||||
Ok(ticks)
|
Ok(ticks)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,6 +533,18 @@ pub fn platform_event_collections(clock: &FixedStepClock) -> u64 {
|
|||||||
clock.platform_event_collections
|
clock.platform_event_collections
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns total presentation time dropped by fixed-step catch-up limits.
|
||||||
|
#[must_use]
|
||||||
|
pub fn dropped_presentation_millis(clock: &FixedStepClock) -> u64 {
|
||||||
|
clock.dropped_presentation_millis
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns how many presentation frames exceeded fixed-step catch-up limits.
|
||||||
|
#[must_use]
|
||||||
|
pub fn dropped_presentation_frames(clock: &FixedStepClock) -> u64 {
|
||||||
|
clock.dropped_presentation_frames
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs end-frame callbacks in stable sequence order.
|
/// Runs end-frame callbacks in stable sequence order.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn end_frame_callback_order(mut callbacks: Vec<WorldEvent>) -> Vec<u64> {
|
pub fn end_frame_callback_order(mut callbacks: Vec<WorldEvent>) -> Vec<u64> {
|
||||||
@@ -502,13 +567,59 @@ pub fn shutdown(mut world: World) -> ShutdownReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_u64(state: &mut u64, value: u64) {
|
fn push_len(out: &mut Vec<u8>, value: usize) {
|
||||||
for byte in value.to_le_bytes() {
|
push_u64(out, u64::try_from(value).unwrap_or(u64::MAX));
|
||||||
*state ^= u64::from(byte);
|
}
|
||||||
*state = state.wrapping_mul(0x0000_0100_0000_01b3);
|
|
||||||
|
fn push_u64(out: &mut Vec<u8>, value: u64) {
|
||||||
|
out.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_u32(out: &mut Vec<u8>, value: u32) {
|
||||||
|
out.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_u16(out: &mut Vec<u8>, value: u16) {
|
||||||
|
out.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_bool(out: &mut Vec<u8>, value: bool) {
|
||||||
|
out.push(u8::from(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_optional_u64(out: &mut Vec<u8>, value: Option<u64>) {
|
||||||
|
push_bool(out, value.is_some());
|
||||||
|
if let Some(value) = value {
|
||||||
|
push_u64(out, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn push_optional_u32(out: &mut Vec<u8>, value: Option<u32>) {
|
||||||
|
push_bool(out, value.is_some());
|
||||||
|
if let Some(value) = value {
|
||||||
|
push_u32(out, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_optional_u16(out: &mut Vec<u8>, value: Option<u16>) {
|
||||||
|
push_bool(out, value.is_some());
|
||||||
|
if let Some(value) = value {
|
||||||
|
push_u16(out, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_optional_handle(out: &mut Vec<u8>, handle: Option<ObjectHandle>) {
|
||||||
|
push_bool(out, handle.is_some());
|
||||||
|
if let Some(handle) = handle {
|
||||||
|
push_handle(out, handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_handle(out: &mut Vec<u8>, handle: ObjectHandle) {
|
||||||
|
push_u32(out, handle.generation);
|
||||||
|
push_u32(out, handle.slot);
|
||||||
|
}
|
||||||
|
|
||||||
fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> {
|
fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> {
|
||||||
let slot = world
|
let slot = world
|
||||||
.slots
|
.slots
|
||||||
@@ -623,6 +734,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn world_error_display_is_actionable() {
|
||||||
|
assert_eq!(
|
||||||
|
WorldError::StaleHandle.to_string(),
|
||||||
|
"object handle belongs to an older slot generation"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
WorldError::DuplicateOriginalObjectId(OriginalObjectId(8)).to_string(),
|
||||||
|
"original object id 8 is already registered"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn identity_metadata_keeps_original_mirror_and_owner_distinct() {
|
fn identity_metadata_keeps_original_mirror_and_owner_distinct() {
|
||||||
let mut world = new(WorldConfig);
|
let mut world = new(WorldConfig);
|
||||||
@@ -710,6 +833,44 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn callback_error_rolls_back_phase_queue_and_deferred_deletes() {
|
||||||
|
let mut world = new(WorldConfig);
|
||||||
|
let first = construct_object(&mut world, ObjectDraft { original_id: None }).expect("first");
|
||||||
|
register_object(&mut world, first).expect("register");
|
||||||
|
enqueue(
|
||||||
|
&mut world,
|
||||||
|
WorldCommand {
|
||||||
|
sequence: 7,
|
||||||
|
target: Some(first),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("enqueue");
|
||||||
|
|
||||||
|
let err = step_with_handler(&mut world, &InputSnapshot, |world, _| {
|
||||||
|
request_delete(world, first)?;
|
||||||
|
Err(WorldError::InvalidFixedStep)
|
||||||
|
})
|
||||||
|
.expect_err("handler error");
|
||||||
|
|
||||||
|
assert_eq!(err, WorldError::InvalidFixedStep);
|
||||||
|
assert_eq!(world.phase, WorldPhase::Idle);
|
||||||
|
assert_eq!(world.tick, Tick(0));
|
||||||
|
assert!(world.deferred_delete.is_empty());
|
||||||
|
assert_eq!(world.queue.len(), 1);
|
||||||
|
|
||||||
|
let snapshot = step(&mut world, &InputSnapshot).expect("retry step");
|
||||||
|
assert_eq!(snapshot.tick, Tick(1));
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.events,
|
||||||
|
vec![WorldEvent {
|
||||||
|
sequence: 0,
|
||||||
|
target: Some(first)
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert_eq!(snapshot.objects, vec![first]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn snapshot_hash_determinism_and_immutability() {
|
fn snapshot_hash_determinism_and_immutability() {
|
||||||
let mut left = new(WorldConfig);
|
let mut left = new(WorldConfig);
|
||||||
@@ -736,9 +897,40 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_hash_uses_canonical_sha256_instead_of_legacy_rotated_fnv() {
|
||||||
|
let mut world = new(WorldConfig);
|
||||||
|
let handle = construct_object(
|
||||||
|
&mut world,
|
||||||
|
ObjectDraft {
|
||||||
|
original_id: Some(OriginalObjectId(42)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("object");
|
||||||
|
set_mirror_original(&mut world, handle, Some(OriginalObjectId(420))).expect("mirror");
|
||||||
|
set_owner(&mut world, handle, Some(OwnerId(9))).expect("owner");
|
||||||
|
register_object(&mut world, handle).expect("register");
|
||||||
|
enqueue(
|
||||||
|
&mut world,
|
||||||
|
WorldCommand {
|
||||||
|
sequence: 999,
|
||||||
|
target: Some(handle),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("enqueue");
|
||||||
|
|
||||||
|
let snapshot = step(&mut world, &InputSnapshot).expect("step");
|
||||||
|
|
||||||
|
assert_ne!(snapshot.hash, legacy_rotated_fnv_hash(&world));
|
||||||
|
assert_ne!(snapshot.hash, canonical_state_hash(&new(WorldConfig)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fixed_step_pause_and_long_determinism_are_stable() {
|
fn fixed_step_pause_and_long_determinism_are_stable() {
|
||||||
let config = FixedStepConfig { step_millis: 20 };
|
let config = FixedStepConfig {
|
||||||
|
step_millis: 20,
|
||||||
|
max_steps_per_frame: 8,
|
||||||
|
};
|
||||||
let mut clock = fixed_step_clock(config).expect("clock");
|
let mut clock = fixed_step_clock(config).expect("clock");
|
||||||
collect_platform_events(&mut clock);
|
collect_platform_events(&mut clock);
|
||||||
set_paused(&mut clock, true);
|
set_paused(&mut clock, true);
|
||||||
@@ -762,6 +954,32 @@ mod tests {
|
|||||||
assert_eq!(first_hashes, second_hashes);
|
assert_eq!(first_hashes, second_hashes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_step_catch_up_is_capped_and_reports_dropped_time() {
|
||||||
|
let config = FixedStepConfig {
|
||||||
|
step_millis: 20,
|
||||||
|
max_steps_per_frame: 3,
|
||||||
|
};
|
||||||
|
let mut clock = fixed_step_clock(config).expect("clock");
|
||||||
|
|
||||||
|
assert_eq!(advance_fixed_step(&mut clock, config, 95), Ok(3));
|
||||||
|
assert_eq!(fixed_step_tick(&clock), Tick(3));
|
||||||
|
assert_eq!(dropped_presentation_millis(&clock), 35);
|
||||||
|
assert_eq!(dropped_presentation_frames(&clock), 1);
|
||||||
|
|
||||||
|
assert_eq!(advance_fixed_step(&mut clock, config, 10), Ok(0));
|
||||||
|
assert_eq!(advance_fixed_step(&mut clock, config, 10), Ok(1));
|
||||||
|
assert_eq!(fixed_step_tick(&clock), Tick(4));
|
||||||
|
assert_eq!(dropped_presentation_millis(&clock), 35);
|
||||||
|
assert_eq!(dropped_presentation_frames(&clock), 1);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
advance_fixed_step(&mut clock, config, u64::MAX),
|
||||||
|
Ok(config.max_steps_per_frame)
|
||||||
|
);
|
||||||
|
assert_eq!(dropped_presentation_frames(&clock), 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_disabled_does_not_change_hash_end_callbacks_and_shutdown_order() {
|
fn render_disabled_does_not_change_hash_end_callbacks_and_shutdown_order() {
|
||||||
let callbacks = vec![
|
let callbacks = vec![
|
||||||
@@ -837,4 +1055,35 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn legacy_rotated_fnv_hash(world: &World) -> StateHash {
|
||||||
|
let mut state = 0xcbf2_9ce4_8422_2325_u64;
|
||||||
|
legacy_hash_u64(&mut state, world.tick.0);
|
||||||
|
for (idx, slot) in world.slots.iter().enumerate() {
|
||||||
|
legacy_hash_u64(
|
||||||
|
&mut state,
|
||||||
|
u64::try_from(idx).expect("slot index should fit"),
|
||||||
|
);
|
||||||
|
legacy_hash_u64(&mut state, u64::from(slot.generation));
|
||||||
|
legacy_hash_u64(&mut state, u64::from(u8::from(slot.live)));
|
||||||
|
legacy_hash_u64(&mut state, u64::from(u8::from(slot.registered)));
|
||||||
|
legacy_hash_u64(&mut state, slot.original_id.map_or(0, |id| u64::from(id.0)));
|
||||||
|
legacy_hash_u64(&mut state, slot.mirror_id.map_or(0, |id| u64::from(id.0)));
|
||||||
|
legacy_hash_u64(&mut state, slot.owner_id.map_or(0, |id| u64::from(id.0)));
|
||||||
|
legacy_hash_u64(&mut state, slot.registration_sequence.unwrap_or(u64::MAX));
|
||||||
|
}
|
||||||
|
let mut out = [0; 32];
|
||||||
|
out[..8].copy_from_slice(&state.to_le_bytes());
|
||||||
|
out[8..16].copy_from_slice(&state.rotate_left(13).to_le_bytes());
|
||||||
|
out[16..24].copy_from_slice(&state.rotate_left(29).to_le_bytes());
|
||||||
|
out[24..32].copy_from_slice(&state.rotate_left(47).to_le_bytes());
|
||||||
|
StateHash(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_hash_u64(state: &mut u64, value: u64) {
|
||||||
|
for byte in value.to_le_bytes() {
|
||||||
|
*state ^= u64::from(byte);
|
||||||
|
*state = state.wrapping_mul(0x0000_0100_0000_01b3);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,16 +114,23 @@ key, configuration, device profile, initial state, input/time script и верс
|
|||||||
## Local evidence requests
|
## Local evidence requests
|
||||||
|
|
||||||
На текущем рабочем месте закрыты статические, corpus и headless runtime gates.
|
На текущем рабочем месте закрыты статические, corpus и headless runtime gates.
|
||||||
Для macOS Desktop GL подтверждены безопасный command/state trace и offscreen
|
Для macOS Desktop GL есть только безопасный command/state trace и исторический
|
||||||
pixel capture:
|
одноразовый offscreen pixel probe:
|
||||||
|
|
||||||
- `cargo test -p fparkan-render-gl --offline desktop_gl33_triangle_command_capture`;
|
- `cargo test -p fparkan-render-gl --offline desktop_gl33_triangle_command_capture`;
|
||||||
- `fixtures/acceptance/macos-gl33-triangle-capture.json`.
|
- `fixtures/acceptance/macos-gl33-triangle-capture.json`.
|
||||||
|
|
||||||
`S3-GL-001` считается закрытым для текущей macOS-focused цели: временный
|
`S3-GL-001` не считается закрытым: временный `rustc` probe создал CGL/OpenGL
|
||||||
`rustc` probe создал CGL/OpenGL offscreen FBO, выполнил shader-based triangle
|
offscreen FBO, выполнил shader-based triangle draw, прочитал RGBA pixels и
|
||||||
draw, прочитал RGBA pixels и сохранил hash capture. Probe не добавляет
|
сохранил hash capture, но постоянный workspace adapter по-прежнему не создаёт
|
||||||
project-owned `unsafe` в workspace; постоянный adapter API остаётся safe.
|
SDL window, GL context, GPU resources, shader programs, draw calls или present.
|
||||||
|
Probe не добавляет project-owned `unsafe` в workspace и остаётся только external
|
||||||
|
evidence request artifact.
|
||||||
|
|
||||||
|
Для повышения `S3-GL-001` до `covered` нужен постоянный macOS backend через
|
||||||
|
выбранную safe facade stack: SDL event/window/context lifecycle, Desktop GL 3.3
|
||||||
|
shader/buffer/texture/draw/present path, hidden-window/offscreen smoke test и
|
||||||
|
licensed local model/terrain frame capture.
|
||||||
|
|
||||||
Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2
|
Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2
|
||||||
backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же
|
backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ S0-CORPUS-005 covered cargo test -p fparkan-corpus --offline fingerprint_changes
|
|||||||
S0-CORPUS-006 covered cargo test -p fparkan-corpus --offline atomic_report_write
|
S0-CORPUS-006 covered cargo test -p fparkan-corpus --offline atomic_report_write
|
||||||
S0-CLI-001 covered cargo test -p fparkan-cli --offline stable_exit_codes_are_mapped
|
S0-CLI-001 covered cargo test -p fparkan-cli --offline stable_exit_codes_are_mapped
|
||||||
S0-CLI-002 covered cargo test -p fparkan-cli --offline accepts_json_format_option archive_json_has_schema_version
|
S0-CLI-002 covered cargo test -p fparkan-cli --offline accepts_json_format_option archive_json_has_schema_version
|
||||||
S0-GL-001 covered cargo test -p fparkan-platform-sdl -p fparkan-render-gl --offline adapter_reports_safe_project_layer_ready
|
S0-GL-001 covered cargo test -p fparkan-platform-sdl -p fparkan-render-gl --offline adapter_boundary_is_project_owned_unsafe_free
|
||||||
S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow
|
S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow
|
||||||
S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read
|
S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read
|
||||||
L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
|
L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
|
||||||
@@ -220,7 +220,7 @@ S3-RENDER-006 covered cargo test -p fparkan-render --offline invalid_range_retur
|
|||||||
S3-RENDER-007 covered cargo test -p fparkan-render --offline capture_is_stable
|
S3-RENDER-007 covered cargo test -p fparkan-render --offline capture_is_stable
|
||||||
S3-RENDER-008 covered cargo test -p fparkan-render --offline recording_backend_stores_captures
|
S3-RENDER-008 covered cargo test -p fparkan-render --offline recording_backend_stores_captures
|
||||||
S3-RENDER-009 covered cargo xtask policy
|
S3-RENDER-009 covered cargo xtask policy
|
||||||
S3-GL-001 covered cargo test -p fparkan-render-gl --offline desktop_gl33_triangle_command_capture plus fixtures/acceptance/macos-gl33-triangle-capture.json records macOS CGL/OpenGL offscreen FBO pixel capture
|
S3-GL-001 omitted permanent macOS Desktop GL 3.3 adapter is not implemented; historical CGL probe is retained as external evidence only
|
||||||
S3-GL-002 omitted outside the current macOS-focused goal scope; GLES2 remains documented for portable/non-macOS targets
|
S3-GL-002 omitted outside the current macOS-focused goal scope; GLES2 remains documented for portable/non-macOS targets
|
||||||
S3-GL-003 covered cargo test -p fparkan-render-gl --offline shader_compile_failure_diagnostic_contains_profile_and_log
|
S3-GL-003 covered cargo test -p fparkan-render-gl --offline shader_compile_failure_diagnostic_contains_profile_and_log
|
||||||
S3-VIEWER-001 covered cargo test -p fparkan-viewer --offline model_fixture_uses_viewer_service_and_render_commands
|
S3-VIEWER-001 covered cargo test -p fparkan-viewer --offline model_fixture_uses_viewer_service_and_render_commands
|
||||||
@@ -232,8 +232,8 @@ S4-ANIM-005 covered cargo test -p fparkan-animation --offline exact_key_time_ret
|
|||||||
S4-ANIM-006 covered cargo test -p fparkan-animation --offline pose_track_blends_translation_and_rotation
|
S4-ANIM-006 covered cargo test -p fparkan-animation --offline pose_track_blends_translation_and_rotation
|
||||||
S4-ANIM-007 covered cargo test -p fparkan-animation --offline quaternion_shortest_path_sign_flip_is_stable
|
S4-ANIM-007 covered cargo test -p fparkan-animation --offline quaternion_shortest_path_sign_flip_is_stable
|
||||||
S4-ANIM-008 covered cargo test -p fparkan-animation --offline zero_or_degenerate_key_interval_is_rejected
|
S4-ANIM-008 covered cargo test -p fparkan-animation --offline zero_or_degenerate_key_interval_is_rejected
|
||||||
S4-ANIM-009 covered cargo test -p fparkan-animation --offline x87_boundary_golden_vectors_and_profile_difference_report
|
S4-ANIM-009 omitted current sampler is a portable reference path; runtime-captured x87 parity vectors are not implemented in the macOS-focused scope
|
||||||
S4-ANIM-010 covered cargo test -p fparkan-animation --offline x87_boundary_golden_vectors_and_profile_difference_report
|
S4-ANIM-010 omitted current sampler accepts the profile marker but does not implement an independent x87 compatibility path
|
||||||
S4-ANIM-011 covered cargo test -p fparkan-animation --offline blend_optional_pose_uses_valid_side
|
S4-ANIM-011 covered cargo test -p fparkan-animation --offline blend_optional_pose_uses_valid_side
|
||||||
S4-ANIM-012 covered cargo test -p fparkan-animation --offline hierarchy_evaluates_parent_before_child_and_rejects_cycles
|
S4-ANIM-012 covered cargo test -p fparkan-animation --offline hierarchy_evaluates_parent_before_child_and_rejects_cycles
|
||||||
S4-ANIM-013 covered cargo test -p fparkan-animation --offline hierarchy_evaluates_parent_before_child_and_rejects_cycles
|
S4-ANIM-013 covered cargo test -p fparkan-animation --offline hierarchy_evaluates_parent_before_child_and_rejects_cycles
|
||||||
@@ -254,13 +254,13 @@ S4-FX-014 covered cargo test -p fparkan-fx --offline rejects_command_count_that_
|
|||||||
S4-FX-015 covered cargo test -p fparkan-fx --offline rejects_trailing_bytes_after_command_stream
|
S4-FX-015 covered cargo test -p fparkan-fx --offline rejects_trailing_bytes_after_command_stream
|
||||||
S4-FX-016 covered cargo test -p fparkan-fx --offline fixed_resource_refs_preserve_tails
|
S4-FX-016 covered cargo test -p fparkan-fx --offline fixed_resource_refs_preserve_tails
|
||||||
S4-FX-017 covered cargo test -p fparkan-fx --offline missing_dependency_error_contains_effect_command_archive_and_name
|
S4-FX-017 covered cargo test -p fparkan-fx --offline missing_dependency_error_contains_effect_command_archive_and_name
|
||||||
S4-FX-018 covered cargo test -p fparkan-fx --offline create_records_seed_transform_and_start_time
|
S4-FX-018 omitted FX create/update/emit is documented as a deterministic reference stub until runtime-captured lifecycle semantics are implemented
|
||||||
S4-FX-019 covered cargo test -p fparkan-fx --offline update_and_emit_are_separate
|
S4-FX-019 omitted FX update side-effect contract is stub-level only and not accepted as original runtime parity evidence
|
||||||
S4-FX-020 covered cargo test -p fparkan-fx --offline update_and_emit_are_separate
|
S4-FX-020 omitted FX emit side-effect contract is stub-level only and not accepted as original runtime parity evidence
|
||||||
S4-FX-021 covered cargo test -p fparkan-fx --offline stable_command_order_and_emission_capture_are_seed_stable
|
S4-FX-021 omitted FX command order is deterministic for the stub but original runtime ordering evidence is not implemented
|
||||||
S4-FX-022 covered cargo test -p fparkan-fx --offline stop_restart_end_lifecycle_controls_emission
|
S4-FX-022 omitted FX lifecycle controls are deterministic for the stub but original stop/restart/end semantics are not implemented
|
||||||
S4-FX-023 covered cargo test -p fparkan-fx --offline stable_command_order_and_emission_capture_are_seed_stable
|
S4-FX-023 omitted FX emission capture is deterministic for the stub but lacks runtime-captured parity evidence
|
||||||
S4-FX-024 covered cargo test -p fparkan-fx --offline unrelated_rng_stream_use_does_not_perturb_fx_capture
|
S4-FX-024 omitted FX RNG accounting is not implemented beyond the reference stub and must not count as compatibility coverage
|
||||||
S4-FX-FUZZ-001 covered cargo test -p fparkan-fx --offline arbitrary_command_streams_are_bounded_and_panic_free
|
S4-FX-FUZZ-001 covered cargo test -p fparkan-fx --offline arbitrary_command_streams_are_bounded_and_panic_free
|
||||||
L4-P1-ANIM-001 covered cargo test -p fparkan-msh --offline licensed_corpus_animation_streams_sample_approved_pose_captures
|
L4-P1-ANIM-001 covered cargo test -p fparkan-msh --offline licensed_corpus_animation_streams_sample_approved_pose_captures
|
||||||
L4-P2-ANIM-001 covered cargo test -p fparkan-msh --offline licensed_corpus_animation_streams_sample_approved_pose_captures
|
L4-P2-ANIM-001 covered cargo test -p fparkan-msh --offline licensed_corpus_animation_streams_sample_approved_pose_captures
|
||||||
@@ -271,8 +271,8 @@ L4-P2-FX-001 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exa
|
|||||||
L4-P1-FX-002 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution
|
L4-P1-FX-002 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution
|
||||||
L4-P2-FX-002 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution
|
L4-P2-FX-002 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution
|
||||||
L4-FX-OP6-001 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution
|
L4-FX-OP6-001 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution
|
||||||
L4-P1-EFFECT-001 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_emission_captures_are_approved
|
L4-P1-EFFECT-001 omitted licensed FX emission capture currently covers deterministic reference-stub output, not original runtime-captured effect parity
|
||||||
L4-P2-EFFECT-001 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_emission_captures_are_approved
|
L4-P2-EFFECT-001 omitted licensed FX emission capture currently covers deterministic reference-stub output, not original runtime-captured effect parity
|
||||||
S5-LMESH-001 covered cargo test -p fparkan-terrain-format --offline land_msh_required_streams_are_order_independent_and_stride_checked
|
S5-LMESH-001 covered cargo test -p fparkan-terrain-format --offline land_msh_required_streams_are_order_independent_and_stride_checked
|
||||||
S5-LMESH-002 covered cargo test -p fparkan-terrain-format --offline land_msh_required_streams_are_order_independent_and_stride_checked
|
S5-LMESH-002 covered cargo test -p fparkan-terrain-format --offline land_msh_required_streams_are_order_independent_and_stride_checked
|
||||||
S5-LMESH-003 covered cargo test -p fparkan-terrain-format --offline decodes_minimal_land_msh
|
S5-LMESH-003 covered cargo test -p fparkan-terrain-format --offline decodes_minimal_land_msh
|
||||||
|
|||||||
|
+323
-24
@@ -10,6 +10,10 @@ use std::fs;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
|
const CORPORA_MANIFEST_ENV: &str = "FPARKAN_CORPORA_MANIFEST";
|
||||||
|
const PART1_ROOT_ENV: &str = "FPARKAN_CORPUS_PART1_ROOT";
|
||||||
|
const PART2_ROOT_ENV: &str = "FPARKAN_CORPUS_PART2_ROOT";
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||||
let code = match run(&args) {
|
let code = match run(&args) {
|
||||||
@@ -27,8 +31,8 @@ fn run(args: &[String]) -> Result<(), String> {
|
|||||||
[cmd] if cmd == "ci" => {
|
[cmd] if cmd == "ci" => {
|
||||||
run_rustfmt_check(Path::new("."))?;
|
run_rustfmt_check(Path::new("."))?;
|
||||||
run_policy(Path::new("."))?;
|
run_policy(Path::new("."))?;
|
||||||
cargo(&["test", "--workspace", "--offline"])?;
|
cargo(&["test", "--workspace", "--locked", "--offline"])?;
|
||||||
clippy_rustup(&["--workspace", "--offline"])?;
|
clippy_rustup(&["--workspace", "--locked", "--offline"])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
[cmd] if cmd == "policy" => run_policy(Path::new(".")),
|
[cmd] if cmd == "policy" => run_policy(Path::new(".")),
|
||||||
@@ -46,23 +50,23 @@ fn run(args: &[String]) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
[cmd, suite, rest @ ..] if cmd == "test" && suite == "synthetic" => {
|
[cmd, suite, rest @ ..] if cmd == "test" && suite == "synthetic" => {
|
||||||
let options = parse_test_options(rest, PathBuf::from("testdata"))?;
|
let options = parse_test_options(rest, PathBuf::from("testdata"))?;
|
||||||
run_stage_tests(options.stage)
|
run_stage_tests(options.stage, TestSuite::Synthetic, None)
|
||||||
}
|
}
|
||||||
[cmd, suite, rest @ ..] if cmd == "test" && suite == "licensed" => {
|
[cmd, suite, rest @ ..] if cmd == "test" && suite == "licensed" => {
|
||||||
let options = parse_test_options(rest, PathBuf::from("testdata"))?;
|
let options = parse_test_options(rest, PathBuf::from("testdata"))?;
|
||||||
validate_licensed_root(&options.root)?;
|
let roots = load_licensed_roots(options.manifest.as_deref())?;
|
||||||
run_stage_tests(options.stage)
|
run_stage_tests(options.stage, TestSuite::Licensed, Some(&roots))
|
||||||
}
|
}
|
||||||
[cmd, subcmd, rest @ ..] if cmd == "corpus" && subcmd == "baseline" => {
|
[cmd, subcmd, rest @ ..] if cmd == "corpus" && subcmd == "baseline" => {
|
||||||
let root = parse_root(rest)?;
|
let root = parse_root(rest)?;
|
||||||
let manifest =
|
let manifest =
|
||||||
discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
|
discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
|
||||||
let report = report(&root, &manifest);
|
let report = report(&root, &manifest).map_err(|e| e.to_string())?;
|
||||||
println!("{}", render_report_json(&report));
|
println!("{}", render_report_json(&report));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
_ => Err(
|
_ => Err(
|
||||||
"usage: cargo xtask ci | policy | acceptance report --suite synthetic|licensed [--stage 0..5|all] [--root testdata] [--out <path>] | acceptance audit [--roadmap <path>] [--coverage <path>] [--out <path>] [--strict] | package --target <triple> --app viewer|game|headless|cli | test synthetic|licensed [--stage 0..5|all] [--root testdata] | corpus baseline --root <path>"
|
"usage: cargo xtask ci | policy | acceptance report --suite synthetic|licensed [--stage 0..5|all] [--manifest corpora.toml] [--out <path>] | acceptance audit [--roadmap <path>] [--coverage <path>] [--out <path>] [--strict] | package --target <triple> --app viewer|game|headless|cli | test synthetic|licensed [--stage 0..5|all] [--manifest corpora.toml] | corpus baseline --root <path>"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -94,6 +98,23 @@ fn cargo_owned(args: &[String]) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cargo_with_env(args: &[&str], envs: &[(&str, &Path)]) -> Result<(), String> {
|
||||||
|
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
|
||||||
|
let mut command = Command::new(cargo);
|
||||||
|
command.args(args);
|
||||||
|
for (key, value) in envs {
|
||||||
|
command.env(key, value);
|
||||||
|
}
|
||||||
|
let status = command
|
||||||
|
.status()
|
||||||
|
.map_err(|err| format!("failed to run cargo: {err}"))?;
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("cargo exited with {status}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn clippy_rustup(args: &[&str]) -> Result<(), String> {
|
fn clippy_rustup(args: &[&str]) -> Result<(), String> {
|
||||||
let rustup = std::env::var_os("RUSTUP").unwrap_or_else(|| "rustup".into());
|
let rustup = std::env::var_os("RUSTUP").unwrap_or_else(|| "rustup".into());
|
||||||
let status = Command::new(rustup)
|
let status = Command::new(rustup)
|
||||||
@@ -153,19 +174,124 @@ fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_licensed_root(root: &Path) -> Result<(), String> {
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
for part in ["IS", "IS2"] {
|
struct LicensedCorpusRoots {
|
||||||
let part_root = root.join(part);
|
part1: PathBuf,
|
||||||
if !part_root.is_dir() {
|
part2: PathBuf,
|
||||||
return Err(format!(
|
}
|
||||||
"licensed corpus part is missing: {}",
|
|
||||||
part_root.display()
|
impl LicensedCorpusRoots {
|
||||||
));
|
fn envs(&self) -> [(&str, &Path); 2] {
|
||||||
|
[
|
||||||
|
(PART1_ROOT_ENV, self.part1.as_path()),
|
||||||
|
(PART2_ROOT_ENV, self.part2.as_path()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_licensed_roots(manifest: Option<&Path>) -> Result<LicensedCorpusRoots, String> {
|
||||||
|
let manifest = manifest
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.or_else(|| std::env::var_os(CORPORA_MANIFEST_ENV).map(PathBuf::from))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"licensed tests require --manifest or {CORPORA_MANIFEST_ENV}=<absolute corpora.toml>"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
parse_licensed_manifest(&manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_licensed_manifest(path: &Path) -> Result<LicensedCorpusRoots, String> {
|
||||||
|
let text = fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
||||||
|
let mut part1 = None;
|
||||||
|
let mut part2 = None;
|
||||||
|
let mut current_kind: Option<String> = None;
|
||||||
|
let mut current_root: Option<PathBuf> = None;
|
||||||
|
|
||||||
|
for raw_line in text.lines() {
|
||||||
|
let line = raw_line.split('#').next().unwrap_or_default().trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if line == "[[corpus]]" {
|
||||||
|
flush_manifest_entry(&mut part1, &mut part2, &mut current_kind, &mut current_root)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((key, value)) = line.split_once('=') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let key = key.trim();
|
||||||
|
match key {
|
||||||
|
"kind" => current_kind = Some(parse_manifest_string(value.trim())?),
|
||||||
|
"root" => current_root = Some(PathBuf::from(parse_manifest_string(value.trim())?)),
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
flush_manifest_entry(&mut part1, &mut part2, &mut current_kind, &mut current_root)?;
|
||||||
|
|
||||||
|
let roots = LicensedCorpusRoots {
|
||||||
|
part1: part1.ok_or_else(|| "licensed manifest is missing kind = \"part1\"".to_string())?,
|
||||||
|
part2: part2.ok_or_else(|| "licensed manifest is missing kind = \"part2\"".to_string())?,
|
||||||
|
};
|
||||||
|
validate_licensed_part("part1", &roots.part1)?;
|
||||||
|
validate_licensed_part("part2", &roots.part2)?;
|
||||||
|
Ok(roots)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush_manifest_entry(
|
||||||
|
part1: &mut Option<PathBuf>,
|
||||||
|
part2: &mut Option<PathBuf>,
|
||||||
|
current_kind: &mut Option<String>,
|
||||||
|
current_root: &mut Option<PathBuf>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let Some(kind) = current_kind.take() else {
|
||||||
|
*current_root = None;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let root = current_root
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| format!("licensed manifest entry {kind} is missing root"))?;
|
||||||
|
match kind.as_str() {
|
||||||
|
"part1" => assign_manifest_root(part1, root, "part1"),
|
||||||
|
"part2" => assign_manifest_root(part2, root, "part2"),
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assign_manifest_root(
|
||||||
|
target: &mut Option<PathBuf>,
|
||||||
|
root: PathBuf,
|
||||||
|
kind: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if target.replace(root).is_some() {
|
||||||
|
return Err(format!("licensed manifest contains duplicate {kind} root"));
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_manifest_string(value: &str) -> Result<String, String> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if let Some(quoted) = trimmed
|
||||||
|
.strip_prefix('"')
|
||||||
|
.and_then(|value| value.strip_suffix('"'))
|
||||||
|
{
|
||||||
|
Ok(quoted.to_string())
|
||||||
|
} else {
|
||||||
|
Err(format!("manifest value must be a quoted string: {trimmed}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_licensed_part(kind: &str, root: &Path) -> Result<(), String> {
|
||||||
|
if root.is_dir() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"licensed corpus {kind} root is missing: {}",
|
||||||
|
root.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_root(args: &[String]) -> Result<PathBuf, String> {
|
fn parse_root(args: &[String]) -> Result<PathBuf, String> {
|
||||||
let mut iter = args.iter();
|
let mut iter = args.iter();
|
||||||
while let Some(arg) = iter.next() {
|
while let Some(arg) = iter.next() {
|
||||||
@@ -248,6 +374,7 @@ fn run_package(options: &PackageOptions) -> Result<(), String> {
|
|||||||
"-p".to_string(),
|
"-p".to_string(),
|
||||||
options.app.package().to_string(),
|
options.app.package().to_string(),
|
||||||
"--release".to_string(),
|
"--release".to_string(),
|
||||||
|
"--locked".to_string(),
|
||||||
"--offline".to_string(),
|
"--offline".to_string(),
|
||||||
"--target".to_string(),
|
"--target".to_string(),
|
||||||
options.target.clone(),
|
options.target.clone(),
|
||||||
@@ -258,6 +385,8 @@ fn run_policy(root: &Path) -> Result<(), String> {
|
|||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
scan_policy_dir(root, &mut failures)?;
|
scan_policy_dir(root, &mut failures)?;
|
||||||
validate_cargo_metadata(root, &mut failures)?;
|
validate_cargo_metadata(root, &mut failures)?;
|
||||||
|
validate_lockfile(root, &mut failures);
|
||||||
|
validate_workspace_license(root, &mut failures)?;
|
||||||
validate_dependency_boundaries(root, &mut failures)?;
|
validate_dependency_boundaries(root, &mut failures)?;
|
||||||
if failures.is_empty() {
|
if failures.is_empty() {
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -278,6 +407,7 @@ fn validate_cargo_metadata(root: &Path, failures: &mut Vec<String>) -> Result<()
|
|||||||
"--format-version",
|
"--format-version",
|
||||||
"1",
|
"1",
|
||||||
"--offline",
|
"--offline",
|
||||||
|
"--locked",
|
||||||
"--no-deps",
|
"--no-deps",
|
||||||
"--manifest-path",
|
"--manifest-path",
|
||||||
])
|
])
|
||||||
@@ -295,6 +425,62 @@ fn validate_cargo_metadata(root: &Path, failures: &mut Vec<String>) -> Result<()
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_lockfile(root: &Path, failures: &mut Vec<String>) {
|
||||||
|
let lockfile = root.join("Cargo.lock");
|
||||||
|
if !lockfile.is_file() {
|
||||||
|
failures.push(format!(
|
||||||
|
"{}: workspace lockfile is required for locked/offline builds",
|
||||||
|
lockfile.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_workspace_license(root: &Path, failures: &mut Vec<String>) -> Result<(), String> {
|
||||||
|
let manifest = root.join("Cargo.toml");
|
||||||
|
let license = fs::read_to_string(root.join("LICENSE.txt"))
|
||||||
|
.map_err(|err| format!("{}: {err}", root.join("LICENSE.txt").display()))?;
|
||||||
|
let expected = if license.contains("GNU GENERAL PUBLIC LICENSE")
|
||||||
|
&& license.contains("Version 2, June 1991")
|
||||||
|
{
|
||||||
|
"GPL-2.0-only"
|
||||||
|
} else {
|
||||||
|
failures.push(format!(
|
||||||
|
"{}: unsupported repository license text",
|
||||||
|
root.join("LICENSE.txt").display()
|
||||||
|
));
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut manifests = Vec::new();
|
||||||
|
collect_cargo_manifests(root, &mut manifests)?;
|
||||||
|
manifests.push(manifest);
|
||||||
|
manifests.sort();
|
||||||
|
manifests.dedup();
|
||||||
|
|
||||||
|
for manifest in manifests {
|
||||||
|
let text = fs::read_to_string(&manifest)
|
||||||
|
.map_err(|err| format!("{}: {err}", manifest.display()))?;
|
||||||
|
let explicit_license = parse_manifest_license(&text);
|
||||||
|
let is_root = manifest == root.join("Cargo.toml");
|
||||||
|
if is_root {
|
||||||
|
if explicit_license.as_deref() != Some(expected) {
|
||||||
|
failures.push(format!(
|
||||||
|
"{}: workspace.package license must be {expected}",
|
||||||
|
manifest.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if let Some(license) = explicit_license {
|
||||||
|
if license != expected {
|
||||||
|
failures.push(format!(
|
||||||
|
"{}: package license {license} does not match repository license {expected}",
|
||||||
|
manifest.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_dependency_boundaries(root: &Path, failures: &mut Vec<String>) -> Result<(), String> {
|
fn validate_dependency_boundaries(root: &Path, failures: &mut Vec<String>) -> Result<(), String> {
|
||||||
let mut manifests = Vec::new();
|
let mut manifests = Vec::new();
|
||||||
collect_cargo_manifests(root, &mut manifests)?;
|
collect_cargo_manifests(root, &mut manifests)?;
|
||||||
@@ -357,6 +543,23 @@ fn collect_cargo_manifests(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), Str
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_manifest_license(manifest: &str) -> Option<String> {
|
||||||
|
let mut in_package = false;
|
||||||
|
let mut in_workspace_package = false;
|
||||||
|
for line in manifest.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with('[') {
|
||||||
|
in_package = trimmed == "[package]";
|
||||||
|
in_workspace_package = trimmed == "[workspace.package]";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (in_package || in_workspace_package) && trimmed.starts_with("license") {
|
||||||
|
return parse_toml_string_value(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_package_name(manifest: &str) -> Option<String> {
|
fn parse_package_name(manifest: &str) -> Option<String> {
|
||||||
let mut in_package = false;
|
let mut in_package = false;
|
||||||
for line in manifest.lines() {
|
for line in manifest.lines() {
|
||||||
@@ -640,6 +843,7 @@ impl fmt::Display for Stage {
|
|||||||
struct TestOptions {
|
struct TestOptions {
|
||||||
stage: Stage,
|
stage: Stage,
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
|
manifest: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -670,6 +874,7 @@ struct AcceptanceOptions {
|
|||||||
suite: TestSuite,
|
suite: TestSuite,
|
||||||
stage: Stage,
|
stage: Stage,
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
|
manifest: Option<PathBuf>,
|
||||||
out: PathBuf,
|
out: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,6 +890,7 @@ fn parse_test_options(args: &[String], default_root: PathBuf) -> Result<TestOpti
|
|||||||
let mut options = TestOptions {
|
let mut options = TestOptions {
|
||||||
stage: Stage::All,
|
stage: Stage::All,
|
||||||
root: default_root,
|
root: default_root,
|
||||||
|
manifest: None,
|
||||||
};
|
};
|
||||||
let mut iter = args.iter();
|
let mut iter = args.iter();
|
||||||
while let Some(arg) = iter.next() {
|
while let Some(arg) = iter.next() {
|
||||||
@@ -701,6 +907,12 @@ fn parse_test_options(args: &[String], default_root: PathBuf) -> Result<TestOpti
|
|||||||
.ok_or_else(|| "--root requires a path".to_string())?;
|
.ok_or_else(|| "--root requires a path".to_string())?;
|
||||||
options.root = PathBuf::from(value);
|
options.root = PathBuf::from(value);
|
||||||
}
|
}
|
||||||
|
"--manifest" => {
|
||||||
|
let value = iter
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "--manifest requires a path".to_string())?;
|
||||||
|
options.manifest = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
_ => return Err(format!("unknown test option: {arg}")),
|
_ => return Err(format!("unknown test option: {arg}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -711,6 +923,7 @@ fn parse_acceptance_options(args: &[String]) -> Result<AcceptanceOptions, String
|
|||||||
let mut suite = None;
|
let mut suite = None;
|
||||||
let mut stage = Stage::All;
|
let mut stage = Stage::All;
|
||||||
let mut root = PathBuf::from("testdata");
|
let mut root = PathBuf::from("testdata");
|
||||||
|
let mut manifest = None;
|
||||||
let mut out = None;
|
let mut out = None;
|
||||||
let mut iter = args.iter();
|
let mut iter = args.iter();
|
||||||
while let Some(arg) = iter.next() {
|
while let Some(arg) = iter.next() {
|
||||||
@@ -733,6 +946,12 @@ fn parse_acceptance_options(args: &[String]) -> Result<AcceptanceOptions, String
|
|||||||
.ok_or_else(|| "--root requires a path".to_string())?;
|
.ok_or_else(|| "--root requires a path".to_string())?;
|
||||||
root = PathBuf::from(value);
|
root = PathBuf::from(value);
|
||||||
}
|
}
|
||||||
|
"--manifest" => {
|
||||||
|
let value = iter
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "--manifest requires a path".to_string())?;
|
||||||
|
manifest = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
"--out" => {
|
"--out" => {
|
||||||
let value = iter
|
let value = iter
|
||||||
.next()
|
.next()
|
||||||
@@ -755,6 +974,7 @@ fn parse_acceptance_options(args: &[String]) -> Result<AcceptanceOptions, String
|
|||||||
suite,
|
suite,
|
||||||
stage,
|
stage,
|
||||||
root,
|
root,
|
||||||
|
manifest,
|
||||||
out,
|
out,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1079,10 +1299,12 @@ fn json_escape(value: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_acceptance_report(options: &AcceptanceOptions) -> Result<(), String> {
|
fn run_acceptance_report(options: &AcceptanceOptions) -> Result<(), String> {
|
||||||
if options.suite == TestSuite::Licensed {
|
let roots = if options.suite == TestSuite::Licensed {
|
||||||
validate_licensed_root(&options.root)?;
|
Some(load_licensed_roots(options.manifest.as_deref())?)
|
||||||
}
|
} else {
|
||||||
run_stage_tests(options.stage)?;
|
None
|
||||||
|
};
|
||||||
|
run_stage_tests(options.stage, options.suite, roots.as_ref())?;
|
||||||
|
|
||||||
if let Some(parent) = options.out.parent() {
|
if let Some(parent) = options.out.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?;
|
fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?;
|
||||||
@@ -1131,12 +1353,35 @@ fn stage_report_packages(stage: Stage) -> Vec<&'static str> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_stage_tests(stage: Stage) -> Result<(), String> {
|
fn run_stage_tests(
|
||||||
|
stage: Stage,
|
||||||
|
suite: TestSuite,
|
||||||
|
roots: Option<&LicensedCorpusRoots>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut suffix = Vec::new();
|
||||||
|
if suite == TestSuite::Licensed {
|
||||||
|
suffix.extend(["--", "--ignored"]);
|
||||||
|
}
|
||||||
|
let envs = roots.map(LicensedCorpusRoots::envs);
|
||||||
match stage {
|
match stage {
|
||||||
Stage::All => cargo(&["test", "--workspace", "--offline"]),
|
Stage::All => {
|
||||||
|
let mut args = vec!["test", "--workspace", "--locked", "--offline"];
|
||||||
|
args.extend(suffix);
|
||||||
|
if let Some(envs) = envs {
|
||||||
|
cargo_with_env(&args, &envs)
|
||||||
|
} else {
|
||||||
|
cargo(&args)
|
||||||
|
}
|
||||||
|
}
|
||||||
Stage::Number(number) => {
|
Stage::Number(number) => {
|
||||||
for package in stage_packages(number)? {
|
for package in stage_packages(number)? {
|
||||||
cargo(&["test", "-p", package, "--offline"])?;
|
let mut args = vec!["test", "-p", package, "--locked", "--offline"];
|
||||||
|
args.extend(suffix.iter().copied());
|
||||||
|
if let Some(envs) = envs {
|
||||||
|
cargo_with_env(&args, &envs)?;
|
||||||
|
} else {
|
||||||
|
cargo(&args)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1189,6 +1434,13 @@ mod tests {
|
|||||||
values.iter().map(|value| (*value).to_string()).collect()
|
values.iter().map(|value| (*value).to_string()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn temp_dir(name: &str) -> PathBuf {
|
||||||
|
let suffix = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_or(0, |duration| duration.as_nanos());
|
||||||
|
std::env::temp_dir().join(format!("fparkan-xtask-{name}-{suffix}"))
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_stage_and_root_options() {
|
fn parses_stage_and_root_options() {
|
||||||
let args = strings(&["--stage", "3", "--root", "fixtures"]);
|
let args = strings(&["--stage", "3", "--root", "fixtures"]);
|
||||||
@@ -1199,6 +1451,7 @@ mod tests {
|
|||||||
Ok(TestOptions {
|
Ok(TestOptions {
|
||||||
stage: Stage::Number(3),
|
stage: Stage::Number(3),
|
||||||
root: PathBuf::from("fixtures"),
|
root: PathBuf::from("fixtures"),
|
||||||
|
manifest: None,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1210,8 +1463,8 @@ mod tests {
|
|||||||
"licensed",
|
"licensed",
|
||||||
"--stage",
|
"--stage",
|
||||||
"5",
|
"5",
|
||||||
"--root",
|
"--manifest",
|
||||||
"testdata",
|
"corpora.toml",
|
||||||
"--out",
|
"--out",
|
||||||
"target/report.json",
|
"target/report.json",
|
||||||
]));
|
]));
|
||||||
@@ -1222,6 +1475,7 @@ mod tests {
|
|||||||
suite: TestSuite::Licensed,
|
suite: TestSuite::Licensed,
|
||||||
stage: Stage::Number(5),
|
stage: Stage::Number(5),
|
||||||
root: PathBuf::from("testdata"),
|
root: PathBuf::from("testdata"),
|
||||||
|
manifest: Some(PathBuf::from("corpora.toml")),
|
||||||
out: PathBuf::from("target/report.json"),
|
out: PathBuf::from("target/report.json"),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -1233,6 +1487,7 @@ mod tests {
|
|||||||
suite: TestSuite::Licensed,
|
suite: TestSuite::Licensed,
|
||||||
stage: Stage::Number(0),
|
stage: Stage::Number(0),
|
||||||
root: PathBuf::from("/private/game"),
|
root: PathBuf::from("/private/game"),
|
||||||
|
manifest: Some(PathBuf::from("/private/corpora.toml")),
|
||||||
out: PathBuf::from("target/report.json"),
|
out: PathBuf::from("target/report.json"),
|
||||||
};
|
};
|
||||||
let report = render_acceptance_report(&options);
|
let report = render_acceptance_report(&options);
|
||||||
@@ -1334,10 +1589,54 @@ mod tests {
|
|||||||
Ok(TestOptions {
|
Ok(TestOptions {
|
||||||
stage: Stage::All,
|
stage: Stage::All,
|
||||||
root: PathBuf::from("testdata"),
|
root: PathBuf::from("testdata"),
|
||||||
|
manifest: None,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_licensed_corpora_manifest() -> Result<(), String> {
|
||||||
|
let root = temp_dir("manifest");
|
||||||
|
let part1 = root.join("IS");
|
||||||
|
let part2 = root.join("IS2");
|
||||||
|
fs::create_dir_all(&part1).map_err(|err| err.to_string())?;
|
||||||
|
fs::create_dir_all(&part2).map_err(|err| err.to_string())?;
|
||||||
|
let manifest = root.join("corpora.toml");
|
||||||
|
fs::write(
|
||||||
|
&manifest,
|
||||||
|
format!(
|
||||||
|
"schema = 1\n\n[[corpus]]\nid = \"part1-local\"\nkind = \"part1\"\nroot = \"{}\"\nexpected_profile = \"parkan-is-part1\"\n\n[[corpus]]\nid = \"part2-local\"\nkind = \"part2\"\nroot = \"{}\"\nexpected_profile = \"parkan-is-part2\"\n",
|
||||||
|
part1.display(),
|
||||||
|
part2.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parse_licensed_manifest(&manifest)?,
|
||||||
|
LicensedCorpusRoots { part1, part2 }
|
||||||
|
);
|
||||||
|
fs::remove_dir_all(root).map_err(|err| err.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn licensed_roots_require_manifest_configuration() {
|
||||||
|
let previous = std::env::var_os(CORPORA_MANIFEST_ENV);
|
||||||
|
std::env::remove_var(CORPORA_MANIFEST_ENV);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
load_licensed_roots(None),
|
||||||
|
Err(format!(
|
||||||
|
"licensed tests require --manifest or {CORPORA_MANIFEST_ENV}=<absolute corpora.toml>"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(value) = previous {
|
||||||
|
std::env::set_var(CORPORA_MANIFEST_ENV, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_stage() {
|
fn rejects_unknown_stage() {
|
||||||
assert_eq!(Stage::parse("6"), Err("stage out of range: 6".to_string()));
|
assert_eq!(Stage::parse("6"), Err("stage out of range: 6".to_string()));
|
||||||
|
|||||||
Reference in New Issue
Block a user