From d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Mon, 22 Jun 2026 13:12:27 +0400 Subject: feat: implement FParkan architecture foundation Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation. --- .cargo/config.toml | 2 + Cargo.toml | 58 +- README.md | 19 +- adapters/fparkan-platform-sdl/Cargo.toml | 12 + adapters/fparkan-platform-sdl/src/lib.rs | 123 ++ adapters/fparkan-render-gl/Cargo.toml | 12 + adapters/fparkan-render-gl/src/lib.rs | 242 +++ adr/ADR-0001-modular-monolith.md | 5 + adr/ADR-0002-behavior-not-abi.md | 5 + adr/ADR-0003-raw-and-interpreted-data.md | 5 + adr/ADR-0004-synthetic-and-licensed-tests.md | 5 + adr/ADR-0005-deterministic-reference-runtime.md | 5 + adr/ADR-0006-error-policy.md | 5 + adr/ADR-0007-safe-sdl-opengl.md | 5 + apps/fparkan-cli/Cargo.toml | 18 + apps/fparkan-cli/src/main.rs | 346 ++++ apps/fparkan-game/Cargo.toml | 15 + apps/fparkan-game/src/main.rs | 322 +++ apps/fparkan-headless/Cargo.toml | 14 + apps/fparkan-headless/src/main.rs | 114 ++ apps/fparkan-viewer/Cargo.toml | 19 + apps/fparkan-viewer/src/main.rs | 353 ++++ crates/common/Cargo.toml | 6 - crates/common/src/lib.rs | 61 - crates/fparkan-animation/Cargo.toml | 11 + crates/fparkan-animation/src/lib.rs | 1217 +++++++++++ crates/fparkan-assets/Cargo.toml | 21 + crates/fparkan-assets/src/lib.rs | 481 +++++ crates/fparkan-binary/Cargo.toml | 11 + crates/fparkan-binary/src/lib.rs | 308 +++ crates/fparkan-corpus/Cargo.toml | 12 + crates/fparkan-corpus/src/lib.rs | 695 +++++++ crates/fparkan-diagnostics/Cargo.toml | 11 + crates/fparkan-diagnostics/src/lib.rs | 301 +++ crates/fparkan-fx/Cargo.toml | 15 + crates/fparkan-fx/src/lib.rs | 1025 ++++++++++ crates/fparkan-material/Cargo.toml | 18 + crates/fparkan-material/src/lib.rs | 1272 ++++++++++++ crates/fparkan-mission-format/Cargo.toml | 13 + crates/fparkan-mission-format/src/lib.rs | 1172 +++++++++++ crates/fparkan-msh/Cargo.toml | 16 + crates/fparkan-msh/src/lib.rs | 1767 ++++++++++++++++ crates/fparkan-nres/Cargo.toml | 13 + crates/fparkan-nres/src/lib.rs | 1935 ++++++++++++++++++ crates/fparkan-path/Cargo.toml | 11 + crates/fparkan-path/src/lib.rs | 259 +++ crates/fparkan-platform/Cargo.toml | 11 + crates/fparkan-platform/src/lib.rs | 93 + crates/fparkan-prototype/Cargo.toml | 20 + crates/fparkan-prototype/src/lib.rs | 2114 ++++++++++++++++++++ crates/fparkan-render/Cargo.toml | 12 + crates/fparkan-render/src/lib.rs | 554 +++++ crates/fparkan-resource/Cargo.toml | 15 + crates/fparkan-resource/src/lib.rs | 880 ++++++++ crates/fparkan-rsli/Cargo.toml | 12 + crates/fparkan-rsli/src/lib.rs | 2113 +++++++++++++++++++ crates/fparkan-runtime/Cargo.toml | 22 + crates/fparkan-runtime/src/lib.rs | 1099 ++++++++++ crates/fparkan-terrain-format/Cargo.toml | 13 + crates/fparkan-terrain-format/src/lib.rs | 1910 ++++++++++++++++++ crates/fparkan-terrain/Cargo.toml | 15 + crates/fparkan-terrain/src/lib.rs | 1079 ++++++++++ crates/fparkan-test-support/Cargo.toml | 12 + crates/fparkan-test-support/src/lib.rs | 25 + crates/fparkan-texm/Cargo.toml | 14 + crates/fparkan-texm/src/lib.rs | 1187 +++++++++++ crates/fparkan-vfs/Cargo.toml | 12 + crates/fparkan-vfs/src/lib.rs | 456 +++++ crates/fparkan-world/Cargo.toml | 11 + crates/fparkan-world/src/lib.rs | 840 ++++++++ crates/msh-core/Cargo.toml | 12 - crates/msh-core/README.md | 14 - crates/msh-core/src/error.rs | 75 - crates/msh-core/src/lib.rs | 434 ---- crates/msh-core/src/tests.rs | 438 ---- crates/nres/Cargo.toml | 10 - crates/nres/README.md | 42 - crates/nres/src/error.rs | 110 - crates/nres/src/lib.rs | 772 ------- crates/nres/src/tests.rs | 983 --------- crates/render-core/Cargo.toml | 11 - crates/render-core/README.md | 14 - crates/render-core/src/lib.rs | 146 -- crates/render-core/src/tests.rs | 256 --- crates/render-demo/Cargo.toml | 31 - crates/render-demo/README.md | 84 - crates/render-demo/build.rs | 4 - crates/render-demo/src/lib.rs | 591 ------ crates/render-demo/src/main.rs | 997 --------- crates/render-mission-demo/Cargo.toml | 33 - crates/render-mission-demo/src/lib.rs | 881 -------- crates/render-mission-demo/src/main.rs | 924 --------- crates/render-parity/Cargo.toml | 9 - crates/render-parity/README.md | 16 - crates/render-parity/src/lib.rs | 212 -- crates/render-parity/src/main.rs | 405 ---- crates/rsli/Cargo.toml | 11 - crates/rsli/README.md | 58 - crates/rsli/src/compress/deflate.rs | 14 - crates/rsli/src/compress/lzh.rs | 303 --- crates/rsli/src/compress/lzss.rs | 79 - crates/rsli/src/compress/mod.rs | 9 - crates/rsli/src/compress/xor.rs | 29 - crates/rsli/src/error.rs | 140 -- crates/rsli/src/lib.rs | 470 ----- crates/rsli/src/parse.rs | 278 --- crates/rsli/src/tests.rs | 1338 ------------- crates/terrain-core/Cargo.toml | 10 - crates/terrain-core/src/lib.rs | 281 --- crates/texm/Cargo.toml | 9 - crates/texm/README.md | 15 - crates/texm/src/error.rs | 86 - crates/texm/src/lib.rs | 417 ---- crates/texm/src/tests.rs | 330 --- crates/tma/Cargo.toml | 10 - crates/tma/src/lib.rs | 485 ----- crates/unitdat/Cargo.toml | 10 - crates/unitdat/src/lib.rs | 180 -- docs/appendices/knowledge-boundaries.md | 33 + docs/baseline/current-project-audit.md | 13 + fixtures/acceptance/coverage.tsv | 365 ++++ .../acceptance/macos-gl33-triangle-capture.json | 17 + fixtures/schemas/corpus-manifest.schema.json | 19 + fixtures/schemas/report.schema.json | 10 + rust-toolchain.toml | 3 + testdata/.gitignore | 3 + xtask/Cargo.toml | 12 + xtask/src/main.rs | 1442 +++++++++++++ 128 files changed, 26720 insertions(+), 12137 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 adapters/fparkan-platform-sdl/Cargo.toml create mode 100644 adapters/fparkan-platform-sdl/src/lib.rs create mode 100644 adapters/fparkan-render-gl/Cargo.toml create mode 100644 adapters/fparkan-render-gl/src/lib.rs create mode 100644 adr/ADR-0001-modular-monolith.md create mode 100644 adr/ADR-0002-behavior-not-abi.md create mode 100644 adr/ADR-0003-raw-and-interpreted-data.md create mode 100644 adr/ADR-0004-synthetic-and-licensed-tests.md create mode 100644 adr/ADR-0005-deterministic-reference-runtime.md create mode 100644 adr/ADR-0006-error-policy.md create mode 100644 adr/ADR-0007-safe-sdl-opengl.md create mode 100644 apps/fparkan-cli/Cargo.toml create mode 100644 apps/fparkan-cli/src/main.rs create mode 100644 apps/fparkan-game/Cargo.toml create mode 100644 apps/fparkan-game/src/main.rs create mode 100644 apps/fparkan-headless/Cargo.toml create mode 100644 apps/fparkan-headless/src/main.rs create mode 100644 apps/fparkan-viewer/Cargo.toml create mode 100644 apps/fparkan-viewer/src/main.rs delete mode 100644 crates/common/Cargo.toml delete mode 100644 crates/common/src/lib.rs create mode 100644 crates/fparkan-animation/Cargo.toml create mode 100644 crates/fparkan-animation/src/lib.rs create mode 100644 crates/fparkan-assets/Cargo.toml create mode 100644 crates/fparkan-assets/src/lib.rs create mode 100644 crates/fparkan-binary/Cargo.toml create mode 100644 crates/fparkan-binary/src/lib.rs create mode 100644 crates/fparkan-corpus/Cargo.toml create mode 100644 crates/fparkan-corpus/src/lib.rs create mode 100644 crates/fparkan-diagnostics/Cargo.toml create mode 100644 crates/fparkan-diagnostics/src/lib.rs create mode 100644 crates/fparkan-fx/Cargo.toml create mode 100644 crates/fparkan-fx/src/lib.rs create mode 100644 crates/fparkan-material/Cargo.toml create mode 100644 crates/fparkan-material/src/lib.rs create mode 100644 crates/fparkan-mission-format/Cargo.toml create mode 100644 crates/fparkan-mission-format/src/lib.rs create mode 100644 crates/fparkan-msh/Cargo.toml create mode 100644 crates/fparkan-msh/src/lib.rs create mode 100644 crates/fparkan-nres/Cargo.toml create mode 100644 crates/fparkan-nres/src/lib.rs create mode 100644 crates/fparkan-path/Cargo.toml create mode 100644 crates/fparkan-path/src/lib.rs create mode 100644 crates/fparkan-platform/Cargo.toml create mode 100644 crates/fparkan-platform/src/lib.rs create mode 100644 crates/fparkan-prototype/Cargo.toml create mode 100644 crates/fparkan-prototype/src/lib.rs create mode 100644 crates/fparkan-render/Cargo.toml create mode 100644 crates/fparkan-render/src/lib.rs create mode 100644 crates/fparkan-resource/Cargo.toml create mode 100644 crates/fparkan-resource/src/lib.rs create mode 100644 crates/fparkan-rsli/Cargo.toml create mode 100644 crates/fparkan-rsli/src/lib.rs create mode 100644 crates/fparkan-runtime/Cargo.toml create mode 100644 crates/fparkan-runtime/src/lib.rs create mode 100644 crates/fparkan-terrain-format/Cargo.toml create mode 100644 crates/fparkan-terrain-format/src/lib.rs create mode 100644 crates/fparkan-terrain/Cargo.toml create mode 100644 crates/fparkan-terrain/src/lib.rs create mode 100644 crates/fparkan-test-support/Cargo.toml create mode 100644 crates/fparkan-test-support/src/lib.rs create mode 100644 crates/fparkan-texm/Cargo.toml create mode 100644 crates/fparkan-texm/src/lib.rs create mode 100644 crates/fparkan-vfs/Cargo.toml create mode 100644 crates/fparkan-vfs/src/lib.rs create mode 100644 crates/fparkan-world/Cargo.toml create mode 100644 crates/fparkan-world/src/lib.rs delete mode 100644 crates/msh-core/Cargo.toml delete mode 100644 crates/msh-core/README.md delete mode 100644 crates/msh-core/src/error.rs delete mode 100644 crates/msh-core/src/lib.rs delete mode 100644 crates/msh-core/src/tests.rs delete mode 100644 crates/nres/Cargo.toml delete mode 100644 crates/nres/README.md delete mode 100644 crates/nres/src/error.rs delete mode 100644 crates/nres/src/lib.rs delete mode 100644 crates/nres/src/tests.rs delete mode 100644 crates/render-core/Cargo.toml delete mode 100644 crates/render-core/README.md delete mode 100644 crates/render-core/src/lib.rs delete mode 100644 crates/render-core/src/tests.rs delete mode 100644 crates/render-demo/Cargo.toml delete mode 100644 crates/render-demo/README.md delete mode 100644 crates/render-demo/build.rs delete mode 100644 crates/render-demo/src/lib.rs delete mode 100644 crates/render-demo/src/main.rs delete mode 100644 crates/render-mission-demo/Cargo.toml delete mode 100644 crates/render-mission-demo/src/lib.rs delete mode 100644 crates/render-mission-demo/src/main.rs delete mode 100644 crates/render-parity/Cargo.toml delete mode 100644 crates/render-parity/README.md delete mode 100644 crates/render-parity/src/lib.rs delete mode 100644 crates/render-parity/src/main.rs delete mode 100644 crates/rsli/Cargo.toml delete mode 100644 crates/rsli/README.md delete mode 100644 crates/rsli/src/compress/deflate.rs delete mode 100644 crates/rsli/src/compress/lzh.rs delete mode 100644 crates/rsli/src/compress/lzss.rs delete mode 100644 crates/rsli/src/compress/mod.rs delete mode 100644 crates/rsli/src/compress/xor.rs delete mode 100644 crates/rsli/src/error.rs delete mode 100644 crates/rsli/src/lib.rs delete mode 100644 crates/rsli/src/parse.rs delete mode 100644 crates/rsli/src/tests.rs delete mode 100644 crates/terrain-core/Cargo.toml delete mode 100644 crates/terrain-core/src/lib.rs delete mode 100644 crates/texm/Cargo.toml delete mode 100644 crates/texm/README.md delete mode 100644 crates/texm/src/error.rs delete mode 100644 crates/texm/src/lib.rs delete mode 100644 crates/texm/src/tests.rs delete mode 100644 crates/tma/Cargo.toml delete mode 100644 crates/tma/src/lib.rs delete mode 100644 crates/unitdat/Cargo.toml delete mode 100644 crates/unitdat/src/lib.rs create mode 100644 docs/baseline/current-project-audit.md create mode 100644 fixtures/acceptance/coverage.tsv create mode 100644 fixtures/acceptance/macos-gl33-triangle-capture.json create mode 100644 fixtures/schemas/corpus-manifest.schema.json create mode 100644 fixtures/schemas/report.schema.json create mode 100644 rust-toolchain.toml create mode 100644 testdata/.gitignore create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/main.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..5592118 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run -p xtask --" diff --git a/Cargo.toml b/Cargo.toml index 34c501a..a14eb8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,62 @@ [workspace] resolver = "3" -members = ["crates/*"] +members = [ + "crates/fparkan-animation", + "crates/fparkan-assets", + "crates/fparkan-binary", + "crates/fparkan-corpus", + "crates/fparkan-diagnostics", + "crates/fparkan-fx", + "crates/fparkan-material", + "crates/fparkan-mission-format", + "crates/fparkan-msh", + "crates/fparkan-nres", + "crates/fparkan-path", + "crates/fparkan-platform", + "crates/fparkan-prototype", + "crates/fparkan-render", + "crates/fparkan-resource", + "crates/fparkan-rsli", + "crates/fparkan-runtime", + "crates/fparkan-terrain", + "crates/fparkan-terrain-format", + "crates/fparkan-test-support", + "crates/fparkan-texm", + "crates/fparkan-vfs", + "crates/fparkan-world", + "adapters/fparkan-platform-sdl", + "adapters/fparkan-render-gl", + "apps/fparkan-cli", + "apps/fparkan-game", + "apps/fparkan-headless", + "apps/fparkan-viewer", + "xtask", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" +repository = "https://github.com/valentineus/fparkan" + +[workspace.lints.rust] +unsafe_code = "forbid" +missing_docs = "warn" +unreachable_pub = "warn" +unused_must_use = "deny" + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +pedantic = { level = "warn", priority = -1 } +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +todo = "deny" +unimplemented = "deny" +dbg_macro = "deny" +print_stdout = "warn" +print_stderr = "warn" +lossy_float_literal = "deny" [profile.release] codegen-units = 1 diff --git a/README.md b/README.md index 92a3b64..80914ef 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,19 @@ Open source проект с реализацией компонентов игр ## Библиотеки -- [crates/nres](crates/nres) — библиотека для работы с файлами архивов NRes (чтение, поиск, редактирование, сохранение). -- [crates/rsli](crates/rsli) — библиотека для работы с файлами архивов RsLi (чтение, поиск, загрузка/распаковка поддерживаемых методов). +- [crates/fparkan-nres](crates/fparkan-nres) — strict/lossless модель архивов NRes. +- [crates/fparkan-rsli](crates/fparkan-rsli) — чтение, lookup и lossless roundtrip архивов RsLi. +- [crates/fparkan-msh](crates/fparkan-msh) — validated static MSH geometry. +- [crates/fparkan-runtime](crates/fparkan-runtime) — transactional mission loading и headless runtime foundation. +- [apps/fparkan-cli](apps/fparkan-cli), [apps/fparkan-viewer](apps/fparkan-viewer), [apps/fparkan-headless](apps/fparkan-headless), [apps/fparkan-game](apps/fparkan-game) — composition roots. ## Тестирование -Базовое тестирование проходит на синтетических тестах из репозитория. +Базовое тестирование проходит на синтетических тестах из репозитория: + +```bash +cargo xtask ci +``` Для дополнительного тестирования на реальных игровых ресурсах: @@ -33,6 +40,12 @@ Open source проект с реализацией компонентов игр - разместите игровые каталоги в [`testdata/`](testdata); - игровые ресурсы в репозиторий не включаются, так как защищены авторским правом. +Локальный licensed gate: + +```bash +cargo xtask acceptance report --suite licensed --stage 5 --root testdata +``` + ## Contributing & Support Проект активно поддерживается и открыт для contribution. Issues и pull requests можно создавать в обоих репозиториях: diff --git a/adapters/fparkan-platform-sdl/Cargo.toml b/adapters/fparkan-platform-sdl/Cargo.toml new file mode 100644 index 0000000..fd9b040 --- /dev/null +++ b/adapters/fparkan-platform-sdl/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fparkan-platform-sdl" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-platform = { path = "../../crates/fparkan-platform" } + +[lints] +workspace = true diff --git a/adapters/fparkan-platform-sdl/src/lib.rs b/adapters/fparkan-platform-sdl/src/lib.rs new file mode 100644 index 0000000..73aea1f --- /dev/null +++ b/adapters/fparkan-platform-sdl/src/lib.rs @@ -0,0 +1,123 @@ +#![forbid(unsafe_code)] +//! SDL platform adapter proof behind safe `FParkan` ports. + +use fparkan_platform::{ + EventSource, GraphicsContextRequest, GraphicsProfile, PhysicalSize, PlatformError, + PlatformEvent, Version, WindowPort, +}; + +/// Adapter capabilities compiled into this package. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SdlAdapterCapabilities { + /// Supported graphics context requests in preference order. + pub graphics: Vec, + /// Whether adapter-owned code is free of `unsafe`. + pub project_owned_unsafe_free: bool, +} + +impl Default for SdlAdapterCapabilities { + fn default() -> Self { + Self { + graphics: vec![ + GraphicsContextRequest { + profile: GraphicsProfile::DesktopCore, + version: Version { major: 3, minor: 3 }, + }, + GraphicsContextRequest { + profile: GraphicsProfile::Embedded, + version: Version { major: 2, minor: 0 }, + }, + ], + project_owned_unsafe_free: true, + } + } +} + +/// Returns adapter readiness status for the safe project-owned layer. +#[must_use] +pub fn safe_adapter_ready() -> bool { + SdlAdapterCapabilities::default().project_owned_unsafe_free +} + +/// In-memory event source used by adapter smoke tests and composition roots +/// before a concrete SDL runtime is injected. +#[derive(Clone, Debug, Default)] +pub struct SdlEventSourceProof { + pending: Vec, +} + +impl SdlEventSourceProof { + /// Creates an event source with deterministic pending events. + #[must_use] + pub fn new(pending: Vec) -> Self { + Self { pending } + } +} + +impl EventSource for SdlEventSourceProof { + fn poll(&mut self, out: &mut Vec) -> Result<(), PlatformError> { + out.append(&mut self.pending); + Ok(()) + } +} + +/// Safe window-port proof with SDL-compatible drawable-size semantics. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SdlWindowProof { + size: PhysicalSize, + presents: u64, +} + +impl SdlWindowProof { + /// Creates a proof window with a fixed drawable size. + #[must_use] + pub fn new(size: PhysicalSize) -> Self { + Self { size, presents: 0 } + } + + /// Number of successful present calls. + #[must_use] + pub fn presents(&self) -> u64 { + self.presents + } +} + +impl WindowPort for SdlWindowProof { + fn drawable_size(&self) -> PhysicalSize { + self.size + } + + fn present(&mut self) -> Result<(), PlatformError> { + self.presents = self.presents.saturating_add(1); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adapter_reports_safe_project_layer_ready() { + assert!(safe_adapter_ready()); + assert_eq!(SdlAdapterCapabilities::default().graphics.len(), 2); + } + + #[test] + fn event_source_and_window_ports_are_deterministic() -> Result<(), PlatformError> { + let mut source = SdlEventSourceProof::new(vec![PlatformEvent::Quit]); + let mut events = Vec::new(); + source.poll(&mut events)?; + source.poll(&mut events)?; + assert_eq!(events, vec![PlatformEvent::Quit]); + + let mut window = SdlWindowProof::new(PhysicalSize { + width: 320, + height: 240, + }); + assert_eq!(window.drawable_size().width, 320); + window.present()?; + assert_eq!(window.presents(), 1); + Ok(()) + } +} diff --git a/adapters/fparkan-render-gl/Cargo.toml b/adapters/fparkan-render-gl/Cargo.toml new file mode 100644 index 0000000..4fcf403 --- /dev/null +++ b/adapters/fparkan-render-gl/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fparkan-render-gl" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-render = { path = "../../crates/fparkan-render" } + +[lints] +workspace = true diff --git a/adapters/fparkan-render-gl/src/lib.rs b/adapters/fparkan-render-gl/src/lib.rs new file mode 100644 index 0000000..094b1ad --- /dev/null +++ b/adapters/fparkan-render-gl/src/lib.rs @@ -0,0 +1,242 @@ +#![forbid(unsafe_code)] +//! OpenGL render adapter proof behind safe `FParkan` render ports. + +use fparkan_render::{ + canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError, +}; + +/// Portable OpenGL profile requested by the game composition root. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GlProfile { + /// Desktop OpenGL 3.3 Core. + DesktopCore33, + /// OpenGL ES 2.0 portable baseline. + Gles2, +} + +/// Shader stage used in diagnostics. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ShaderStage { + /// Vertex shader. + Vertex, + /// Fragment shader. + Fragment, +} + +/// Shader compilation diagnostic surfaced by the adapter. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ShaderCompileError { + /// Requested GL profile. + pub profile: GlProfile, + /// Shader stage. + pub stage: ShaderStage, + /// Backend compiler log. + pub log: String, +} + +impl std::fmt::Display for ShaderCompileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{:?} {:?} shader compile failed: {}", + self.profile, self.stage, self.log + ) + } +} + +impl std::error::Error for ShaderCompileError {} + +/// Adapter capabilities compiled into this package. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GlAdapterCapabilities { + /// Supported profiles in preference order. + pub profiles: Vec, + /// Whether adapter-owned code is free of `unsafe`. + pub project_owned_unsafe_free: bool, +} + +impl Default for GlAdapterCapabilities { + fn default() -> Self { + Self { + profiles: vec![GlProfile::DesktopCore33, GlProfile::Gles2], + project_owned_unsafe_free: true, + } + } +} + +/// Returns adapter readiness status for the safe project-owned layer. +#[must_use] +pub fn safe_adapter_ready() -> bool { + GlAdapterCapabilities::default().project_owned_unsafe_free +} + +/// Validates shader source through the adapter diagnostic contract. +/// +/// # Errors +/// +/// Returns [`ShaderCompileError`] when the source is empty or contains a +/// deterministic synthetic failure marker. +pub fn compile_shader_source( + profile: GlProfile, + stage: ShaderStage, + source: &str, +) -> Result<(), ShaderCompileError> { + if source.trim().is_empty() { + return Err(ShaderCompileError { + profile, + stage, + log: "empty shader source".to_string(), + }); + } + if source.contains("#error") { + return Err(ShaderCompileError { + profile, + stage, + log: "synthetic compiler failure marker".to_string(), + }); + } + Ok(()) +} + +/// Safe render backend facade used for adapter-level command validation. +/// +/// A concrete OpenGL implementation can be injected behind the same +/// [`RenderBackend`] port once an audited safe GL facade is selected. This type +/// keeps the project-owned adapter API executable without introducing local FFI. +#[derive(Clone, Debug)] +pub struct SafeGlCommandBackend { + profile: GlProfile, + captures: Vec>, +} + +impl SafeGlCommandBackend { + /// Creates a backend proof for a requested GL profile. + #[must_use] + pub fn new(profile: GlProfile) -> Self { + Self { + profile, + captures: Vec::new(), + } + } + + /// Active GL profile. + #[must_use] + pub fn profile(&self) -> GlProfile { + self.profile + } + + /// Deterministic command captures produced by executed frames. + #[must_use] + pub fn captures(&self) -> &[Vec] { + &self.captures + } +} + +impl RenderBackend for SafeGlCommandBackend { + fn execute(&mut self, commands: &RenderCommandList) -> Result { + self.captures.push(canonical_capture(commands)?); + Ok(FrameOutput) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_render::{ + DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderCommand, RenderPhase, + }; + + #[test] + fn adapter_reports_safe_project_layer_ready() { + assert!(safe_adapter_ready()); + assert_eq!(GlAdapterCapabilities::default().profiles.len(), 2); + } + + #[test] + fn backend_executes_and_captures_commands() -> Result<(), RenderError> { + let mut backend = SafeGlCommandBackend::new(GlProfile::Gles2); + let commands = RenderCommandList { + commands: vec![ + RenderCommand::BeginFrame, + RenderCommand::Draw(DrawCommand { + id: DrawId(7), + phase: RenderPhase::Opaque, + object_id: None, + mesh: GpuMeshId(11), + material: GpuMaterialId(13), + transform: [0.0; 16], + range: IndexRange { start: 0, count: 3 }, + stable_order: 17, + }), + RenderCommand::EndFrame, + ], + }; + + backend.execute(&commands)?; + + assert_eq!(backend.profile(), GlProfile::Gles2); + assert_eq!(backend.captures().len(), 1); + Ok(()) + } + + #[test] + fn desktop_gl33_triangle_command_capture() -> Result<(), RenderError> { + let mut backend = SafeGlCommandBackend::new(GlProfile::DesktopCore33); + let commands = triangle_commands(); + + backend.execute(&commands)?; + + assert_eq!(backend.profile(), GlProfile::DesktopCore33); + assert_eq!( + backend.captures(), + &[b"B\nD,Opaque,7,11,13,17\nE\n".to_vec()] + ); + Ok(()) + } + + #[test] + fn gles2_triangle_command_capture() -> Result<(), RenderError> { + let mut backend = SafeGlCommandBackend::new(GlProfile::Gles2); + let commands = triangle_commands(); + + backend.execute(&commands)?; + + assert_eq!(backend.profile(), GlProfile::Gles2); + assert_eq!( + backend.captures(), + &[b"B\nD,Opaque,7,11,13,17\nE\n".to_vec()] + ); + Ok(()) + } + + #[test] + fn shader_compile_failure_diagnostic_contains_profile_and_log() { + let err = compile_shader_source(GlProfile::Gles2, ShaderStage::Fragment, "#error") + .expect_err("shader failure"); + + assert_eq!(err.profile, GlProfile::Gles2); + assert_eq!(err.stage, ShaderStage::Fragment); + assert!(err.log.contains("synthetic compiler failure")); + assert!(err.to_string().contains("Gles2")); + assert!(err.to_string().contains("synthetic compiler failure")); + } + + fn triangle_commands() -> RenderCommandList { + RenderCommandList { + commands: vec![ + RenderCommand::BeginFrame, + RenderCommand::Draw(DrawCommand { + id: DrawId(7), + phase: RenderPhase::Opaque, + object_id: None, + mesh: GpuMeshId(11), + material: GpuMaterialId(13), + transform: [0.0; 16], + range: IndexRange { start: 0, count: 3 }, + stable_order: 17, + }), + RenderCommand::EndFrame, + ], + } + } +} diff --git a/adr/ADR-0001-modular-monolith.md b/adr/ADR-0001-modular-monolith.md new file mode 100644 index 0000000..82c36b9 --- /dev/null +++ b/adr/ADR-0001-modular-monolith.md @@ -0,0 +1,5 @@ +# ADR-0001: Modular Monolith + +Status: accepted + +FParkan is implemented as one Cargo workspace with local crates grouped by domain. Binaries and adapters compose domain crates; domain crates do not import platform, windowing, OpenGL, GUI, or application packages. diff --git a/adr/ADR-0002-behavior-not-abi.md b/adr/ADR-0002-behavior-not-abi.md new file mode 100644 index 0000000..aea3c7e --- /dev/null +++ b/adr/ADR-0002-behavior-not-abi.md @@ -0,0 +1,5 @@ +# ADR-0002: Behavior Compatibility, Not ABI Compatibility + +Status: accepted + +The project targets clean-room behavior compatibility for formats, resource lookup, loading order, deterministic runtime behavior, and presentation command semantics. It does not reproduce original DLL boundaries, exports, calling conventions, object layouts, RVAs, or native singleton access patterns. diff --git a/adr/ADR-0003-raw-and-interpreted-data.md b/adr/ADR-0003-raw-and-interpreted-data.md new file mode 100644 index 0000000..5938b83 --- /dev/null +++ b/adr/ADR-0003-raw-and-interpreted-data.md @@ -0,0 +1,5 @@ +# ADR-0003: Raw And Interpreted Data + +Status: accepted + +Legacy data models keep raw bytes distinct from validated structure and interpreted domain views. Writers preserve raw data unless an explicit editing profile requests canonical rebuilding. diff --git a/adr/ADR-0004-synthetic-and-licensed-tests.md b/adr/ADR-0004-synthetic-and-licensed-tests.md new file mode 100644 index 0000000..0084ac4 --- /dev/null +++ b/adr/ADR-0004-synthetic-and-licensed-tests.md @@ -0,0 +1,5 @@ +# ADR-0004: Synthetic And Licensed Tests + +Status: accepted + +Synthetic tests run everywhere and contain no proprietary data. Licensed corpus tests require an explicit local manifest and fail when requested without configuration. Reports contain metrics and fingerprints, not payload dumps or absolute game roots. diff --git a/adr/ADR-0005-deterministic-reference-runtime.md b/adr/ADR-0005-deterministic-reference-runtime.md new file mode 100644 index 0000000..5e89d30 --- /dev/null +++ b/adr/ADR-0005-deterministic-reference-runtime.md @@ -0,0 +1,5 @@ +# ADR-0005: Deterministic Reference Runtime + +Status: accepted + +Stages 0-5 use a single-threaded deterministic reference profile. Stable ordering, explicit ticks, named random streams, and canonical captures are part of the contract. Wall-clock time, pointer addresses, hash iteration order, and GPU handles are not semantic inputs. diff --git a/adr/ADR-0006-error-policy.md b/adr/ADR-0006-error-policy.md new file mode 100644 index 0000000..1080497 --- /dev/null +++ b/adr/ADR-0006-error-policy.md @@ -0,0 +1,5 @@ +# ADR-0006: Error Policy + +Status: accepted + +Missing required resources, malformed bytes, unsupported documented branches, capability mismatches, and budget failures are structured errors. Runtime code must not silently skip mandatory objects or convert corrupted data into empty success values. diff --git a/adr/ADR-0007-safe-sdl-opengl.md b/adr/ADR-0007-safe-sdl-opengl.md new file mode 100644 index 0000000..f82c424 --- /dev/null +++ b/adr/ADR-0007-safe-sdl-opengl.md @@ -0,0 +1,5 @@ +# ADR-0007: Safe SDL/OpenGL Boundary + +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. diff --git a/apps/fparkan-cli/Cargo.toml b/apps/fparkan-cli/Cargo.toml new file mode 100644 index 0000000..22952e6 --- /dev/null +++ b/apps/fparkan-cli/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "fparkan-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-corpus = { path = "../../crates/fparkan-corpus" } +fparkan-nres = { path = "../../crates/fparkan-nres" } +fparkan-prototype = { path = "../../crates/fparkan-prototype" } +fparkan-resource = { path = "../../crates/fparkan-resource" } +fparkan-rsli = { path = "../../crates/fparkan-rsli" } +fparkan-runtime = { path = "../../crates/fparkan-runtime" } +fparkan-vfs = { path = "../../crates/fparkan-vfs" } + +[lints] +workspace = true diff --git a/apps/fparkan-cli/src/main.rs b/apps/fparkan-cli/src/main.rs new file mode 100644 index 0000000..6f9f0f6 --- /dev/null +++ b/apps/fparkan-cli/src/main.rs @@ -0,0 +1,346 @@ +#![forbid(unsafe_code)] +#![allow(clippy::print_stderr, clippy::print_stdout)] +//! `FParkan` command-line tools. + +use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions}; +use fparkan_prototype::{ + build_prototype_graph_report, extend_graph_report_with_visual_dependencies, +}; +use fparkan_resource::{resource_name, CachedResourceRepository}; +use fparkan_runtime::{ + create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest, +}; +use fparkan_vfs::DirectoryVfs; +use std::fmt::Write; +use std::path::PathBuf; +use std::sync::Arc; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let result = run(&args); + let code = exit_code(&result); + if let Err(err) = result { + eprintln!("{err}"); + } + std::process::exit(code); +} + +fn run(args: &[String]) -> Result<(), String> { + match args { + [domain, command, rest @ ..] if domain == "corpus" && command == "discover" => { + let rest = strip_format_json(rest)?; + let root = parse_root(&rest)?; + let manifest = + discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?; + let report = report(&root, &manifest); + println!("{}", render_report_json(&report)); + Ok(()) + } + [domain, command, rest @ ..] if domain == "corpus" && command == "validate" => { + let rest = strip_format_json(rest)?; + let root = parse_root(&rest)?; + let manifest = + discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?; + let report = report(&root, &manifest); + if report.casefold_collisions > 0 { + return Err("casefold collisions found".to_string()); + } + println!("{}", render_report_json(&report)); + Ok(()) + } + [domain, command, rest @ ..] if domain == "archive" && command == "inspect" => { + let rest = strip_format_json(rest)?; + inspect_archive(&rest) + } + [domain, command, rest @ ..] if domain == "prototype" && command == "inspect" => { + let rest = strip_format_json(rest)?; + inspect_prototype(&rest) + } + [domain, command, rest @ ..] if domain == "mission" && command == "graph" => { + let rest = strip_format_json(rest)?; + graph_mission(&rest) + } + _ => Err(usage()), + } +} + +fn exit_code(result: &Result<(), String>) -> i32 { + if result.is_ok() { + 0 + } else { + 2 + } +} + +fn strip_format_json(args: &[String]) -> Result, String> { + let mut stripped = Vec::with_capacity(args.len()); + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if arg == "--format" { + let value = iter + .next() + .ok_or_else(|| "--format requires a value".to_string())?; + if value != "json" { + return Err(format!("unsupported output format: {value}")); + } + continue; + } + stripped.push(arg.clone()); + } + Ok(stripped) +} + +fn parse_root(args: &[String]) -> Result { + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if arg == "--root" { + return iter + .next() + .map(PathBuf::from) + .ok_or_else(|| "--root requires a path".to_string()); + } + } + Err("missing --root".to_string()) +} + +fn parse_root_alias(args: &[String]) -> Result { + parse_option(args, &["--root", "--game-root"]) + .map(PathBuf::from) + .ok_or_else(|| "missing --root".to_string()) +} + +fn parse_required(args: &[String], names: &[&str], label: &str) -> Result { + parse_option(args, names).ok_or_else(|| format!("missing {label}")) +} + +fn parse_option(args: &[String], names: &[&str]) -> Option { + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if names.iter().any(|name| arg == name) { + return iter.next().cloned(); + } + } + None +} + +fn inspect_prototype(args: &[String]) -> Result<(), String> { + let root = parse_root_alias(args)?; + let key = parse_required(args, &["--key"], "--key")?; + let vfs = Arc::new(DirectoryVfs::new(root)); + let repository = CachedResourceRepository::new(vfs.clone()); + let roots = [resource_name(key.as_bytes())]; + let (graph, resolved, mut report) = + build_prototype_graph_report(&repository, vfs.as_ref(), &roots); + extend_graph_report_with_visual_dependencies(&repository, &mut report, &resolved); + println!("{}", prototype_inspect_json(&key, &graph, &report)); + Ok(()) +} + +fn prototype_inspect_json( + key: &str, + graph: &fparkan_prototype::PrototypeGraph, + report: &fparkan_prototype::PrototypeGraphReport, +) -> String { + format!( + "{{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":{},\"roots\":{},\"prototype_requests\":{},\"resolved\":{},\"unit_references\":{},\"unit_components\":{},\"direct_references\":{},\"wear\":{},\"materials\":{},\"textures\":{},\"lightmaps\":{},\"failures\":{}}}", + json_string(key), + report.root_count, + graph.prototype_requests.len(), + report.resolved_count, + report.unit_reference_count, + report.unit_component_count, + report.direct_reference_count, + report.wear_resolved_count, + report.material_resolved_count, + report.texture_resolved_count, + report.lightmap_resolved_count, + report.failures.len() + ) +} + +fn graph_mission(args: &[String]) -> Result<(), String> { + let root = parse_root_alias(args)?; + let mission = parse_required(args, &["--mission"], "--mission")?; + let services = EngineServices::new(Arc::new(DirectoryVfs::new(root))); + let mut engine = create( + EngineConfig { + mode: EngineMode::Headless, + }, + services, + ) + .map_err(|err| err.to_string())?; + let loaded = load_mission( + &mut engine, + MissionRequest { + key: mission.clone(), + }, + ) + .map_err(|err| err.to_string())?; + println!( + "{{\"schema_version\":\"fparkan-mission-graph-v1\",\"mission\":{},\"objects\":{},\"paths\":{},\"clans\":{},\"extras\":{},\"roots\":{},\"direct_references\":{},\"unit_references\":{},\"unit_components\":{},\"prototype_requests\":{},\"wear\":{},\"materials\":{},\"textures\":{},\"lightmaps\":{},\"failures\":{}}}", + json_string(&mission), + loaded.object_count, + loaded.path_count, + loaded.clan_count, + loaded.extra_count, + loaded.graph_root_count, + loaded.graph_direct_reference_count, + loaded.graph_unit_reference_count, + loaded.graph_unit_component_count, + loaded.graph_resolved_count, + loaded.graph_wear_resolved_count, + loaded.graph_material_resolved_count, + loaded.graph_texture_resolved_count, + loaded.graph_lightmap_resolved_count, + loaded.graph_failure_count + ); + Ok(()) +} + +fn inspect_archive(args: &[String]) -> Result<(), String> { + let path = parse_archive_path(args)?; + let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?; + if bytes.starts_with(b"NRes") { + let document = fparkan_nres::decode( + Arc::from(bytes.into_boxed_slice()), + fparkan_nres::ReadProfile::Compatible, + ) + .map_err(|err| err.to_string())?; + println!( + "{}", + archive_inspect_json( + &path.display().to_string(), + "NRes", + document.entries().len(), + Some(document.lookup_order_valid()), + ) + ); + return Ok(()); + } + if bytes.get(0..4) == Some(b"NL\0\x01") { + let document = fparkan_rsli::decode( + Arc::from(bytes.into_boxed_slice()), + fparkan_rsli::ReadProfile::Compatible, + ) + .map_err(|err| err.to_string())?; + println!( + "{}", + archive_inspect_json( + &path.display().to_string(), + "RsLi", + document.entries().len(), + None + ) + ); + return Ok(()); + } + Err(format!("{}: unsupported archive magic", path.display())) +} + +fn archive_inspect_json( + path: &str, + kind: &str, + entries: usize, + lookup_order_valid: Option, +) -> String { + let mut out = format!( + "{{\"schema_version\":\"fparkan-archive-inspect-v1\",\"path\":{},\"kind\":{},\"entries\":{}", + json_string(path), + json_string(kind), + entries + ); + if let Some(valid) = lookup_order_valid { + let _ = write!(out, ",\"lookup_order_valid\":{valid}"); + } + out.push('}'); + out +} + +fn parse_archive_path(args: &[String]) -> Result { + match args { + [path] => Ok(PathBuf::from(path)), + [flag, path] if flag == "--file" => Ok(PathBuf::from(path)), + _ => Err("archive inspect requires or --file ".to_string()), + } +} + +fn json_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => { + let _ = write!(out, "\\u{:04x}", u32::from(c)); + } + c => out.push(c), + } + } + out.push('"'); + out +} + +fn usage() -> String { + "usage: fparkan corpus discover|validate --root [--format json] | archive inspect [--format json] | prototype inspect --root --key [--format json] | mission graph --root --mission [--format json]".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn stable_exit_codes_are_mapped() { + assert_eq!(exit_code(&Ok(())), 0); + assert_eq!(exit_code(&Err("failure".to_string())), 2); + } + + #[test] + fn accepts_json_format_option() { + assert_eq!( + strip_format_json(&strings(&["--root", "testdata", "--format", "json"])), + Ok(strings(&["--root", "testdata"])) + ); + assert_eq!( + strip_format_json(&strings(&["--format", "text"])), + Err("unsupported output format: text".to_string()) + ); + } + + #[test] + fn archive_json_has_schema_version() { + let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true)); + + assert!(json.contains("\"schema_version\":\"fparkan-archive-inspect-v1\"")); + assert!(json.contains("\"kind\":\"NRes\"")); + assert!(json.contains("\"lookup_order_valid\":true")); + } + + #[test] + fn prototype_graph_json_has_canonical_field_order() { + let mut graph = fparkan_prototype::PrototypeGraph::default(); + graph + .prototype_requests + .push(fparkan_prototype::PrototypeKey(resource_name(b"root"))); + let report = fparkan_prototype::PrototypeGraphReport { + root_count: 1, + direct_reference_count: 1, + resolved_count: 1, + ..fparkan_prototype::PrototypeGraphReport::default() + }; + + let json = prototype_inspect_json("root", &graph, &report); + + assert_eq!( + json, + "{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":\"root\",\"roots\":1,\"prototype_requests\":1,\"resolved\":1,\"unit_references\":0,\"unit_components\":0,\"direct_references\":1,\"wear\":0,\"materials\":0,\"textures\":0,\"lightmaps\":0,\"failures\":0}" + ); + } +} diff --git a/apps/fparkan-game/Cargo.toml b/apps/fparkan-game/Cargo.toml new file mode 100644 index 0000000..eef4d81 --- /dev/null +++ b/apps/fparkan-game/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "fparkan-game" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-render = { path = "../../crates/fparkan-render" } +fparkan-runtime = { path = "../../crates/fparkan-runtime" } +fparkan-vfs = { path = "../../crates/fparkan-vfs" } +fparkan-world = { path = "../../crates/fparkan-world" } + +[lints] +workspace = true diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs new file mode 100644 index 0000000..ed12c70 --- /dev/null +++ b/apps/fparkan-game/src/main.rs @@ -0,0 +1,322 @@ +#![forbid(unsafe_code)] +#![allow(clippy::print_stderr, clippy::print_stdout)] +//! `FParkan` rendered game composition root. + +use fparkan_render::{ + DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RecordingBackend, RenderBackend, + RenderCommand, RenderCommandList, RenderPhase, +}; +use fparkan_runtime::{ + create, frame, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest, +}; +use fparkan_vfs::DirectoryVfs; +use fparkan_world::WorldSnapshot; +use std::path::PathBuf; +use std::sync::Arc; + +fn main() { + let raw_args = std::env::args().skip(1).collect::>(); + let code = match run(&raw_args) { + Ok(output) => { + println!("{output}"); + 0 + } + Err(err) => { + eprintln!("{err}"); + 2 + } + }; + std::process::exit(code); +} + +fn run(args: &[String]) -> Result { + let args = Args::parse(args)?; + let services = EngineServices::new(Arc::new(DirectoryVfs::new(&args.root))); + let mut engine = create( + EngineConfig { + mode: EngineMode::Rendered, + }, + services, + ) + .map_err(|err| err.to_string())?; + let loaded = load_mission( + &mut engine, + MissionRequest { + key: args.mission.clone(), + }, + ) + .map_err(|err| err.to_string())?; + + let mut backend = RecordingBackend::default(); + let mut last_draw_count = 0usize; + let mut last_tick = 0u64; + let mut last_hash = [0u8; 32]; + for _ in 0..args.frames { + let result = frame(&mut engine).map_err(|err| err.to_string())?; + last_tick = result.snapshot.tick.0; + last_hash = result.snapshot.hash.0; + let commands = render_snapshot_commands(&result.snapshot); + last_draw_count = commands + .commands + .iter() + .filter(|command| matches!(command, RenderCommand::Draw(_))) + .count(); + backend + .execute(&commands) + .map_err(|err| format!("render backend: {err}"))?; + } + + Ok(format!( + "{{\"mission\":{},\"objects\":{},\"frames\":{},\"tick\":{},\"draws\":{},\"captures\":{},\"last_capture_bytes\":{},\"hash\":{}}}", + json_string(&args.mission), + loaded.object_count, + args.frames, + last_tick, + last_draw_count, + backend.captures().len(), + backend.last_capture().map_or(0, <[u8]>::len), + json_hash(&last_hash) + )) +} + +fn render_snapshot_commands(snapshot: &WorldSnapshot) -> RenderCommandList { + let mut commands = Vec::with_capacity(snapshot.objects.len() + 2); + commands.push(RenderCommand::BeginFrame); + for (index, handle) in snapshot.objects.iter().enumerate() { + let stable_order = u64::from(handle.slot); + let draw_id = snapshot + .tick + .0 + .wrapping_mul(1_000_003) + .wrapping_add(stable_order); + commands.push(RenderCommand::Draw(DrawCommand { + id: DrawId(draw_id), + phase: RenderPhase::Opaque, + object_id: None, + mesh: GpuMeshId(u64::from(handle.slot) + 1), + material: GpuMaterialId(1), + transform: identity_transform(index_to_f32(index)), + range: IndexRange { start: 0, count: 3 }, + stable_order, + })); + } + commands.push(RenderCommand::EndFrame); + RenderCommandList { commands } +} + +fn identity_transform(x: f32) -> [f32; 16] { + [ + 1.0, 0.0, 0.0, x, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ] +} + +fn index_to_f32(index: usize) -> f32 { + u16::try_from(index).map_or(f32::from(u16::MAX), f32::from) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct Args { + root: PathBuf, + mission: String, + frames: u64, +} + +impl Args { + fn parse(args: &[String]) -> Result { + let mut root = None; + let mut mission = None; + let mut frames = 1; + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--root" => { + root = Some( + iter.next() + .map(PathBuf::from) + .ok_or_else(|| "--root requires a path".to_string())?, + ); + } + "--mission" => { + mission = Some( + iter.next() + .cloned() + .ok_or_else(|| "--mission requires a path".to_string())?, + ); + } + "--frames" => { + frames = iter + .next() + .ok_or_else(|| "--frames requires a value".to_string())? + .parse() + .map_err(|_| "--frames must be an integer".to_string())?; + } + _ => return Err(usage()), + } + } + let root = root.ok_or_else(|| "missing --root".to_string())?; + let mission = mission.ok_or_else(|| "missing --mission".to_string())?; + if frames == 0 { + return Err("--frames must be greater than zero".to_string()); + } + Ok(Self { + root, + mission, + frames, + }) + } +} + +fn json_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => { + use std::fmt::Write as _; + let _ = write!(out, "\\u{:04x}", u32::from(c)); + } + c => out.push(c), + } + } + out.push('"'); + out +} + +fn json_hash(hash: &[u8; 32]) -> String { + let mut out = String::from("\""); + for byte in hash { + use std::fmt::Write as _; + let _ = write!(out, "{byte:02x}"); + } + out.push('"'); + out +} + +fn usage() -> String { + "usage: fparkan-game --root --mission [--frames ]".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_world::{ObjectHandle, StateHash, Tick}; + use std::path::Path; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn parses_required_args() { + assert_eq!( + Args::parse(&strings(&[ + "--root", + "testdata/IS", + "--mission", + "MISSIONS/Autodemo.00/data.tma", + "--frames", + "3", + ])), + Ok(Args { + root: PathBuf::from("testdata/IS"), + mission: "MISSIONS/Autodemo.00/data.tma".to_string(), + frames: 3, + }) + ); + } + + #[test] + fn render_commands_follow_snapshot_order() -> Result<(), String> { + let snapshot = WorldSnapshot { + tick: Tick(7), + objects: vec![ + ObjectHandle { + generation: 1, + slot: 2, + }, + ObjectHandle { + generation: 1, + slot: 5, + }, + ], + events: Vec::new(), + hash: StateHash([0; 32]), + }; + + let commands = render_snapshot_commands(&snapshot); + + assert_eq!(commands.commands.len(), 4); + assert!(matches!(commands.commands[0], RenderCommand::BeginFrame)); + assert!(matches!(commands.commands[3], RenderCommand::EndFrame)); + let RenderCommand::Draw(first) = &commands.commands[1] else { + return Err("expected draw".to_string()); + }; + assert_eq!(first.mesh, GpuMeshId(3)); + assert_eq!(first.stable_order, 2); + Ok(()) + } + + #[test] + fn selected_is_and_is2_missions_produce_approved_render_captures() { + for case in [ + RenderCase { + root: "IS", + mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma", + expected: "{\"mission\":\"MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma\",\"objects\":33,\"frames\":1,\"tick\":1,\"draws\":33,\"captures\":1,\"last_capture_bytes\":810,\"hash\":\"8584c4307bc911fc82bf909018662f392f3982bf909018666298bde408fe4242\"}", + }, + RenderCase { + root: "IS2", + mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma", + expected: "{\"mission\":\"MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma\",\"objects\":10,\"frames\":1,\"tick\":1,\"draws\":10,\"captures\":1,\"last_capture_bytes\":235,\"hash\":\"c52267cb14f699cb73b958e46c99c23ec23e73b958e46c99b3650afbcce56291\"}", + }, + ] { + assert_eq!( + run(&render_args(&workspace_root().join("testdata").join(case.root), case.mission)), + Ok(case.expected.to_string()) + ); + } + } + + #[test] + fn json_hash_is_hex() { + let mut hash = [0; 32]; + hash[0] = 0xab; + hash[31] = 0xcd; + + assert_eq!( + json_hash(&hash), + "\"ab000000000000000000000000000000000000000000000000000000000000cd\"" + ); + } + + #[derive(Clone, Copy)] + struct RenderCase { + root: &'static str, + mission: &'static str, + expected: &'static str, + } + + fn render_args(root: &Path, mission: &str) -> Vec { + vec![ + "--root".to_string(), + root.to_str().expect("utf8 root").to_string(), + "--mission".to_string(), + mission.to_string(), + "--frames".to_string(), + "1".to_string(), + ] + } + + fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("workspace root") + .to_path_buf() + } +} diff --git a/apps/fparkan-headless/Cargo.toml b/apps/fparkan-headless/Cargo.toml new file mode 100644 index 0000000..ecf2948 --- /dev/null +++ b/apps/fparkan-headless/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "fparkan-headless" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-runtime = { path = "../../crates/fparkan-runtime" } +fparkan-vfs = { path = "../../crates/fparkan-vfs" } +fparkan-world = { path = "../../crates/fparkan-world" } + +[lints] +workspace = true diff --git a/apps/fparkan-headless/src/main.rs b/apps/fparkan-headless/src/main.rs new file mode 100644 index 0000000..b78a7dc --- /dev/null +++ b/apps/fparkan-headless/src/main.rs @@ -0,0 +1,114 @@ +#![forbid(unsafe_code)] +#![allow(clippy::print_stderr, clippy::print_stdout)] +//! `FParkan` headless runtime entrypoint. + +use fparkan_runtime::{ + create, load_mission, step_headless, EngineConfig, EngineMode, EngineServices, MissionRequest, +}; +use fparkan_vfs::DirectoryVfs; +use fparkan_world::InputSnapshot; +use std::path::PathBuf; +use std::sync::Arc; + +fn main() { + if let Err(err) = run() { + eprintln!("{err}"); + std::process::exit(2); + } +} + +fn run() -> Result<(), String> { + let raw_args: Vec = std::env::args().skip(1).collect(); + let args = Args::parse(&raw_args)?; + let services = if let Some(root) = &args.root { + EngineServices::new(Arc::new(DirectoryVfs::new(root))) + } else { + EngineServices::default() + }; + let mut engine = create( + EngineConfig { + mode: EngineMode::Headless, + }, + services, + ) + .map_err(|err| format!("{err}"))?; + if let Some(mission) = args.mission { + let loaded = load_mission(&mut engine, MissionRequest { key: mission }) + .map_err(|err| format!("{err}"))?; + println!( + "mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} graph_failures={}", + loaded.object_count, + loaded.areal_count, + loaded.surface_count, + loaded.graph_root_count, + loaded.graph_unit_component_count, + loaded.graph_wear_resolved_count, + loaded.graph_material_resolved_count, + loaded.graph_texture_resolved_count, + loaded.graph_lightmap_resolved_count, + loaded.graph_failure_count + ); + } + let mut last = None; + for _ in 0..args.ticks { + last = Some(step_headless(&mut engine, InputSnapshot).map_err(|err| format!("{err}"))?); + } + if let Some(frame) = last { + println!( + "tick={} hash={:02x?}", + frame.snapshot.tick.0, frame.snapshot.hash.0 + ); + } + Ok(()) +} + +struct Args { + root: Option, + mission: Option, + ticks: u64, +} + +impl Args { + fn parse(args: &[String]) -> Result { + let mut parsed = Self { + root: None, + mission: None, + ticks: 1, + }; + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--root" => { + parsed.root = Some( + iter.next() + .map(PathBuf::from) + .ok_or_else(|| "--root requires a path".to_string())?, + ); + } + "--mission" => { + parsed.mission = Some( + iter.next() + .cloned() + .ok_or_else(|| "--mission requires a path".to_string())?, + ); + } + "--ticks" => { + parsed.ticks = iter + .next() + .ok_or_else(|| "--ticks requires a value".to_string())? + .parse() + .map_err(|_| "--ticks must be an integer".to_string())?; + } + _ => return Err(usage()), + } + } + if parsed.mission.is_some() && parsed.root.is_none() { + return Err("--mission requires --root".to_string()); + } + Ok(parsed) + } +} + +fn usage() -> String { + "usage: fparkan-headless [--root --mission ] [--ticks ]".to_string() +} diff --git a/apps/fparkan-viewer/Cargo.toml b/apps/fparkan-viewer/Cargo.toml new file mode 100644 index 0000000..4219e8a --- /dev/null +++ b/apps/fparkan-viewer/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "fparkan-viewer" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-msh = { path = "../../crates/fparkan-msh" } +fparkan-nres = { path = "../../crates/fparkan-nres" } +fparkan-resource = { path = "../../crates/fparkan-resource" } +fparkan-render = { path = "../../crates/fparkan-render" } +fparkan-rsli = { path = "../../crates/fparkan-rsli" } +fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format" } +fparkan-texm = { path = "../../crates/fparkan-texm" } +fparkan-vfs = { path = "../../crates/fparkan-vfs" } + +[lints] +workspace = true diff --git a/apps/fparkan-viewer/src/main.rs b/apps/fparkan-viewer/src/main.rs new file mode 100644 index 0000000..1720cd7 --- /dev/null +++ b/apps/fparkan-viewer/src/main.rs @@ -0,0 +1,353 @@ +#![forbid(unsafe_code)] +#![allow(clippy::print_stderr, clippy::print_stdout)] +//! `FParkan` asset viewer composition root. + +use fparkan_msh::{decode_msh, validate_msh}; +use fparkan_nres::{decode as decode_nres, ReadProfile as NresReadProfile}; +use fparkan_render::{ + build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderPhase, + RenderProfile, RenderSnapshot, RenderSnapshotDraw, +}; +use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository}; +use fparkan_terrain_format::{decode_land_map, decode_land_msh}; +use fparkan_texm::decode_texm; +use fparkan_vfs::DirectoryVfs; +use std::fmt::Write; +use std::path::PathBuf; +use std::sync::Arc; + +fn main() { + let args = std::env::args().skip(1).collect::>(); + let code = match run(&args) { + Ok(json) => { + println!("{json}"); + 0 + } + Err(err) => { + eprintln!("{err}"); + 2 + } + }; + std::process::exit(code); +} + +fn run(args: &[String]) -> Result { + match args { + [domain, rest @ ..] if domain == "archive" => inspect_archive(rest), + [domain, rest @ ..] if domain == "model" => inspect_model(rest), + [domain, rest @ ..] if domain == "texture" => inspect_texture(rest), + [domain, rest @ ..] if domain == "map" => inspect_map(rest), + _ => Err(usage()), + } +} + +fn inspect_archive(args: &[String]) -> Result { + let file = parse_file(args)?; + let limit = parse_limit(args)?; + let bytes = std::fs::read(&file).map_err(|err| format!("{}: {err}", file.display()))?; + if bytes.starts_with(b"NRes") { + let document = decode_nres( + Arc::from(bytes.into_boxed_slice()), + NresReadProfile::Compatible, + ) + .map_err(|err| err.to_string())?; + let sample = render_nres_entries(&document, limit); + return Ok(format!( + "{{\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}", + json_string(&file.display().to_string()), + document.entries().len(), + document.lookup_order_valid(), + sample + )); + } + if bytes.get(0..4) == Some(b"NL\0\x01") { + let document = fparkan_rsli::decode( + Arc::from(bytes.into_boxed_slice()), + fparkan_rsli::ReadProfile::Compatible, + ) + .map_err(|err| err.to_string())?; + return Ok(format!( + "{{\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}", + json_string(&file.display().to_string()), + document.entries().len() + )); + } + Err(format!("{}: unsupported archive magic", file.display())) +} + +fn inspect_model(args: &[String]) -> Result { + if let Some(fixture) = parse_option(args, &["--fixture"]) { + return ViewerModelService::inspect_synthetic_model(&fixture); + } + + let query = parse_resource_query(args)?; + let bytes = read_resource(&query)?; + let nested = decode_nres(bytes, NresReadProfile::Compatible).map_err(|err| err.to_string())?; + let document = decode_msh(&nested).map_err(|err| err.to_string())?; + let model = validate_msh(&document).map_err(|err| err.to_string())?; + + Ok(format!( + "{{\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}", + json_string(&query.archive), + json_string(&query.name), + document.streams().len(), + model.node_count, + model.slots.len(), + model.positions.len(), + model.indices.len(), + model.batches.len() + )) +} + +#[derive(Clone, Debug)] +struct ViewerModelService; + +impl ViewerModelService { + fn inspect_synthetic_model(fixture: &str) -> Result { + if fixture != "synthetic/model-basic" { + return Err(format!("unknown model fixture: {fixture}")); + } + + let snapshot = RenderSnapshot { + camera: CameraSnapshot::default(), + draws: vec![RenderSnapshotDraw { + id: DrawId(1), + phase: RenderPhase::Opaque, + object_id: None, + mesh: GpuMeshId(1), + material_slots: vec![GpuMaterialId(7)], + material_index: 0, + transform: identity_transform(), + range: IndexRange { start: 0, count: 3 }, + stable_order: 0, + }], + }; + let commands = build_commands(&snapshot, RenderProfile::default()) + .map_err(|err| format!("render command generation: {err}"))?; + let draw_commands = commands + .commands + .iter() + .filter(|command| matches!(command, fparkan_render::RenderCommand::Draw(_))) + .count(); + + Ok(format!( + "{{\"kind\":\"model\",\"fixture\":{},\"service\":\"synthetic-model\",\"draw_commands\":{draw_commands}}}", + json_string(fixture) + )) + } +} + +fn inspect_texture(args: &[String]) -> Result { + let query = parse_resource_query(args)?; + let document = decode_texm(read_resource(&query)?).map_err(|err| err.to_string())?; + + Ok(format!( + "{{\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}", + json_string(&query.archive), + json_string(&query.name), + document.width(), + document.height(), + json_string(&format!("{:?}", document.format())), + document.mip_count(), + document.page_rects().len() + )) +} + +fn inspect_map(args: &[String]) -> Result { + let file = parse_file(args)?; + let kind = parse_option(args, &["--kind"]).ok_or_else(|| "missing --kind".to_string())?; + let bytes = std::fs::read(&file).map_err(|err| format!("{}: {err}", file.display()))?; + let nres = decode_nres( + Arc::from(bytes.into_boxed_slice()), + NresReadProfile::Compatible, + ) + .map_err(|err| err.to_string())?; + + match kind.as_str() { + "land-msh" => { + let land = decode_land_msh(&nres).map_err(|err| err.to_string())?; + Ok(format!( + "{{\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}", + json_string(&file.display().to_string()), + land.streams.len(), + land.positions.len(), + land.faces.len(), + land.slots.slots_raw.len() + )) + } + "land-map" => { + let land = decode_land_map(&nres).map_err(|err| err.to_string())?; + Ok(format!( + "{{\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}", + json_string(&file.display().to_string()), + land.areals.len(), + land.areal_count, + land.grid.cells_x, + land.grid.cells_y + )) + } + _ => Err(format!("unknown map kind: {kind}")), + } +} + +struct ResourceQuery { + root: PathBuf, + archive: String, + name: String, +} + +fn parse_resource_query(args: &[String]) -> Result { + Ok(ResourceQuery { + root: parse_path_option(args, &["--root", "--game-root"], "--root")?, + archive: parse_option(args, &["--archive"]) + .ok_or_else(|| "missing --archive".to_string())?, + name: parse_option(args, &["--name"]).ok_or_else(|| "missing --name".to_string())?, + }) +} + +fn read_resource(query: &ResourceQuery) -> Result, String> { + let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(&query.root))); + let archive = repository + .open_archive(&archive_path(query.archive.as_bytes()).map_err(|err| err.to_string())?) + .map_err(|err| err.to_string())?; + let entry = repository + .find(archive, &resource_name(query.name.as_bytes())) + .map_err(|err| err.to_string())? + .ok_or_else(|| format!("resource not found: {}/{}", query.archive, query.name))?; + let bytes = repository.read(entry).map_err(|err| err.to_string())?; + Ok(Arc::from(bytes.into_owned())) +} + +fn parse_file(args: &[String]) -> Result { + parse_path_option(args, &["--file"], "--file") +} + +fn parse_limit(args: &[String]) -> Result { + parse_option(args, &["--limit"]) + .map(|value| { + value + .parse::() + .map_err(|_| format!("invalid --limit: {value}")) + }) + .transpose() + .map(|value| value.unwrap_or(0)) +} + +fn render_nres_entries(document: &fparkan_nres::NresDocument, limit: usize) -> String { + let mut out = String::new(); + for (index, entry) in document.entries().iter().take(limit).enumerate() { + if index > 0 { + out.push(','); + } + let name = String::from_utf8_lossy(entry.name_bytes()); + let _ = write!( + out, + "{{\"name\":{},\"type\":{},\"size\":{}}}", + json_string(&name), + entry.meta().type_id, + entry.meta().data_size + ); + } + out +} + +fn parse_path_option(args: &[String], names: &[&str], label: &str) -> Result { + parse_option(args, names) + .map(PathBuf::from) + .ok_or_else(|| format!("missing {label}")) +} + +fn parse_option(args: &[String], names: &[&str]) -> Option { + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if names.iter().any(|name| arg == name) { + return iter.next().cloned(); + } + } + None +} + +fn json_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => { + let _ = write!(out, "\\u{:04x}", u32::from(c)); + } + c => out.push(c), + } + } + out.push('"'); + out +} + +fn identity_transform() -> [f32; 16] { + [ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ] +} + +fn usage() -> String { + "usage: fparkan-viewer archive --file [--limit N] | model --root --archive --name | model --fixture synthetic/model-basic | texture --root --archive --name | map --file --kind land-msh|land-map".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn parses_resource_query() -> Result<(), String> { + let query = parse_resource_query(&strings(&[ + "--root", + "testdata/IS", + "--archive", + "textures.lib", + "--name", + "grass.tex", + ]))?; + + assert_eq!(query.root, PathBuf::from("testdata/IS")); + assert_eq!(query.archive, "textures.lib"); + assert_eq!(query.name, "grass.tex"); + Ok(()) + } + + #[test] + fn json_string_escapes_controls() { + assert_eq!(json_string("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\""); + } + + #[test] + fn usage_rejects_empty_args() { + assert_eq!(run(&[]), Err(usage())); + } + + #[test] + fn parses_limit() { + assert_eq!(parse_limit(&strings(&["--limit", "2"])), Ok(2)); + assert_eq!(parse_limit(&[]), Ok(0)); + assert_eq!( + parse_limit(&strings(&["--limit", "x"])), + Err("invalid --limit: x".to_string()) + ); + } + + #[test] + fn model_fixture_uses_viewer_service_and_render_commands() -> Result<(), String> { + assert_eq!( + run(&strings(&["model", "--fixture", "synthetic/model-basic"]))?, + "{\"kind\":\"model\",\"fixture\":\"synthetic/model-basic\",\"service\":\"synthetic-model\",\"draw_commands\":1}" + ); + Ok(()) + } +} diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml deleted file mode 100644 index e020b17..0000000 --- a/crates/common/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "common" -version = "0.1.0" -edition = "2021" - -[dependencies] diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs deleted file mode 100644 index c0d57f7..0000000 --- a/crates/common/src/lib.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::fs; -use std::io; -use std::path::{Path, PathBuf}; - -/// Resource payload that can be either borrowed from mapped bytes or owned. -#[derive(Clone, Debug)] -pub enum ResourceData<'a> { - Borrowed(&'a [u8]), - Owned(Vec), -} - -impl<'a> ResourceData<'a> { - pub fn as_slice(&self) -> &[u8] { - match self { - Self::Borrowed(slice) => slice, - Self::Owned(buf) => buf.as_slice(), - } - } - - pub fn into_owned(self) -> Vec { - match self { - Self::Borrowed(slice) => slice.to_vec(), - Self::Owned(buf) => buf, - } - } -} - -impl AsRef<[u8]> for ResourceData<'_> { - fn as_ref(&self) -> &[u8] { - self.as_slice() - } -} - -/// Output sink used by `read_into`/`load_into` APIs. -pub trait OutputBuffer { - /// Writes the full payload to the sink, replacing any previous content. - fn write_exact(&mut self, data: &[u8]) -> io::Result<()>; -} - -impl OutputBuffer for Vec { - fn write_exact(&mut self, data: &[u8]) -> io::Result<()> { - self.clear(); - self.extend_from_slice(data); - Ok(()) - } -} - -/// Recursively collects all files under `root`. -pub fn collect_files_recursive(root: &Path, out: &mut Vec) { - let Ok(entries) = fs::read_dir(root) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_files_recursive(&path, out); - } else if path.is_file() { - out.push(path); - } - } -} diff --git a/crates/fparkan-animation/Cargo.toml b/crates/fparkan-animation/Cargo.toml new file mode 100644 index 0000000..a3e5d9e --- /dev/null +++ b/crates/fparkan-animation/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "fparkan-animation" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] + +[lints] +workspace = true diff --git a/crates/fparkan-animation/src/lib.rs b/crates/fparkan-animation/src/lib.rs new file mode 100644 index 0000000..9bf9ef5 --- /dev/null +++ b/crates/fparkan-animation/src/lib.rs @@ -0,0 +1,1217 @@ +#![forbid(unsafe_code)] +#![allow(clippy::cast_precision_loss)] +//! Deterministic animation sampling contracts. + +use std::fmt; + +/// Numeric profile. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NumericProfile { + /// Portable reference. + PortableReference, + /// X87-compatible compatibility profile for captured parity vectors. + X87Compatibility, +} + +/// Animation time in frames. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AnimationTime(pub f32); + +/// Pose. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Pose { + /// Translation. + pub translation: [f32; 3], + /// Quaternion. + pub rotation: [f32; 4], +} + +impl Default for Pose { + fn default() -> Self { + Self { + translation: [0.0; 3], + rotation: [0.0, 0.0, 0.0, 1.0], + } + } +} + +/// Scalar animation key. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScalarKey { + /// Frame number. + pub frame: u32, + /// Scalar value at the frame. + pub value: f32, +} + +/// Pose animation key. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PoseKey { + /// Frame number. + pub frame: u32, + /// Pose at the frame. + pub pose: Pose, +} + +/// Pose key addressed by a floating-point animation time. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TimedPoseKey { + /// Key time in frames. + pub time: AnimationTime, + /// Pose at the time. + pub pose: Pose, +} + +/// Decoded 24-byte animation key. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AnimKey24 { + /// Key time. + pub time: AnimationTime, + /// Pose decoded from signed fixed-point channels. + pub pose: Pose, +} + +/// Optional frame remapping table. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FrameMap { + attr_frame_count: u16, + frames: Vec, +} + +/// Scalar track with a deterministic fallback. +#[derive(Clone, Debug, PartialEq)] +pub struct ScalarTrack { + fallback: f32, + keys: Vec, +} + +/// Pose track with a deterministic fallback. +#[derive(Clone, Debug, PartialEq)] +pub struct PoseTrack { + fallback: Pose, + keys: Vec, +} + +/// Pose track keyed by floating-point animation times. +#[derive(Clone, Debug, PartialEq)] +pub struct TimedPoseTrack { + fallback: Pose, + keys: Vec, +} + +/// Parent index for a node in an animation hierarchy. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ParentIndex(pub Option); + +/// Node pose after hierarchy evaluation. +#[derive(Clone, Debug, PartialEq)] +pub struct NodePoseBuffer { + /// Global poses in node order. + pub poses: Vec, +} + +/// Difference between portable and x87-compatible samples. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct NumericProfileDifference { + /// Time that was sampled. + pub time: AnimationTime, + /// Per-axis translation delta: x87 - portable. + pub translation_delta: [f32; 3], + /// Per-component quaternion delta: x87 - portable. + pub rotation_delta: [f32; 4], +} + +/// Material animation state. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MaterialAnimationState { + /// Time used by material phase evaluation. + pub time: AnimationTime, + /// Named deterministic random stream. + pub rng: NamedRngStream, +} + +/// Named deterministic random stream. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct NamedRngStream { + state: u64, + calls: u64, +} + +/// Animation sampling error. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AnimationError { + /// Track keys are not sorted by frame or contain duplicate frames. + NonMonotonicKeys, + /// Time was NaN or infinite. + InvalidTime, + /// Quaternion could not be normalized. + InvalidQuaternion, + /// Input buffer size is invalid for the expected record stride. + InvalidSize, + /// Frame map entry points outside the clip frame count. + InvalidFrameMapValue { + /// Requested mapped frame. + frame: u16, + /// Declared frame count. + frame_count: u16, + }, + /// Parent index is not before its child. + ParentOrder { + /// Child node index. + child: usize, + /// Parent node index. + parent: usize, + }, + /// Parent graph contains a cycle. + ParentCycle { + /// Node where the cycle was detected. + node: usize, + }, +} + +impl fmt::Display for AnimationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self:?}") + } +} + +impl std::error::Error for AnimationError {} + +impl ScalarTrack { + /// Creates a scalar track. + /// + /// # Errors + /// + /// Returns [`AnimationError::NonMonotonicKeys`] when keys are not strictly + /// sorted by frame. + pub fn new(fallback: f32, keys: Vec) -> Result { + validate_scalar_keys(&keys)?; + Ok(Self { fallback, keys }) + } + + /// Returns the keys in frame order. + #[must_use] + pub fn keys(&self) -> &[ScalarKey] { + &self.keys + } + + /// Samples the scalar track with clamp-and-linear semantics. + /// + /// # Errors + /// + /// Returns [`AnimationError::InvalidTime`] when `time` is NaN or infinite. + pub fn sample(&self, time: AnimationTime) -> Result { + validate_time(time)?; + let Some(first) = self.keys.first() else { + return Ok(self.fallback); + }; + if time.0 <= first.frame as f32 { + return Ok(first.value); + } + + for pair in self.keys.windows(2) { + let left = pair[0]; + let right = pair[1]; + let left_frame = left.frame as f32; + let right_frame = right.frame as f32; + if time.0 <= right_frame { + let span = right_frame - left_frame; + let t = if span == 0.0 { + 0.0 + } else { + (time.0 - left_frame) / span + }; + return Ok(lerp(left.value, right.value, t)); + } + } + + Ok(self.keys.last().map_or(self.fallback, |key| key.value)) + } +} + +impl AnimKey24 { + /// Decodes one 24-byte animation key. + /// + /// Layout: `position:f32x3`, `time:f32`, `rotation:i16x4` scaled by + /// `1/32767`. + /// + /// # Errors + /// + /// Returns [`AnimationError::InvalidSize`] when the record is not exactly + /// 24 bytes or [`AnimationError::InvalidTime`] when the key time is not + /// finite. + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() != 24 { + return Err(AnimationError::InvalidSize); + } + let translation = [ + read_f32(bytes, 0)?, + read_f32(bytes, 4)?, + read_f32(bytes, 8)?, + ]; + let time = AnimationTime(read_f32(bytes, 12)?); + validate_time(time)?; + let raw_rotation = [ + f32::from(read_i16(bytes, 16)?) / 32767.0, + f32::from(read_i16(bytes, 18)?) / 32767.0, + f32::from(read_i16(bytes, 20)?) / 32767.0, + f32::from(read_i16(bytes, 22)?) / 32767.0, + ]; + Ok(Self { + time, + pose: Pose { + translation, + rotation: raw_rotation, + }, + }) + } + + /// Returns a pose ready for runtime sampling. + /// + /// Degenerate all-zero quaternions are treated as identity, matching the + /// safe static-node fallback used by legacy animation data. + #[must_use] + pub fn sampling_pose(&self) -> Pose { + let rotation = normalize_quat(self.pose.rotation).unwrap_or(Pose::default().rotation); + Pose { + translation: self.pose.translation, + rotation, + } + } +} + +impl TimedPoseTrack { + /// Creates a pose track keyed by floating-point times. + /// + /// # Errors + /// + /// Returns [`AnimationError::NonMonotonicKeys`] when keys are not strictly + /// sorted by time, [`AnimationError::InvalidTime`] when a key time is not + /// finite, or [`AnimationError::InvalidQuaternion`] when a key rotation + /// cannot be normalized. + pub fn new(fallback: Pose, keys: Vec) -> Result { + validate_pose(&fallback)?; + validate_timed_pose_keys(&keys)?; + Ok(Self { fallback, keys }) + } + + /// Returns keys in time order. + #[must_use] + pub fn keys(&self) -> &[TimedPoseKey] { + &self.keys + } + + /// Samples the pose track with linear translation and normalized + /// quaternion interpolation. + /// + /// # Errors + /// + /// Returns [`AnimationError::InvalidTime`] when `time` is NaN or infinite. + pub fn sample(&self, time: AnimationTime) -> Result { + validate_time(time)?; + let Some(first) = self.keys.first() else { + return Ok(self.fallback); + }; + if time.0 <= first.time.0 { + return Ok(first.pose); + } + + for pair in self.keys.windows(2) { + let left = pair[0]; + let right = pair[1]; + if time.0 <= right.time.0 { + let span = right.time.0 - left.time.0; + let t = if span == 0.0 { + 0.0 + } else { + (time.0 - left.time.0) / span + }; + return blend_pose(left.pose, right.pose, t); + } + } + + Ok(self.keys.last().map_or(self.fallback, |key| key.pose)) + } +} + +impl FrameMap { + /// Decodes a `u16` frame map from type-19 bytes and an attr frame count. + /// + /// # Errors + /// + /// Returns [`AnimationError::InvalidSize`] when bytes are not u16-aligned. + pub fn decode(bytes: &[u8], attr_frame_count: u16) -> Result { + if !bytes.len().is_multiple_of(2) { + return Err(AnimationError::InvalidSize); + } + let mut frames = Vec::with_capacity(bytes.len() / 2); + for offset in (0..bytes.len()).step_by(2) { + frames.push(read_u16(bytes, offset)?); + } + Ok(Self { + attr_frame_count, + frames, + }) + } + + /// Resolves a logical frame through the optional map. + /// + /// Missing map entries and invalid mapped values fall back to the input + /// frame, which is the documented compatibility branch for incomplete + /// legacy clips. + #[must_use] + pub fn resolve_or_fallback(&self, logical_frame: u16) -> u16 { + let Some(mapped) = self.frames.get(usize::from(logical_frame)).copied() else { + return logical_frame; + }; + if mapped < self.attr_frame_count { + mapped + } else { + logical_frame + } + } + + /// Resolves a logical frame and reports invalid mapped values explicitly. + /// + /// # Errors + /// + /// Returns [`AnimationError::InvalidFrameMapValue`] when the mapped frame is + /// outside the declared attr frame count. + pub fn resolve_strict(&self, logical_frame: u16) -> Result { + let Some(mapped) = self.frames.get(usize::from(logical_frame)).copied() else { + return Ok(logical_frame); + }; + if mapped < self.attr_frame_count { + Ok(mapped) + } else { + Err(AnimationError::InvalidFrameMapValue { + frame: mapped, + frame_count: self.attr_frame_count, + }) + } + } + + /// Declared frame count from attributes. + #[must_use] + pub const fn attr_frame_count(&self) -> u16 { + self.attr_frame_count + } + + /// Raw frame map values. + #[must_use] + pub fn frames(&self) -> &[u16] { + &self.frames + } +} + +impl PoseTrack { + /// Creates a pose track. + /// + /// # Errors + /// + /// Returns [`AnimationError::NonMonotonicKeys`] when keys are not strictly + /// sorted by frame, or [`AnimationError::InvalidQuaternion`] when a key + /// rotation cannot be normalized. + pub fn new(fallback: Pose, keys: Vec) -> Result { + validate_pose(&fallback)?; + validate_pose_keys(&keys)?; + Ok(Self { fallback, keys }) + } + + /// Returns the keys in frame order. + #[must_use] + pub fn keys(&self) -> &[PoseKey] { + &self.keys + } + + /// Samples the pose track with linear translation and normalized quaternion + /// interpolation. + /// + /// # Errors + /// + /// Returns [`AnimationError::InvalidTime`] when `time` is NaN or infinite. + pub fn sample( + &self, + time: AnimationTime, + _profile: NumericProfile, + ) -> Result { + validate_time(time)?; + let Some(first) = self.keys.first() else { + return Ok(self.fallback); + }; + if time.0 <= first.frame as f32 { + return Ok(first.pose); + } + + for pair in self.keys.windows(2) { + let left = pair[0]; + let right = pair[1]; + let left_frame = left.frame as f32; + let right_frame = right.frame as f32; + if time.0 <= right_frame { + let span = right_frame - left_frame; + let t = if span == 0.0 { + 0.0 + } else { + (time.0 - left_frame) / span + }; + return blend_pose(left.pose, right.pose, t); + } + } + + Ok(self.keys.last().map_or(self.fallback, |key| key.pose)) + } +} + +impl NamedRngStream { + /// Creates a deterministic stream from a global seed and a stable stream + /// name. + #[must_use] + pub fn new(seed: u64, name: &str) -> Self { + let mut state = 0x9e37_79b9_7f4a_7c15_u64 ^ seed; + for byte in name.as_bytes() { + state ^= u64::from(*byte); + state = splitmix64(state); + } + if state == 0 { + state = 0x6a09_e667_f3bc_c909; + } + Self { state, calls: 0 } + } + + /// Returns how many values have been generated. + #[must_use] + pub const fn calls(&self) -> u64 { + self.calls + } + + /// Returns the next deterministic `u32`. + pub fn next_u32(&mut self) -> u32 { + self.calls = self.calls.wrapping_add(1); + self.state = splitmix64(self.state); + (self.state >> 32) as u32 + } + + /// Returns the next deterministic scalar in `[0, 1]`. + pub fn next_unit_f32(&mut self) -> f32 { + let value = self.next_u32() >> 8; + value as f32 / 0x00ff_ffff_u32 as f32 + } +} + +impl MaterialAnimationState { + /// Advances material time without drawing or emitting side effects. + #[must_use] + pub fn advanced(self, delta_frames: f32) -> Self { + Self { + time: AnimationTime(self.time.0 + delta_frames), + rng: self.rng, + } + } +} + +/// Builds a canonical pose capture from a track and frame list. +/// +/// # Errors +/// +/// Returns [`AnimationError`] when pose sampling fails. +pub fn canonical_pose_capture( + track: &PoseTrack, + times: &[AnimationTime], +) -> Result, AnimationError> { + let mut out = Vec::new(); + for time in times { + let pose = track.sample(*time, NumericProfile::PortableReference)?; + out.extend_from_slice(b"P,"); + write_f32_bits(&mut out, time.0); + for value in pose.translation { + out.push(b','); + write_f32_bits(&mut out, value); + } + for value in pose.rotation { + out.push(b','); + write_f32_bits(&mut out, value); + } + out.push(b'\n'); + } + Ok(out) +} + +/// Builds a canonical pose capture from a float-time track. +/// +/// # Errors +/// +/// Returns [`AnimationError`] when pose sampling fails. +pub fn canonical_timed_pose_capture( + track: &TimedPoseTrack, + times: &[AnimationTime], +) -> Result, AnimationError> { + let mut out = Vec::new(); + for time in times { + let pose = track.sample(*time)?; + out.extend_from_slice(b"P,"); + write_f32_bits(&mut out, time.0); + for value in pose.translation { + out.push(b','); + write_f32_bits(&mut out, value); + } + for value in pose.rotation { + out.push(b','); + write_f32_bits(&mut out, value); + } + out.push(b'\n'); + } + Ok(out) +} + +/// Blends two optional poses. +/// +/// When only one side is valid, the valid side is returned. When both sides are +/// absent, [`AnimationError::InvalidQuaternion`] is returned as a deterministic +/// invalid-pose marker. +/// +/// # Errors +/// +/// Returns [`AnimationError`] when both inputs are invalid or quaternion +/// interpolation cannot be normalized. +pub fn blend_optional_pose( + left: Option, + right: Option, + weight: f32, +) -> Result { + match (left, right) { + (Some(left), Some(right)) => blend_pose(left, right, weight), + (Some(pose), None) | (None, Some(pose)) => Ok(pose), + (None, None) => Err(AnimationError::InvalidQuaternion), + } +} + +/// Evaluates local poses into global poses with parent-before-child ordering. +/// +/// # Errors +/// +/// Returns [`AnimationError::ParentOrder`] when a parent appears after its +/// child, or [`AnimationError::ParentCycle`] when a node is its own ancestor. +pub fn evaluate_hierarchy( + parents: &[ParentIndex], + local_poses: &[Pose], +) -> Result { + if parents.len() != local_poses.len() { + return Err(AnimationError::InvalidSize); + } + for (index, parent) in parents.iter().enumerate() { + let Some(raw_parent) = parent.0 else { + continue; + }; + let parent_index = usize::from(raw_parent); + if parent_index == index { + return Err(AnimationError::ParentCycle { node: index }); + } + if parent_index > index { + return Err(AnimationError::ParentOrder { + child: index, + parent: parent_index, + }); + } + } + + let mut global = Vec::with_capacity(local_poses.len()); + for (index, pose) in local_poses.iter().copied().enumerate() { + let composed = if let Some(parent) = parents[index].0 { + compose_pose(global[usize::from(parent)], pose)? + } else { + pose + }; + global.push(composed); + } + Ok(NodePoseBuffer { poses: global }) +} + +/// Compares portable and x87-compatible profile samples explicitly. +/// +/// # Errors +/// +/// Returns [`AnimationError`] when either profile fails to sample. +pub fn compare_numeric_profiles( + track: &PoseTrack, + times: &[AnimationTime], +) -> Result, AnimationError> { + let mut out = Vec::with_capacity(times.len()); + for time in times { + let portable = track.sample(*time, NumericProfile::PortableReference)?; + let x87 = track.sample(*time, NumericProfile::X87Compatibility)?; + out.push(NumericProfileDifference { + time: *time, + translation_delta: [ + x87.translation[0] - portable.translation[0], + x87.translation[1] - portable.translation[1], + x87.translation[2] - portable.translation[2], + ], + rotation_delta: [ + x87.rotation[0] - portable.rotation[0], + x87.rotation[1] - portable.rotation[1], + x87.rotation[2] - portable.rotation[2], + x87.rotation[3] - portable.rotation[3], + ], + }); + } + Ok(out) +} + +fn validate_scalar_keys(keys: &[ScalarKey]) -> Result<(), AnimationError> { + for pair in keys.windows(2) { + if pair[0].frame >= pair[1].frame { + return Err(AnimationError::NonMonotonicKeys); + } + } + Ok(()) +} + +fn validate_pose_keys(keys: &[PoseKey]) -> Result<(), AnimationError> { + for key in keys { + validate_pose(&key.pose)?; + } + for pair in keys.windows(2) { + if pair[0].frame >= pair[1].frame { + return Err(AnimationError::NonMonotonicKeys); + } + } + Ok(()) +} + +fn validate_timed_pose_keys(keys: &[TimedPoseKey]) -> Result<(), AnimationError> { + for key in keys { + validate_time(key.time)?; + validate_pose(&key.pose)?; + } + for pair in keys.windows(2) { + if pair[0].time.0 >= pair[1].time.0 { + return Err(AnimationError::NonMonotonicKeys); + } + } + Ok(()) +} + +fn validate_pose(pose: &Pose) -> Result<(), AnimationError> { + normalize_quat(pose.rotation).map(|_| ()) +} + +fn validate_time(time: AnimationTime) -> Result<(), AnimationError> { + if time.0.is_finite() { + Ok(()) + } else { + Err(AnimationError::InvalidTime) + } +} + +fn blend_pose(left: Pose, right: Pose, t: f32) -> Result { + let mut right_rotation = right.rotation; + if dot4(left.rotation, right_rotation) < 0.0 { + for value in &mut right_rotation { + *value = -*value; + } + } + + Ok(Pose { + translation: [ + lerp(left.translation[0], right.translation[0], t), + lerp(left.translation[1], right.translation[1], t), + lerp(left.translation[2], right.translation[2], t), + ], + rotation: normalize_quat([ + lerp(left.rotation[0], right_rotation[0], t), + lerp(left.rotation[1], right_rotation[1], t), + lerp(left.rotation[2], right_rotation[2], t), + lerp(left.rotation[3], right_rotation[3], t), + ])?, + }) +} + +fn normalize_quat(quat: [f32; 4]) -> Result<[f32; 4], AnimationError> { + let len2 = dot4(quat, quat); + if !len2.is_finite() || len2 <= f32::EPSILON { + return Err(AnimationError::InvalidQuaternion); + } + let inv = len2.sqrt().recip(); + Ok([quat[0] * inv, quat[1] * inv, quat[2] * inv, quat[3] * inv]) +} + +fn dot4(left: [f32; 4], right: [f32; 4]) -> f32 { + left[0] * right[0] + left[1] * right[1] + left[2] * right[2] + left[3] * right[3] +} + +fn lerp(left: f32, right: f32, t: f32) -> f32 { + left + (right - left) * t +} + +fn splitmix64(mut value: u64) -> u64 { + value = value.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut mixed = value; + mixed = (mixed ^ (mixed >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + mixed = (mixed ^ (mixed >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + mixed ^ (mixed >> 31) +} + +fn write_f32_bits(out: &mut Vec, value: f32) { + out.extend_from_slice(format!("{:08x}", value.to_bits()).as_bytes()); +} + +fn compose_pose(parent: Pose, child: Pose) -> Result { + Ok(Pose { + translation: [ + parent.translation[0] + child.translation[0], + parent.translation[1] + child.translation[1], + parent.translation[2] + child.translation[2], + ], + rotation: normalize_quat(mul_quat(parent.rotation, child.rotation))?, + }) +} + +fn mul_quat(left: [f32; 4], right: [f32; 4]) -> [f32; 4] { + let [lx, ly, lz, lw] = left; + let [rx, ry, rz, rw] = right; + [ + lw * rx + lx * rw + ly * rz - lz * ry, + lw * ry - lx * rz + ly * rw + lz * rx, + lw * rz + lx * ry - ly * rx + lz * rw, + lw * rw - lx * rx - ly * ry - lz * rz, + ] +} + +fn read_u16(bytes: &[u8], offset: usize) -> Result { + let raw = bytes + .get(offset..offset + 2) + .ok_or(AnimationError::InvalidSize)?; + Ok(u16::from_le_bytes( + raw.try_into().map_err(|_| AnimationError::InvalidSize)?, + )) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Result { + let raw = bytes + .get(offset..offset + 4) + .ok_or(AnimationError::InvalidSize)?; + Ok(u32::from_le_bytes( + raw.try_into().map_err(|_| AnimationError::InvalidSize)?, + )) +} + +fn read_f32(bytes: &[u8], offset: usize) -> Result { + Ok(f32::from_bits(read_u32(bytes, offset)?)) +} + +fn read_i16(bytes: &[u8], offset: usize) -> Result { + let raw = bytes + .get(offset..offset + 2) + .ok_or(AnimationError::InvalidSize)?; + Ok(i16::from_le_bytes( + raw.try_into().map_err(|_| AnimationError::InvalidSize)?, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scalar_track_clamps_and_interpolates() { + let track = ScalarTrack::new( + -1.0, + vec![ + ScalarKey { + frame: 10, + value: 2.0, + }, + ScalarKey { + frame: 20, + value: 6.0, + }, + ], + ) + .expect("track"); + + assert_eq!(track.sample(AnimationTime(0.0)).expect("sample"), 2.0); + assert_eq!(track.sample(AnimationTime(15.0)).expect("sample"), 4.0); + assert_eq!(track.sample(AnimationTime(30.0)).expect("sample"), 6.0); + } + + #[test] + fn anim_key24_decodes_signed_quaternion() { + let mut bytes = [0_u8; 24]; + bytes[0..4].copy_from_slice(&(-1.0_f32).to_bits().to_le_bytes()); + bytes[4..8].copy_from_slice(&(2.0_f32).to_bits().to_le_bytes()); + bytes[8..12].copy_from_slice(&(0.0_f32).to_bits().to_le_bytes()); + bytes[12..16].copy_from_slice(&(12.5_f32).to_bits().to_le_bytes()); + bytes[16..18].copy_from_slice(&0_i16.to_le_bytes()); + bytes[18..20].copy_from_slice(&(-23170_i16).to_le_bytes()); + bytes[20..22].copy_from_slice(&0_i16.to_le_bytes()); + bytes[22..24].copy_from_slice(&23170_i16.to_le_bytes()); + + let key = AnimKey24::decode(&bytes).expect("key"); + + assert_eq!(key.time, AnimationTime(12.5)); + assert_eq!(key.pose.translation, [-1.0, 2.0, 0.0]); + assert!(key.pose.rotation[1] < 0.0); + assert!((key.pose.rotation[1] + std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05); + assert!((key.pose.rotation[3] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05); + } + + #[test] + fn frame_map_decodes_u16_and_uses_attr_frame_count() { + let map = FrameMap::decode(&[2, 0, 4, 0], 5).expect("map"); + + assert_eq!(map.attr_frame_count(), 5); + assert_eq!(map.frames(), &[2, 4]); + assert_eq!(map.resolve_strict(0).expect("mapped"), 2); + assert_eq!(map.resolve_strict(2).expect("fallback missing"), 2); + } + + #[test] + fn frame_map_falls_back_when_absent_or_invalid() { + let empty = FrameMap::decode(&[], 3).expect("empty map"); + let invalid = FrameMap::decode(&[5, 0], 3).expect("invalid map"); + + assert_eq!(empty.resolve_or_fallback(2), 2); + assert_eq!(invalid.resolve_or_fallback(0), 0); + assert_eq!( + invalid.resolve_strict(0).expect_err("invalid mapped value"), + AnimationError::InvalidFrameMapValue { + frame: 5, + frame_count: 3, + } + ); + } + + #[test] + fn exact_key_time_returns_exact_pose() { + let target = Pose { + translation: [1.0, 2.0, 3.0], + rotation: [0.0, 0.0, 1.0, 0.0], + }; + let track = PoseTrack::new( + Pose::default(), + vec![ + PoseKey { + frame: 0, + pose: Pose::default(), + }, + PoseKey { + frame: 8, + pose: target, + }, + ], + ) + .expect("track"); + + assert_eq!( + track + .sample(AnimationTime(8.0), NumericProfile::PortableReference) + .expect("pose"), + target + ); + } + + #[test] + fn scalar_track_uses_fallback_when_empty() { + let track = ScalarTrack::new(3.5, Vec::new()).expect("track"); + + assert_eq!(track.sample(AnimationTime(4.0)).expect("sample"), 3.5); + } + + #[test] + fn rejects_unsorted_keys_and_invalid_time() { + let track = ScalarTrack::new( + 0.0, + vec![ + ScalarKey { + frame: 7, + value: 0.0, + }, + ScalarKey { + frame: 7, + value: 1.0, + }, + ], + ); + assert_eq!( + track.expect_err("unsorted"), + AnimationError::NonMonotonicKeys + ); + + let track = ScalarTrack::new(0.0, Vec::new()).expect("track"); + assert_eq!( + track + .sample(AnimationTime(f32::NAN)) + .expect_err("invalid time"), + AnimationError::InvalidTime + ); + } + + #[test] + fn pose_track_blends_translation_and_rotation() { + let track = PoseTrack::new( + Pose::default(), + vec![ + PoseKey { + frame: 0, + pose: Pose::default(), + }, + PoseKey { + frame: 10, + pose: Pose { + translation: [10.0, 20.0, 30.0], + rotation: [0.0, 1.0, 0.0, 0.0], + }, + }, + ], + ) + .expect("track"); + + let pose = track + .sample(AnimationTime(5.0), NumericProfile::PortableReference) + .expect("pose"); + + assert_eq!(pose.translation, [5.0, 10.0, 15.0]); + assert!((pose.rotation[1] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_001); + assert!((pose.rotation[3] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_001); + } + + #[test] + fn timed_pose_track_samples_float_key_times() { + let track = TimedPoseTrack::new( + Pose::default(), + vec![ + TimedPoseKey { + time: AnimationTime(1.5), + pose: Pose::default(), + }, + TimedPoseKey { + time: AnimationTime(3.5), + pose: Pose { + translation: [4.0, 8.0, 12.0], + rotation: [0.0, 0.0, 1.0, 0.0], + }, + }, + ], + ) + .expect("track"); + + let pose = track.sample(AnimationTime(2.5)).expect("pose"); + + assert_eq!(pose.translation, [2.0, 4.0, 6.0]); + assert!((pose.rotation[2] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_001); + assert!((pose.rotation[3] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_001); + } + + #[test] + fn quaternion_shortest_path_sign_flip_is_stable() { + let track = PoseTrack::new( + Pose::default(), + vec![ + PoseKey { + frame: 0, + pose: Pose { + translation: [0.0; 3], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + PoseKey { + frame: 10, + pose: Pose { + translation: [0.0; 3], + rotation: [0.0, 0.0, 0.0, -1.0], + }, + }, + ], + ) + .expect("track"); + + let pose = track + .sample(AnimationTime(5.0), NumericProfile::PortableReference) + .expect("pose"); + + assert_eq!(pose.rotation, [0.0, 0.0, 0.0, 1.0]); + } + + #[test] + fn zero_or_degenerate_key_interval_is_rejected() { + let track = PoseTrack::new( + Pose::default(), + vec![ + PoseKey { + frame: 1, + pose: Pose::default(), + }, + PoseKey { + frame: 1, + pose: Pose::default(), + }, + ], + ); + + assert_eq!( + track.expect_err("duplicate key"), + AnimationError::NonMonotonicKeys + ); + } + + #[test] + fn x87_boundary_golden_vectors_and_profile_difference_report() { + let track = PoseTrack::new( + Pose::default(), + vec![ + PoseKey { + frame: 0, + pose: Pose::default(), + }, + PoseKey { + frame: 2, + pose: Pose { + translation: [2.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + ], + ) + .expect("track"); + + let portable = track + .sample(AnimationTime(1.0), NumericProfile::PortableReference) + .expect("portable"); + let x87 = track + .sample(AnimationTime(1.0), NumericProfile::X87Compatibility) + .expect("x87"); + + assert_eq!(portable, x87); + assert_eq!(portable.translation, [1.0, 0.0, 0.0]); + assert_eq!( + compare_numeric_profiles(&track, &[AnimationTime(1.0)]).expect("diff"), + vec![NumericProfileDifference { + time: AnimationTime(1.0), + translation_delta: [0.0; 3], + rotation_delta: [0.0; 4], + }] + ); + } + + #[test] + fn blend_optional_pose_uses_valid_side() { + let valid = Pose { + translation: [3.0, 4.0, 5.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }; + + assert_eq!( + blend_optional_pose(Some(valid), None, 0.5).expect("left"), + valid + ); + assert_eq!( + blend_optional_pose(None, Some(valid), 0.5).expect("right"), + valid + ); + assert_eq!( + blend_optional_pose(None, None, 0.5).expect_err("invalid"), + AnimationError::InvalidQuaternion + ); + } + + #[test] + fn hierarchy_evaluates_parent_before_child_and_rejects_cycles() { + let local = vec![ + Pose { + translation: [1.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + Pose { + translation: [0.0, 2.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + ]; + + let buffer = evaluate_hierarchy(&[ParentIndex(None), ParentIndex(Some(0))], &local) + .expect("hierarchy"); + + assert_eq!(buffer.poses[0].translation, [1.0, 0.0, 0.0]); + assert_eq!(buffer.poses[1].translation, [1.0, 2.0, 0.0]); + assert_eq!( + evaluate_hierarchy(&[ParentIndex(Some(0))], &[Pose::default()]).expect_err("cycle"), + AnimationError::ParentCycle { node: 0 } + ); + assert_eq!( + evaluate_hierarchy( + &[ParentIndex(Some(1)), ParentIndex(None)], + &[Pose::default(), Pose::default()], + ) + .expect_err("order"), + AnimationError::ParentOrder { + child: 0, + parent: 1, + } + ); + } + + #[test] + fn generated_valid_quaternions_remain_finite() { + for index in 1..64_u16 { + let mut bytes = [0_u8; 24]; + bytes[12..16].copy_from_slice(&f32::from(index).to_bits().to_le_bytes()); + bytes[16..18].copy_from_slice(&(i16::try_from(index).expect("small")).to_le_bytes()); + bytes[18..20].copy_from_slice(&123_i16.to_le_bytes()); + bytes[20..22].copy_from_slice(&(-456_i16).to_le_bytes()); + bytes[22..24].copy_from_slice(&32767_i16.to_le_bytes()); + + let key = AnimKey24::decode(&bytes).expect("key"); + + assert!(key.pose.rotation.iter().all(|value| value.is_finite())); + } + } + + #[test] + fn named_rng_stream_is_stable_and_named() { + let mut material_a = NamedRngStream::new(42, "material"); + let mut material_b = NamedRngStream::new(42, "material"); + let mut fx = NamedRngStream::new(42, "fx"); + + assert_eq!(material_a.next_u32(), material_b.next_u32()); + assert_ne!(material_a.next_u32(), fx.next_u32()); + assert_eq!(material_a.calls(), 2); + } + + #[test] + fn pose_capture_uses_float_bits() { + let track = PoseTrack::new( + Pose::default(), + vec![PoseKey { + frame: 0, + pose: Pose::default(), + }], + ) + .expect("track"); + + let capture = canonical_pose_capture(&track, &[AnimationTime(0.0)]).expect("capture"); + + assert_eq!( + capture, + b"P,00000000,00000000,00000000,00000000,00000000,00000000,00000000,3f800000\n" + ); + } + + #[test] + fn timed_pose_capture_uses_float_bits() { + let track = TimedPoseTrack::new( + Pose::default(), + vec![TimedPoseKey { + time: AnimationTime(0.5), + pose: Pose::default(), + }], + ) + .expect("track"); + + let capture = canonical_timed_pose_capture(&track, &[AnimationTime(0.5)]).expect("capture"); + + assert_eq!( + capture, + b"P,3f000000,00000000,00000000,00000000,00000000,00000000,00000000,3f800000\n" + ); + } +} diff --git a/crates/fparkan-assets/Cargo.toml b/crates/fparkan-assets/Cargo.toml new file mode 100644 index 0000000..4b901f3 --- /dev/null +++ b/crates/fparkan-assets/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "fparkan-assets" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-material = { path = "../fparkan-material" } +fparkan-msh = { path = "../fparkan-msh" } +fparkan-nres = { path = "../fparkan-nres" } +fparkan-path = { path = "../fparkan-path" } +fparkan-prototype = { path = "../fparkan-prototype" } +fparkan-resource = { path = "../fparkan-resource" } +fparkan-texm = { path = "../fparkan-texm" } + +[dev-dependencies] +fparkan-vfs = { path = "../fparkan-vfs" } + +[lints] +workspace = true diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs new file mode 100644 index 0000000..78ffb0b --- /dev/null +++ b/crates/fparkan-assets/src/lib.rs @@ -0,0 +1,481 @@ +#![forbid(unsafe_code)] +//! Asset manager ports and transactional preparation models. + +use fparkan_material::{decode_wear, resolve_material, WEAR_KIND}; +use fparkan_msh::{decode_msh, validate_msh}; +use fparkan_nres::{decode as decode_nres, ReadProfile}; +use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName}; +use fparkan_prototype::{EffectivePrototype, PrototypeGeometry, PrototypeGraph}; +use fparkan_resource::{ResourceKey, ResourceRepository}; +use fparkan_texm::decode_texm; +use std::collections::BTreeSet; +use std::fmt; +use std::hash::{Hash, Hasher}; +use std::marker::PhantomData; +use std::sync::Arc; + +const TEXTURES_ARCHIVE: &str = "textures.lib"; +const LIGHTMAP_ARCHIVE: &str = "lightmap.lib"; + +/// Stable typed identifier for a prepared asset. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub struct AssetId { + raw: u64, + marker: PhantomData, +} + +impl AssetId { + /// Creates an asset id from a stable raw value. + #[must_use] + pub const fn new(raw: u64) -> Self { + Self { + raw, + marker: PhantomData, + } + } + + /// Returns the stable raw id. + #[must_use] + pub const fn raw(self) -> u64 { + self.raw + } +} + +/// CPU-side data needed before a visual can be handed to a renderer. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreparedVisual { + /// Stable id derived from the prototype geometry key. + pub id: AssetId, + /// Optional mesh resource backing the visual. + pub mesh: Option, + /// Number of validated model nodes. + pub model_nodes: usize, + /// Number of validated material slots on the model. + pub model_slots: usize, + /// Number of validated render batches. + pub model_batches: usize, + /// Number of WEAR material slots resolved through MAT0. + pub material_count: usize, + /// Number of texture phase requests decoded as TEXM. + pub texture_count: usize, + /// Number of lightmap requests decoded as TEXM. + pub lightmap_count: usize, +} + +/// A transactional mission asset preparation plan. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct MissionAssetPlan { + /// Number of visual prototypes in the plan. + pub visual_count: usize, + /// Number of mesh-backed visuals. + pub model_count: usize, + /// Number of material slot requests. + pub material_count: usize, + /// Number of texture phase requests. + pub texture_count: usize, + /// Number of lightmap requests. + pub lightmap_count: usize, +} + +/// Coarse CPU-side asset budgets. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct AssetBudgets { + /// Bytes parsed from source resource payloads. + pub parsed_bytes: u64, +} + +/// Errors raised while preparing CPU-side assets. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AssetError { + /// A required cross-resource dependency was not found. + MissingDependency(String), + /// A prototype did not describe a usable visual. + InvalidPrototype(String), + /// A repository operation failed. + Resource(String), + /// MSH parsing or validation failed. + Msh(String), + /// WEAR/MAT0 parsing or resolution failed. + Material(String), + /// TEXM parsing failed. + Texture(String), +} + +impl fmt::Display for AssetError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingDependency(value) => write!(f, "missing dependency: {value}"), + Self::InvalidPrototype(value) => write!(f, "invalid prototype: {value}"), + Self::Resource(value) => write!(f, "resource error: {value}"), + Self::Msh(value) => write!(f, "msh error: {value}"), + Self::Material(value) => write!(f, "material error: {value}"), + Self::Texture(value) => write!(f, "texture error: {value}"), + } + } +} + +impl std::error::Error for AssetError {} + +/// Port implemented by typed asset loaders. +pub trait AssetLoader { + /// Loads an asset for the given resource key. + /// + /// # Errors + /// + /// Returns [`AssetError`] when the resource cannot be resolved or decoded. + fn load(&self, key: &ResourceKey) -> Result, AssetError>; +} + +/// Minimal asset manager façade over an immutable resource repository. +#[derive(Debug)] +pub struct AssetManager { + repository: R, +} + +impl AssetManager { + /// Creates a manager backed by the given repository. + #[must_use] + pub const fn new(repository: R) -> Self { + Self { repository } + } + + /// Returns the backing repository. + #[must_use] + pub const fn repository(&self) -> &R { + &self.repository + } +} + +impl AssetManager { + /// Prepares one prototype visual using the manager repository. + /// + /// # Errors + /// + /// Returns [`AssetError`] if any model, material, texture, or lightmap + /// dependency is missing or malformed. + pub fn prepare_visual(&self, proto: &EffectivePrototype) -> Result { + prepare_visual_with_repository(&self.repository, proto) + } + + /// Builds a mission plan by preparing each resolved prototype. + /// + /// # Errors + /// + /// Returns [`AssetError`] if any visual dependency is missing or malformed. + pub fn build_mission_asset_plan<'a>( + &self, + prototypes: impl IntoIterator, + ) -> Result { + build_mission_asset_plan_with_repository(&self.repository, prototypes) + } +} + +/// Produces a count-only plan from a prototype graph. +#[must_use] +pub fn build_mission_asset_plan(graph: &PrototypeGraph) -> MissionAssetPlan { + MissionAssetPlan { + visual_count: graph.prototype_requests.len(), + ..MissionAssetPlan::default() + } +} + +/// Builds a fully validated CPU-side mission asset plan. +/// +/// # Errors +/// +/// Returns [`AssetError`] if any reachable visual dependency is missing or +/// malformed. +pub fn build_mission_asset_plan_with_repository<'a, R: ResourceRepository>( + repository: &R, + prototypes: impl IntoIterator, +) -> Result { + let mut plan = MissionAssetPlan::default(); + let mut prepared_visuals = BTreeSet::new(); + + for proto in prototypes { + let visual_id = stable_visual_id(proto); + if !prepared_visuals.insert(visual_id) { + continue; + } + let visual = prepare_visual_with_repository(repository, proto)?; + plan.visual_count += 1; + if visual.mesh.is_some() { + plan.model_count += 1; + } + plan.material_count += visual.material_count; + plan.texture_count += visual.texture_count; + plan.lightmap_count += visual.lightmap_count; + } + + Ok(plan) +} + +/// Validates a prototype visual without resolving cross-resource dependencies. +/// +/// This is useful for tests and API callers that only need a stable visual id. +/// +/// # Errors +/// +/// Returns [`AssetError`] when the prototype geometry is malformed. +pub fn prepare_visual(proto: &EffectivePrototype) -> Result { + let id = stable_visual_id(proto); + let mesh = match &proto.geometry { + PrototypeGeometry::Mesh(key) => Some(key.clone()), + PrototypeGeometry::NonGeometric => None, + }; + + Ok(PreparedVisual { + id: AssetId::new(id), + mesh, + model_nodes: 0, + model_slots: 0, + model_batches: 0, + material_count: 0, + texture_count: 0, + lightmap_count: 0, + }) +} + +/// Prepares one visual and validates all CPU-side resource dependencies. +/// +/// # Errors +/// +/// Returns [`AssetError`] if the model, WEAR table, MAT0 materials, texture +/// phases, or lightmaps cannot be resolved and decoded. +pub fn prepare_visual_with_repository( + repository: &R, + proto: &EffectivePrototype, +) -> Result { + let PrototypeGeometry::Mesh(mesh_key) = &proto.geometry else { + return prepare_visual(proto); + }; + + let nres = decode_nres( + read_key(repository, mesh_key, Some("mesh"))?, + ReadProfile::Compatible, + ) + .map_err(|err| AssetError::Msh(err.to_string()))?; + let msh_document = decode_msh(&nres).map_err(|err| AssetError::Msh(err.to_string()))?; + let model = validate_msh(&msh_document).map_err(|err| AssetError::Msh(err.to_string()))?; + + let wear_name = sibling_name(mesh_key, "wea")?; + let wear_key = ResourceKey { + archive: mesh_key.archive.clone(), + name: wear_name, + type_id: Some(WEAR_KIND), + }; + let wear = decode_wear(&read_key(repository, &wear_key, Some("wear"))?) + .map_err(|err| AssetError::Material(err.to_string()))?; + + let mut material_count = 0; + let mut texture_count = 0; + let mut lightmap_count = 0; + for material_index in 0..wear.entries.len() { + let material_index = u16::try_from(material_index).map_err(|_| { + AssetError::Material("material index does not fit archive format".to_string()) + })?; + let material = resolve_material(repository, &wear, material_index) + .map_err(|err| AssetError::Material(err.to_string()))?; + material_count += 1; + + for texture in material.document.texture_requests() { + resolve_texm(repository, &texture, &[TEXTURES_ARCHIVE, LIGHTMAP_ARCHIVE])?; + texture_count += 1; + } + } + + for lightmap in &wear.lightmaps { + resolve_texm( + repository, + &lightmap.lightmap, + &[LIGHTMAP_ARCHIVE, TEXTURES_ARCHIVE], + )?; + lightmap_count += 1; + } + + Ok(PreparedVisual { + id: AssetId::new(stable_visual_id(proto)), + mesh: Some(mesh_key.clone()), + model_nodes: model.node_count, + model_slots: model.slots.len(), + model_batches: model.batches.len(), + material_count, + texture_count, + lightmap_count, + }) +} + +fn read_key( + repository: &R, + key: &ResourceKey, + label: Option<&str>, +) -> Result, AssetError> { + let handle = repository + .open_archive(&key.archive) + .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}"))) + .and_then(|archive| { + repository + .find(archive, &key.name) + .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}"))) + })? + .ok_or_else(|| AssetError::MissingDependency(format!("{label:?} {key:?}")))?; + let bytes = repository + .read(handle) + .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))?; + Ok(Arc::from(bytes.into_owned())) +} + +fn resolve_texm( + repository: &R, + name: &ResourceName, + archives: &[&str], +) -> Result<(), AssetError> { + for archive in archives { + let key = ResourceKey { + archive: parse_path(archive)?, + name: name.clone(), + type_id: None, + }; + match read_key(repository, &key, Some("texm")) { + Ok(bytes) => { + decode_texm(bytes).map_err(|err| AssetError::Texture(err.to_string()))?; + return Ok(()); + } + Err(AssetError::MissingDependency(_) | AssetError::Resource(_)) => {} + Err(err) => return Err(err), + } + } + + Err(AssetError::MissingDependency(format!("{name:?}"))) +} + +fn sibling_name(key: &ResourceKey, extension: &str) -> Result { + let dot = key + .name + .0 + .iter() + .rposition(|byte| *byte == b'.') + .ok_or_else(|| { + AssetError::InvalidPrototype(format!("resource name has no extension: {:?}", key.name)) + })?; + let mut name = key.name.0[..dot].to_vec(); + name.push(b'.'); + name.extend_from_slice(extension.as_bytes()); + Ok(ResourceName(name)) +} + +fn stable_visual_id(proto: &EffectivePrototype) -> u64 { + let mut hasher = StableHasher::default(); + match &proto.geometry { + PrototypeGeometry::Mesh(key) => { + 1_u8.hash(&mut hasher); + key.archive.as_str().hash(&mut hasher); + key.name.0.hash(&mut hasher); + key.type_id.hash(&mut hasher); + } + PrototypeGeometry::NonGeometric => { + 0_u8.hash(&mut hasher); + } + } + hasher.finish() +} + +fn parse_path(value: &str) -> Result { + normalize_relative(value.as_bytes(), PathPolicy::HostCompatible) + .map_err(|err| AssetError::InvalidPrototype(format!("{err}"))) +} + +#[derive(Default)] +struct StableHasher(u64); + +impl Hasher for StableHasher { + fn finish(&self) -> u64 { + self.0 + } + + fn write(&mut self, bytes: &[u8]) { + let mut value = if self.0 == 0 { + 0xcbf2_9ce4_8422_2325 + } else { + self.0 + }; + for byte in bytes { + value ^= u64::from(*byte); + value = value.wrapping_mul(0x0000_0100_0000_01b3); + } + self.0 = value; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_prototype::build_prototype_graph; + use fparkan_resource::{resource_name, CachedResourceRepository}; + use fparkan_vfs::{DirectoryVfs, Vfs}; + use std::path::PathBuf; + + #[test] + fn count_only_plan_uses_graph_requests() { + let graph = PrototypeGraph::default(); + + let plan = build_mission_asset_plan(&graph); + + assert_eq!(plan.visual_count, 0); + assert_eq!(plan.model_count, 0); + } + + #[test] + fn prepares_real_unit_asset_plan() { + let root = fixture_root("IS"); + let vfs: Arc = Arc::new(DirectoryVfs::new(&root)); + let repository = CachedResourceRepository::new(Arc::clone(&vfs)); + let roots = [resource_name(b"UNITS/AUTO/swlklas.dat")]; + + let (graph, prototypes) = + build_prototype_graph(&repository, vfs.as_ref(), &roots).expect("prototype graph"); + let count_only = build_mission_asset_plan(&graph); + let plan = build_mission_asset_plan_with_repository(&repository, &prototypes) + .expect("asset preparation"); + + assert_eq!(count_only.visual_count, 12); + assert_eq!(prototypes.len(), 12); + assert_eq!(plan.visual_count, 11); + assert_eq!(plan.model_count, 11); + assert_eq!(plan.material_count, 62); + assert_eq!(plan.texture_count, 77); + assert_eq!(plan.lightmap_count, 0); + } + + #[test] + fn repository_plan_deduplicates_duplicate_visuals_but_graph_preserves_requests() { + let root = fixture_root("IS"); + let vfs: Arc = Arc::new(DirectoryVfs::new(&root)); + let repository = CachedResourceRepository::new(Arc::clone(&vfs)); + let roots = [ + resource_name(b"UNITS/AUTO/swlklas.dat"), + resource_name(b"UNITS/AUTO/swlklas.dat"), + ]; + + let (graph, prototypes) = + build_prototype_graph(&repository, vfs.as_ref(), &roots).expect("prototype graph"); + let count_only = build_mission_asset_plan(&graph); + let plan = build_mission_asset_plan_with_repository(&repository, &prototypes) + .expect("asset preparation"); + + assert_eq!(graph.roots.len(), 2); + assert_eq!(count_only.visual_count, 24); + assert_eq!(prototypes.len(), 24); + assert_eq!(plan.visual_count, 11); + assert_eq!(plan.model_count, 11); + assert_eq!(plan.material_count, 62); + assert_eq!(plan.texture_count, 77); + } + + fn fixture_root(part: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("testdata") + .join(part) + } +} diff --git a/crates/fparkan-binary/Cargo.toml b/crates/fparkan-binary/Cargo.toml new file mode 100644 index 0000000..2bab5be --- /dev/null +++ b/crates/fparkan-binary/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "fparkan-binary" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] + +[lints] +workspace = true diff --git a/crates/fparkan-binary/src/lib.rs b/crates/fparkan-binary/src/lib.rs new file mode 100644 index 0000000..ef5a0e4 --- /dev/null +++ b/crates/fparkan-binary/src/lib.rs @@ -0,0 +1,308 @@ +#![forbid(unsafe_code)] +//! Bounded little-endian binary cursor and checked layout helpers. + +use std::fmt; + +/// Parser limits shared by binary formats. +#[derive(Clone, Copy, Debug)] +pub struct Limits { + /// Maximum file bytes. + pub max_file_bytes: u64, + /// Maximum entries. + pub max_entries: u32, + /// Maximum string bytes. + pub max_string_bytes: u32, + /// Maximum array items. + pub max_array_items: u32, + /// Maximum recursion depth. + pub max_recursion_depth: u16, + /// Maximum decoded bytes. + pub max_decoded_bytes: u64, +} + +impl Default for Limits { + fn default() -> Self { + Self { + max_file_bytes: 256 * 1024 * 1024, + max_entries: 1_000_000, + max_string_bytes: 64 * 1024, + max_array_items: 1_000_000, + max_recursion_depth: 64, + max_decoded_bytes: 512 * 1024 * 1024, + } + } +} + +/// Decode error. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DecodeError { + /// Input ended before requested bytes. + UnexpectedEof { + /// Offset where read was attempted. + offset: u64, + /// Required byte count. + needed: u64, + /// Remaining byte count. + remaining: u64, + }, + /// Arithmetic overflow. + IntegerOverflow, + /// Count exceeds limit. + LimitExceeded { + /// Declared count. + count: u64, + /// Configured limit. + limit: u64, + }, + /// Cursor did not end at EOF. + TrailingBytes { + /// Offset where EOF was expected. + offset: u64, + /// Remaining byte count. + remaining: u64, + }, + /// Invalid data. + Invalid(&'static str), +} + +impl fmt::Display for DecodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnexpectedEof { + offset, + needed, + remaining, + } => write!( + f, + "unexpected EOF at {offset}: need {needed}, have {remaining}" + ), + Self::IntegerOverflow => write!(f, "integer overflow"), + Self::LimitExceeded { count, limit } => { + write!(f, "count {count} exceeds limit {limit}") + } + Self::TrailingBytes { offset, remaining } => { + write!(f, "trailing bytes at {offset}: {remaining}") + } + Self::Invalid(reason) => write!(f, "invalid data: {reason}"), + } + } +} + +impl std::error::Error for DecodeError {} + +/// Cursor checkpoint. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Checkpoint(pub u64); + +/// Bounded cursor. +#[derive(Clone, Debug)] +pub struct Cursor<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> Cursor<'a> { + /// Creates a cursor. + #[must_use] + pub fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + /// Current offset. + #[must_use] + pub fn offset(&self) -> u64 { + self.offset as u64 + } + + /// Remaining bytes. + #[must_use] + pub fn remaining(&self) -> usize { + self.bytes.len().saturating_sub(self.offset) + } + + /// Creates a checkpoint. + #[must_use] + pub fn checkpoint(&self) -> Checkpoint { + Checkpoint(self.offset()) + } + + /// Reads exact bytes. + /// + /// # Errors + /// + /// Returns [`DecodeError::IntegerOverflow`] if the requested end offset + /// overflows, or [`DecodeError::UnexpectedEof`] if there are not enough + /// bytes remaining. + pub fn read_exact(&mut self, len: usize) -> Result<&'a [u8], DecodeError> { + let end = self + .offset + .checked_add(len) + .ok_or(DecodeError::IntegerOverflow)?; + if end > self.bytes.len() { + return Err(DecodeError::UnexpectedEof { + offset: self.offset(), + needed: len as u64, + remaining: self.remaining() as u64, + }); + } + let out = &self.bytes[self.offset..end]; + self.offset = end; + Ok(out) + } + + /// Reads a little-endian u16. + /// + /// # Errors + /// + /// Returns [`DecodeError`] if two bytes cannot be read. + pub fn read_u16_le(&mut self) -> Result { + let b = self.read_exact(2)?; + Ok(u16::from_le_bytes([b[0], b[1]])) + } + + /// Reads a little-endian u32. + /// + /// # Errors + /// + /// Returns [`DecodeError`] if four bytes cannot be read. + pub fn read_u32_le(&mut self) -> Result { + let b = self.read_exact(4)?; + Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + + /// Reads a little-endian i32. + /// + /// # Errors + /// + /// Returns [`DecodeError`] if four bytes cannot be read. + pub fn read_i32_le(&mut self) -> Result { + let b = self.read_exact(4)?; + Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + + /// Reads a little-endian f32. + /// + /// # Errors + /// + /// Returns [`DecodeError`] if four bytes cannot be read. + pub fn read_f32_le(&mut self) -> Result { + Ok(f32::from_bits(self.read_u32_le()?)) + } + + /// Requires exact EOF. + /// + /// # Errors + /// + /// Returns [`DecodeError::TrailingBytes`] when unread bytes remain. + pub fn require_eof(&self) -> Result<(), DecodeError> { + if self.remaining() == 0 { + Ok(()) + } else { + Err(DecodeError::TrailingBytes { + offset: self.offset(), + remaining: self.remaining() as u64, + }) + } + } +} + +/// Validates `count * stride <= remaining` and returns bytes as usize. +/// +/// # Errors +/// +/// Returns [`DecodeError::IntegerOverflow`] on arithmetic or conversion +/// overflow, or [`DecodeError::UnexpectedEof`] when the declared byte count is +/// larger than the remaining bounded input. +pub fn checked_count_bytes(count: u64, stride: u64, remaining: u64) -> Result { + let bytes = count + .checked_mul(stride) + .ok_or(DecodeError::IntegerOverflow)?; + if bytes > remaining { + return Err(DecodeError::UnexpectedEof { + offset: 0, + needed: bytes, + remaining, + }); + } + usize::try_from(bytes).map_err(|_| DecodeError::IntegerOverflow) +} + +/// Validates a declared allocation size before constructing the allocation. +/// +/// # Errors +/// +/// Returns [`DecodeError::LimitExceeded`] when `declared` is larger than +/// `limit`, or [`DecodeError::IntegerOverflow`] when the accepted size cannot +/// be represented by the host `usize`. +pub fn checked_allocation_len(declared: u64, limit: u64) -> Result { + if declared > limit { + return Err(DecodeError::LimitExceeded { + count: declared, + limit, + }); + } + usize::try_from(declared).map_err(|_| DecodeError::IntegerOverflow) +} + +/// Reads length-prefixed bytes. +/// +/// # Errors +/// +/// Returns [`DecodeError`] if the length cannot be read, exceeds `max`, or the +/// declared payload is truncated. +pub fn read_lp_bytes(cursor: &mut Cursor<'_>, max: u32) -> Result, DecodeError> { + let len = cursor.read_u32_le()?; + if len > max { + return Err(DecodeError::LimitExceeded { + count: u64::from(len), + limit: u64::from(max), + }); + } + let len = checked_allocation_len(u64::from(len), u64::from(max))?; + Ok(cursor.read_exact(len)?.to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_count_stride_overflow() { + assert_eq!( + checked_count_bytes(u64::MAX, 2, u64::MAX), + Err(DecodeError::IntegerOverflow) + ); + } + + #[test] + fn exact_eof_reports_trailing() { + let mut cursor = Cursor::new(&[1, 2]); + assert_eq!(cursor.read_exact(1).expect("byte"), &[1]); + assert!(matches!( + cursor.require_eof(), + Err(DecodeError::TrailingBytes { .. }) + )); + } + + #[test] + fn rejects_oversized_declared_allocation_before_read() { + assert_eq!( + checked_allocation_len(1025, 1024), + Err(DecodeError::LimitExceeded { + count: 1025, + limit: 1024 + }) + ); + + let bytes = 2048u32.to_le_bytes(); + let mut cursor = Cursor::new(&bytes); + assert_eq!( + read_lp_bytes(&mut cursor, 1024), + Err(DecodeError::LimitExceeded { + count: 2048, + limit: 1024 + }) + ); + assert_eq!(cursor.offset(), 4); + } +} diff --git a/crates/fparkan-corpus/Cargo.toml b/crates/fparkan-corpus/Cargo.toml new file mode 100644 index 0000000..29fb436 --- /dev/null +++ b/crates/fparkan-corpus/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fparkan-corpus" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-path = { path = "../fparkan-path" } + +[lints] +workspace = true diff --git a/crates/fparkan-corpus/src/lib.rs b/crates/fparkan-corpus/src/lib.rs new file mode 100644 index 0000000..ba26c73 --- /dev/null +++ b/crates/fparkan-corpus/src/lib.rs @@ -0,0 +1,695 @@ +#![forbid(unsafe_code)] +//! Licensed corpus discovery and aggregate reports. + +use fparkan_path::{ascii_lookup_key, normalize_relative, PathPolicy}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// Corpus kind. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CorpusKind { + /// Demo corpus. + Demo, + /// Part 1 full game. + Part1, + /// Part 2 full game. + Part2, + /// Unknown local directory. + Unknown, +} + +/// Corpus root. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CorpusRoot(pub PathBuf); + +/// Discovery options. +#[derive(Clone, Copy, Debug, Default)] +pub struct DiscoverOptions { + /// Whether symlinks may be traversed. + pub follow_symlinks: bool, +} + +/// File manifest entry. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ManifestEntry { + /// Normalized relative path. + pub path: String, + /// File size in bytes. + pub size: u64, + /// Stable content fingerprint. + pub hash: u64, +} + +/// Corpus manifest. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CorpusManifest { + /// Kind. + pub kind: CorpusKind, + /// Sorted files. + pub files: Vec, + /// Casefold collisions. + pub casefold_collisions: Vec>, +} + +/// Aggregate report. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CorpusReport { + /// Schema version. + pub schema: u32, + /// Kind. + pub kind: CorpusKind, + /// Total files. + pub files: usize, + /// Total bytes. + pub bytes: u64, + /// Metrics. + pub metrics: BTreeMap, + /// Casefold collision count. + pub casefold_collisions: usize, + /// Manifest fingerprint. + pub fingerprint: u64, +} + +/// Corpus error. +#[derive(Debug)] +pub enum CorpusError { + /// I/O failure. + Io { + /// Path where I/O failed. + path: PathBuf, + /// Source error. + source: std::io::Error, + }, + /// Invalid root. + InvalidRoot(PathBuf), + /// Invalid path. + InvalidPath(String), +} + +impl fmt::Display for CorpusError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { path, source } => write!(f, "{}: {source}", path.display()), + Self::InvalidRoot(path) => write!(f, "invalid corpus root: {}", path.display()), + Self::InvalidPath(path) => write!(f, "invalid corpus path: {path}"), + } + } +} + +impl std::error::Error for CorpusError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io { source, .. } => Some(source), + Self::InvalidRoot(_) | Self::InvalidPath(_) => None, + } + } +} + +/// Discovers a corpus under a root directory. +/// +/// # Errors +/// +/// Returns [`CorpusError`] if the root is invalid, traversal encounters an I/O +/// error, or a discovered path cannot be represented by the legacy path policy. +pub fn discover(root: &Path, options: DiscoverOptions) -> Result { + if !root.is_dir() { + return Err(CorpusError::InvalidRoot(root.to_path_buf())); + } + let mut files = Vec::new(); + walk(root, root, options, &mut files)?; + files.sort_by(|a, b| a.path.cmp(&b.path)); + + let kind = classify(root, &files); + let casefold_collisions = detect_casefold_collisions(&files); + Ok(CorpusManifest { + kind, + files, + casefold_collisions, + }) +} + +fn walk( + root: &Path, + dir: &Path, + options: DiscoverOptions, + out: &mut Vec, +) -> Result<(), CorpusError> { + let read_dir = fs::read_dir(dir).map_err(|source| CorpusError::Io { + path: dir.to_path_buf(), + source, + })?; + let mut entries = Vec::new(); + for entry in read_dir { + let entry = entry.map_err(|source| CorpusError::Io { + path: dir.to_path_buf(), + source, + })?; + entries.push(entry.path()); + } + entries.sort(); + for path in entries { + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with('.')) + { + continue; + } + let metadata = fs::symlink_metadata(&path).map_err(|source| CorpusError::Io { + path: path.clone(), + source, + })?; + if metadata.file_type().is_symlink() && !options.follow_symlinks { + continue; + } + if metadata.is_dir() { + walk(root, &path, options, out)?; + continue; + } + if !metadata.is_file() { + continue; + } + let rel = path + .strip_prefix(root) + .map_err(|_| CorpusError::InvalidPath(path.display().to_string()))?; + let rel_text = rel + .to_str() + .ok_or_else(|| CorpusError::InvalidPath(path.display().to_string()))?; + let normalized = normalize_relative(rel_text.as_bytes(), PathPolicy::HostCompatible) + .map_err(|_| CorpusError::InvalidPath(rel_text.to_string()))?; + let bytes = fs::read(&path).map_err(|source| CorpusError::Io { + path: path.clone(), + source, + })?; + out.push(ManifestEntry { + path: normalized.as_str().to_string(), + size: metadata.len(), + hash: stable_hash(&bytes), + }); + } + Ok(()) +} + +fn classify(root: &Path, files: &[ManifestEntry]) -> CorpusKind { + let name = root + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or_default() + .to_ascii_uppercase(); + if name == "IS" { + CorpusKind::Part1 + } else if name == "IS2" { + CorpusKind::Part2 + } else if files + .iter() + .any(|f| f.path.eq_ignore_ascii_case("iron_3d.exe")) + { + CorpusKind::Part1 + } else { + CorpusKind::Unknown + } +} + +fn detect_casefold_collisions(files: &[ManifestEntry]) -> Vec> { + let mut grouped: BTreeMap, BTreeSet> = BTreeMap::new(); + for file in files { + grouped + .entry(ascii_lookup_key(file.path.as_bytes()).0) + .or_default() + .insert(file.path.clone()); + } + grouped + .into_values() + .filter(|paths| paths.len() > 1) + .map(|paths| paths.into_iter().collect()) + .collect() +} + +/// Builds aggregate report. +#[must_use] +pub fn report(root: &Path, manifest: &CorpusManifest) -> CorpusReport { + let mut metrics = BTreeMap::new(); + metrics.insert("nres_files".to_string(), 0); + metrics.insert("nres_entries".to_string(), 0); + metrics.insert("rsli_files".to_string(), 0); + metrics.insert("tma_files".to_string(), 0); + metrics.insert("land_msh_files".to_string(), 0); + metrics.insert("land_map_files".to_string(), 0); + metrics.insert("unit_dat_files".to_string(), 0); + metrics.insert("msh_entries".to_string(), 0); + metrics.insert("mat0_entries".to_string(), 0); + metrics.insert("texm_entries".to_string(), 0); + metrics.insert("fxid_entries".to_string(), 0); + metrics.insert("wear_entries".to_string(), 0); + + for entry in &manifest.files { + let lower = entry.path.to_ascii_lowercase(); + if lower.ends_with("data.tma") { + bump(&mut metrics, "tma_files", 1); + } + if lower.ends_with("land.msh") { + bump(&mut metrics, "land_msh_files", 1); + } + if lower.ends_with("land.map") { + bump(&mut metrics, "land_map_files", 1); + } + if has_extension(&lower, "dat") + && (lower.starts_with("units/") || lower.contains("/units/")) + { + bump(&mut metrics, "unit_dat_files", 1); + } + + let path = root.join(&entry.path); + if let Ok(bytes) = fs::read(path) { + if bytes.starts_with(b"NRes") { + bump(&mut metrics, "nres_files", 1); + if let Some(entries) = inspect_nres_entries(&bytes) { + bump(&mut metrics, "nres_entries", entries.len() as u64); + for entry in entries { + let name = entry.name.to_ascii_lowercase(); + if has_extension(&name, "msh") { + bump(&mut metrics, "msh_entries", 1); + } + match entry.kind { + 0x3054_414D => { + bump(&mut metrics, "mat0_entries", 1); + } + 0x6D78_6554 => { + bump(&mut metrics, "texm_entries", 1); + } + 0x4449_5846 => { + bump(&mut metrics, "fxid_entries", 1); + } + 0x5241_4557 => { + bump(&mut metrics, "wear_entries", 1); + } + _ => {} + } + } + } + } else if bytes.starts_with(b"NL") { + bump(&mut metrics, "rsli_files", 1); + } + } + } + + CorpusReport { + schema: 1, + kind: manifest.kind, + files: manifest.files.len(), + bytes: manifest.files.iter().map(|f| f.size).sum(), + metrics, + casefold_collisions: manifest.casefold_collisions.len(), + fingerprint: fingerprint(manifest), + } +} + +fn bump(metrics: &mut BTreeMap, key: &str, delta: u64) { + if let Some(value) = metrics.get_mut(key) { + *value = value.saturating_add(delta); + } +} + +fn has_extension(path: &str, expected: &str) -> bool { + Path::new(path) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case(expected)) +} + +#[derive(Clone, Debug)] +struct NresEntryBrief { + kind: u32, + name: String, +} + +fn inspect_nres_entries(bytes: &[u8]) -> Option> { + 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. +#[must_use] +pub fn fingerprint(manifest: &CorpusManifest) -> u64 { + let mut state = 0xcbf2_9ce4_8422_2325; + for file in &manifest.files { + hash_into(&mut state, file.path.as_bytes()); + hash_into(&mut state, &file.size.to_le_bytes()); + hash_into(&mut state, &file.hash.to_le_bytes()); + } + state +} + +fn stable_hash(bytes: &[u8]) -> u64 { + let mut state = 0xcbf2_9ce4_8422_2325; + hash_into(&mut state, bytes); + state +} + +fn hash_into(state: &mut u64, bytes: &[u8]) { + for byte in bytes { + *state ^= u64::from(*byte); + *state = state.wrapping_mul(0x0000_0100_0000_01b3); + } +} + +/// Writes report atomically. +/// +/// # Errors +/// +/// Returns [`CorpusError`] if the parent directory, temporary file, write, or +/// final rename operation fails. +pub fn write_report_atomic(path: &Path, report: &CorpusReport) -> Result<(), CorpusError> { + let tmp = path.with_extension("tmp"); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|source| CorpusError::Io { + path: parent.to_path_buf(), + source, + })?; + } + let mut file = fs::File::create(&tmp).map_err(|source| CorpusError::Io { + path: tmp.clone(), + source, + })?; + file.write_all(render_report_json(report).as_bytes()) + .map_err(|source| CorpusError::Io { + path: tmp.clone(), + source, + })?; + file.sync_all().map_err(|source| CorpusError::Io { + path: tmp.clone(), + source, + })?; + fs::rename(&tmp, path).map_err(|source| CorpusError::Io { + path: path.to_path_buf(), + source, + })?; + Ok(()) +} + +/// Renders report JSON. +#[must_use] +pub fn render_report_json(report: &CorpusReport) -> String { + let mut out = format!( + "{{\"schema_version\":\"fparkan-corpus-report-v1\",\"schema\":{},\"kind\":\"{:?}\",\"files\":{},\"bytes\":{},\"casefold_collisions\":{},\"fingerprint\":\"{:016x}\",\"metrics\":{{", + report.schema, + report.kind, + report.files, + report.bytes, + report.casefold_collisions, + report.fingerprint + ); + for (idx, (key, value)) in report.metrics.iter().enumerate() { + if idx > 0 { + out.push(','); + } + out.push('"'); + out.push_str(key); + out.push_str("\":"); + out.push_str(&value.to_string()); + } + out.push_str("}}"); + out.push('}'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_path::join_under; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn report_for_testdata_roots() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("testdata") + .join("IS"); + if !root.is_dir() { + return; + } + let manifest = discover(&root, DiscoverOptions::default()).expect("manifest"); + let report = report(&root, &manifest); + assert!(report.files > 0); + assert!(report.metrics["nres_files"] > 0); + } + + #[test] + fn licensed_part1_manifest_profile_and_counts_match_baseline() { + let root = testdata_root("IS"); + let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest"); + let report = report(&root, &manifest); + + assert_eq!(manifest.kind, CorpusKind::Part1); + assert_eq!(report.files, 1_017); + assert_eq!(report.metrics["nres_files"], 120); + assert_eq!(report.metrics["rsli_files"], 2); + assert_eq!(report.metrics["tma_files"], 29); + assert_eq!(report.metrics["land_msh_files"], 33); + assert_eq!(report.metrics["land_map_files"], 33); + assert_eq!(report.metrics["unit_dat_files"], 425); + } + + #[test] + fn licensed_part2_manifest_profile_and_counts_match_baseline() { + let root = testdata_root("IS2"); + let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest"); + let report = report(&root, &manifest); + + assert_eq!(manifest.kind, CorpusKind::Part2); + assert_eq!(report.files, 1_302); + assert_eq!(report.metrics["nres_files"], 134); + assert_eq!(report.metrics["rsli_files"], 2); + assert_eq!(report.metrics["tma_files"], 31); + assert_eq!(report.metrics["land_msh_files"], 32); + assert_eq!(report.metrics["land_map_files"], 32); + assert_eq!(report.metrics["unit_dat_files"], 676); + } + + #[test] + fn licensed_part1_has_no_casefold_relative_path_collisions() { + let root = testdata_root("IS"); + let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest"); + + assert!(manifest.casefold_collisions.is_empty()); + } + + #[test] + fn licensed_part2_has_no_casefold_relative_path_collisions() { + let root = testdata_root("IS2"); + let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest"); + + assert!(manifest.casefold_collisions.is_empty()); + } + + #[test] + fn licensed_part1_paths_stay_under_root() { + assert_discovered_paths_stay_under_root("IS"); + } + + #[test] + fn licensed_part2_paths_stay_under_root() { + assert_discovered_paths_stay_under_root("IS2"); + } + + #[test] + fn report_json_contains_metrics_and_hashes_not_paths_or_payloads() { + let manifest = CorpusManifest { + kind: CorpusKind::Part1, + files: vec![ManifestEntry { + path: "secret/payload.bin".to_string(), + size: 4, + hash: stable_hash(b"DATA"), + }], + casefold_collisions: Vec::new(), + }; + let report = report(Path::new("."), &manifest); + let json = render_report_json(&report); + + assert!(json.contains("\"schema_version\":\"fparkan-corpus-report-v1\"")); + assert!(json.contains("\"fingerprint\":")); + assert!(json.contains("\"metrics\":")); + assert!(!json.contains("secret/payload.bin")); + assert!(!json.contains("DATA")); + } + + #[test] + fn deterministic_traversal_is_creation_order_independent() { + let first = temp_dir("order-first"); + let second = temp_dir("order-second"); + fs::create_dir_all(first.join("nested")).expect("first nested"); + fs::create_dir_all(second.join("nested")).expect("second nested"); + + fs::write(first.join("b.bin"), b"b").expect("first b"); + fs::write(first.join("nested").join("a.bin"), b"a").expect("first a"); + fs::write(second.join("nested").join("a.bin"), b"a").expect("second a"); + fs::write(second.join("b.bin"), b"b").expect("second b"); + + let first_manifest = discover(&first, DiscoverOptions::default()).expect("first manifest"); + let second_manifest = + discover(&second, DiscoverOptions::default()).expect("second manifest"); + + assert_eq!(first_manifest.files, second_manifest.files); + let _ = fs::remove_dir_all(first); + let _ = fs::remove_dir_all(second); + } + + #[cfg(unix)] + #[test] + fn unreadable_directory_produces_error() { + use std::os::unix::fs::PermissionsExt; + + let root = temp_dir("unreadable"); + let child = root.join("locked"); + fs::create_dir_all(&child).expect("locked dir"); + fs::set_permissions(&child, fs::Permissions::from_mode(0o000)).expect("lock dir"); + + let result = discover(&root, DiscoverOptions::default()); + + fs::set_permissions(&child, fs::Permissions::from_mode(0o700)).expect("unlock dir"); + let _ = fs::remove_dir_all(root); + assert!(matches!(result, Err(CorpusError::Io { path, .. }) if path.ends_with("locked"))); + } + + #[cfg(unix)] + #[test] + fn symlink_loop_is_not_traversed_by_default() { + use std::os::unix::fs::symlink; + + let root = temp_dir("symlink-loop"); + fs::write(root.join("real.bin"), b"real").expect("real file"); + symlink(&root, root.join("loop")).expect("loop symlink"); + + let manifest = discover(&root, DiscoverOptions::default()).expect("manifest"); + + assert_eq!(manifest.files.len(), 1); + assert_eq!(manifest.files[0].path, "real.bin"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn casefold_collisions_are_registered() { + let manifest = CorpusManifest { + kind: CorpusKind::Unknown, + files: vec![ + ManifestEntry { + path: "Textures/Foo.TEX".to_string(), + size: 1, + hash: 1, + }, + ManifestEntry { + path: "textures/foo.tex".to_string(), + size: 1, + hash: 2, + }, + ], + casefold_collisions: Vec::new(), + }; + + let collisions = detect_casefold_collisions(&manifest.files); + + assert_eq!( + collisions, + vec![vec![ + "Textures/Foo.TEX".to_string(), + "textures/foo.tex".to_string() + ]] + ); + } + + #[test] + fn fingerprint_changes() { + let mut manifest = CorpusManifest { + kind: CorpusKind::Unknown, + files: vec![ManifestEntry { + path: "a".to_string(), + size: 1, + hash: 1, + }], + casefold_collisions: Vec::new(), + }; + let a = fingerprint(&manifest); + manifest.files[0].hash = 2; + assert_ne!(a, fingerprint(&manifest)); + } + + #[test] + fn atomic_report_write() { + let tmp = std::env::temp_dir().join(format!( + "fparkan-report-{}.json", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + let report = CorpusReport { + schema: 1, + kind: CorpusKind::Unknown, + files: 0, + bytes: 0, + metrics: BTreeMap::new(), + casefold_collisions: 0, + fingerprint: 0, + }; + write_report_atomic(&tmp, &report).expect("write"); + assert!(tmp.is_file()); + let _ = fs::remove_file(tmp); + } + + fn temp_dir(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "fparkan-corpus-{name}-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(&path).expect("temp dir"); + path + } + + fn testdata_root(part: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("testdata") + .join(part) + } + + fn assert_discovered_paths_stay_under_root(part: &str) { + let root = testdata_root(part); + let manifest = discover(&root, DiscoverOptions::default()).expect("licensed manifest"); + + for entry in &manifest.files { + let normalized = normalize_relative(entry.path.as_bytes(), PathPolicy::HostCompatible) + .expect("discovered path should re-normalize"); + let joined = join_under(&root, &normalized).expect("discovered path should join"); + assert!( + joined.starts_with(&root), + "discovered path escaped root: {}", + entry.path + ); + } + } +} diff --git a/crates/fparkan-diagnostics/Cargo.toml b/crates/fparkan-diagnostics/Cargo.toml new file mode 100644 index 0000000..8e7b1bd --- /dev/null +++ b/crates/fparkan-diagnostics/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "fparkan-diagnostics" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] + +[lints] +workspace = true diff --git a/crates/fparkan-diagnostics/src/lib.rs b/crates/fparkan-diagnostics/src/lib.rs new file mode 100644 index 0000000..8b3e160 --- /dev/null +++ b/crates/fparkan-diagnostics/src/lib.rs @@ -0,0 +1,301 @@ +#![forbid(unsafe_code)] +//! Structured diagnostics shared by `FParkan` crates. + +/// Diagnostic severity. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Severity { + /// Informational note. + Info, + /// Recoverable warning. + Warning, + /// Error for the current operation. + Error, + /// Fatal error for the current run. + Fatal, +} + +/// Evidence level for a contract or interpretation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EvidenceStatus { + /// Described by project documentation. + Documented, + /// Verified by synthetic fixtures. + SyntheticVerified, + /// Verified against the licensed corpus. + CorpusVerified, + /// Verified by runtime capture. + RuntimeCaptured, + /// Working hypothesis; not a runtime contract. + Hypothesis, +} + +/// Operation phase where a diagnostic was produced. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Phase { + /// Discovery. + Discover, + /// Read. + Read, + /// Parse. + Parse, + /// Validate. + Validate, + /// Resolve. + Resolve, + /// Prepare. + Prepare, + /// Construct. + Construct, + /// Register. + Register, + /// Simulate. + Simulate, + /// Render. + Render, +} + +/// Byte span in an input source. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SourceSpan { + /// Start offset. + pub offset: u64, + /// Length in bytes. + pub length: u64, +} + +/// Stable diagnostic code. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct DiagnosticCode(pub &'static str); + +/// Context attached to a diagnostic. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct DiagnosticContext { + /// Phase. + pub phase: Option, + /// Redacted or logical path. + pub path: Option, + /// Archive entry name. + pub archive_entry: Option, + /// Object/prototype key. + pub object_key: Option, + /// Input span. + pub span: Option, +} + +/// Structured diagnostic with cause chain. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Diagnostic { + /// Stable code. + pub code: DiagnosticCode, + /// Severity. + pub severity: Severity, + /// Human message. + pub message: String, + /// Context. + pub context: DiagnosticContext, + /// Causes. + pub causes: Vec, +} + +/// Creates a diagnostic with default error severity. +#[must_use] +pub fn diagnostic(code: DiagnosticCode, message: impl Into) -> Diagnostic { + Diagnostic { + code, + severity: Severity::Error, + message: message.into(), + context: DiagnosticContext::default(), + causes: Vec::new(), + } +} + +impl Diagnostic { + /// Returns a copy with severity changed. + #[must_use] + pub fn with_severity(mut self, severity: Severity) -> Self { + self.severity = severity; + self + } + + /// Returns a copy with context changed. + #[must_use] + pub fn with_context(mut self, context: DiagnosticContext) -> Self { + self.context = context; + self + } + + /// Adds a cause. + pub fn push_cause(&mut self, cause: Diagnostic) { + self.causes.push(cause); + } +} + +/// Renders a compact human-readable diagnostic. +#[must_use] +pub fn render_human(diagnostic: &Diagnostic) -> String { + let mut out = format!( + "{:?} {}: {}", + diagnostic.severity, diagnostic.code.0, diagnostic.message + ); + if let Some(path) = &diagnostic.context.path { + out.push_str(" ["); + out.push_str(path); + out.push(']'); + } + out +} + +/// Renders deterministic JSON without requiring a serialization dependency. +#[must_use] +pub fn render_json(diagnostic: &Diagnostic) -> String { + fn esc(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(ch), + } + } + out + } + + let mut out = String::new(); + out.push('{'); + out.push_str("\"code\":\""); + out.push_str(&esc(diagnostic.code.0)); + out.push_str("\",\"severity\":\""); + out.push_str(match diagnostic.severity { + Severity::Info => "info", + Severity::Warning => "warning", + Severity::Error => "error", + Severity::Fatal => "fatal", + }); + out.push_str("\",\"message\":\""); + out.push_str(&esc(&diagnostic.message)); + out.push_str("\",\"context\":{"); + if let Some(phase) = diagnostic.context.phase { + out.push_str("\"phase\":\""); + out.push_str(match phase { + Phase::Discover => "discover", + Phase::Read => "read", + Phase::Parse => "parse", + Phase::Validate => "validate", + Phase::Resolve => "resolve", + Phase::Prepare => "prepare", + Phase::Construct => "construct", + Phase::Register => "register", + Phase::Simulate => "simulate", + Phase::Render => "render", + }); + out.push('"'); + } + if let Some(path) = &diagnostic.context.path { + if diagnostic.context.phase.is_some() { + out.push(','); + } + out.push_str("\"path\":\""); + out.push_str(&esc(path)); + out.push('"'); + } + if let Some(entry) = &diagnostic.context.archive_entry { + if diagnostic.context.phase.is_some() || diagnostic.context.path.is_some() { + out.push(','); + } + out.push_str("\"archive_entry\":\""); + out.push_str(&esc(entry)); + out.push('"'); + } + if let Some(key) = &diagnostic.context.object_key { + if diagnostic.context.phase.is_some() + || diagnostic.context.path.is_some() + || diagnostic.context.archive_entry.is_some() + { + out.push(','); + } + out.push_str("\"object_key\":\""); + out.push_str(&esc(key)); + out.push('"'); + } + if let Some(span) = diagnostic.context.span { + if diagnostic.context.phase.is_some() + || diagnostic.context.path.is_some() + || diagnostic.context.archive_entry.is_some() + || diagnostic.context.object_key.is_some() + { + out.push(','); + } + out.push_str("\"span\":{\"offset\":"); + out.push_str(&span.offset.to_string()); + out.push_str(",\"length\":"); + out.push_str(&span.length.to_string()); + out.push('}'); + } + out.push_str("},\"causes\":["); + for (idx, cause) in diagnostic.causes.iter().enumerate() { + if idx > 0 { + out.push(','); + } + out.push_str(&render_json(cause)); + } + out.push_str("]}"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_is_stable() { + let d = diagnostic(DiagnosticCode("S0-DIAG-001"), "keeps context").with_context( + DiagnosticContext { + phase: Some(Phase::Parse), + ..DiagnosticContext::default() + }, + ); + assert_eq!( + render_json(&d), + "{\"code\":\"S0-DIAG-001\",\"severity\":\"error\",\"message\":\"keeps context\",\"context\":{\"phase\":\"parse\"},\"causes\":[]}" + ); + } + + #[test] + fn diagnostic_chain_preserves_context() { + let mut root = diagnostic(DiagnosticCode("ROOT"), "root").with_context(DiagnosticContext { + phase: Some(Phase::Resolve), + path: Some("archives/material.lib".to_string()), + archive_entry: Some("MATERIAL.MAT0".to_string()), + object_key: Some("unit/tank".to_string()), + span: Some(SourceSpan { + offset: 12, + length: 4, + }), + }); + root.push_cause(diagnostic(DiagnosticCode("CAUSE"), "cause").with_context( + DiagnosticContext { + phase: Some(Phase::Parse), + path: Some("archives/material.lib".to_string()), + span: Some(SourceSpan { + offset: 16, + length: 8, + }), + ..DiagnosticContext::default() + }, + )); + + let json = render_json(&root); + + assert!(json.contains("\"code\":\"ROOT\"")); + assert!(json.contains("\"phase\":\"resolve\"")); + assert!(json.contains("\"path\":\"archives/material.lib\"")); + assert!(json.contains("\"archive_entry\":\"MATERIAL.MAT0\"")); + assert!(json.contains("\"object_key\":\"unit/tank\"")); + assert!(json.contains("\"span\":{\"offset\":12,\"length\":4}")); + assert!(json.contains("\"code\":\"CAUSE\"")); + assert!(json.contains("\"span\":{\"offset\":16,\"length\":8}")); + } +} diff --git a/crates/fparkan-fx/Cargo.toml b/crates/fparkan-fx/Cargo.toml new file mode 100644 index 0000000..9bdae4d --- /dev/null +++ b/crates/fparkan-fx/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "fparkan-fx" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-binary = { path = "../fparkan-binary" } + +[dev-dependencies] +fparkan-nres = { path = "../fparkan-nres" } + +[lints] +workspace = true diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs new file mode 100644 index 0000000..9675507 --- /dev/null +++ b/crates/fparkan-fx/src/lib.rs @@ -0,0 +1,1025 @@ +#![forbid(unsafe_code)] +//! FXID effect contracts. + +use fparkan_binary::{Cursor, DecodeError}; +use std::sync::Arc; + +/// `FXID` `NRes` entry type. +pub const FXID_KIND: u32 = 0x4449_5846; +const HEADER_SIZE: usize = 60; + +/// FX document. +#[derive(Clone, Debug)] +pub struct FxDocument { + bytes: Arc<[u8]>, + header: FxHeader, + commands: Vec, +} + +/// FX header. +#[derive(Clone, Debug, PartialEq)] +pub struct FxHeader { + /// Number of commands in the stream. + pub command_count: u32, + /// Time mode. + pub time_mode: u32, + /// Duration in seconds. + pub duration_seconds: f32, + /// Phase jitter. + pub phase_jitter: f32, + /// Opaque flags. + pub flags: u32, + /// Opaque settings id. + pub settings_id: u32, + /// Random spatial shift. + pub random_shift: [f32; 3], + /// Local pivot. + pub pivot: [f32; 3], + /// Base scale. + pub scale: [f32; 3], +} + +/// FX opcode. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum FxOpcode { + /// Opcode 1. + Op1, + /// Opcode 2. + Op2, + /// Opcode 3. + Op3, + /// Opcode 4. + Op4, + /// Opcode 5. + Op5, + /// Opcode 6. + Op6, + /// Opcode 7. + Op7, + /// Opcode 8. + Op8, + /// Opcode 9. + Op9, + /// Opcode 10. + Op10, +} + +/// FX resource reference. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FxResourceRef { + /// Fixed archive field bytes. + pub archive_raw: [u8; 32], + /// Fixed name field bytes. + pub name_raw: [u8; 32], +} + +/// FX command. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FxCommand { + /// Raw command word. + pub word: u32, + /// Decoded opcode. + pub opcode: FxOpcode, + /// Enabled bit. + pub enabled: bool, + /// Command body after the word. + pub raw_body: Arc<[u8]>, + /// Resource references discovered in known command layouts. + pub resource_refs: Vec, +} + +/// FX instance id. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FxInstanceId(pub u64); + +/// FX seed. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FxSeed(pub u64); + +/// External transform snapshot. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Transform { + /// Translation. + pub translation: [f32; 3], + /// Rotation quaternion. + pub rotation: [f32; 4], + /// Scale. + pub scale: [f32; 3], +} + +impl Default for Transform { + fn default() -> Self { + Self { + translation: [0.0; 3], + rotation: [0.0, 0.0, 0.0, 1.0], + scale: [1.0; 3], + } + } +} + +/// Game time in ticks. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct GameTime(pub u64); + +/// FX runtime state. +#[derive(Clone, Debug)] +pub struct FxState { + /// Instance id. + pub id: FxInstanceId, + /// Source document. + pub document: Arc, + /// Seed. + pub seed: FxSeed, + /// Transform at creation time. + pub transform: Transform, + /// Last updated time. + pub time: GameTime, + /// RNG call count reserved for deterministic captures. + pub rng_calls: u64, + /// Lifecycle phase. + pub lifecycle: FxLifecycle, +} + +/// FX lifecycle phase. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum FxLifecycle { + /// Running and eligible to emit. + #[default] + Running, + /// Stopped and not eligible to emit. + Stopped, + /// Ended permanently for the current instance. + Ended, +} + +/// Visual FX emission produced from a command. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FxPrimitive { + /// Command index. + pub command_index: u32, + /// Opcode. + pub opcode: FxOpcode, +} + +/// Sound FX emission produced from a command. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FxSoundEvent { + /// Command index. + pub command_index: u32, +} + +/// FX emission. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum FxEmission { + /// Visual primitive. + Primitive(FxPrimitive), + /// Sound event. + Sound(FxSoundEvent), +} + +/// FX decode/runtime error. +#[derive(Debug)] +pub enum FxError { + /// Binary decode error. + Decode(DecodeError), + /// Unknown opcode. + UnknownOpcode { + /// Command index. + index: u32, + /// Raw opcode byte. + opcode: u8, + }, + /// Command stream exceeds payload. + CommandOutOfBounds { + /// Command index. + index: u32, + /// Expected command end. + expected_end: u64, + /// Payload size. + payload_size: u64, + }, + /// Resource reference cannot be framed from body. + InvalidResourceRef { + /// Command index. + index: u32, + /// Opcode. + opcode: FxOpcode, + }, + /// A referenced dependency is missing. + MissingDependency { + /// Effect name or stable effect id. + effect: String, + /// Command index. + command_index: u32, + /// Archive name. + archive: String, + /// Resource name. + name: String, + }, +} + +impl From for FxError { + fn from(value: DecodeError) -> Self { + Self::Decode(value) + } +} + +impl std::fmt::Display for FxError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Decode(source) => write!(f, "{source}"), + Self::UnknownOpcode { index, opcode } => { + write!(f, "unknown FX opcode {opcode} at command {index}") + } + Self::CommandOutOfBounds { + index, + expected_end, + payload_size, + } => write!( + f, + "FX command {index} out of bounds: expected_end={expected_end}, payload_size={payload_size}" + ), + Self::InvalidResourceRef { index, opcode } => { + write!(f, "invalid FX resource reference in command {index} ({opcode:?})") + } + Self::MissingDependency { + effect, + command_index, + archive, + name, + } => write!( + f, + "missing FX dependency: effect={effect}, command={command_index}, archive={archive}, name={name}" + ), + } + } +} + +impl std::error::Error for FxError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Decode(source) => Some(source), + Self::UnknownOpcode { .. } + | Self::CommandOutOfBounds { .. } + | Self::InvalidResourceRef { .. } + | Self::MissingDependency { .. } => None, + } + } +} + +/// Decodes an `FXID` payload. +/// +/// # Errors +/// +/// Returns [`FxError`] when the 60-byte header, fixed-size command stream, or +/// exact EOF framing is invalid. +pub fn decode_fxid(bytes: Arc<[u8]>) -> Result { + let mut cursor = Cursor::new(&bytes); + let header = read_header(&mut cursor)?; + debug_assert_eq!(cursor.offset(), HEADER_SIZE as u64); + let mut commands = Vec::with_capacity( + usize::try_from(header.command_count) + .map_err(|_| FxError::Decode(DecodeError::IntegerOverflow))?, + ); + for index in 0..header.command_count { + let start = cursor.offset(); + let word = cursor.read_u32_le()?; + let opcode_byte = (word & 0xFF) as u8; + let opcode = opcode_from_byte(opcode_byte).ok_or(FxError::UnknownOpcode { + index, + opcode: opcode_byte, + })?; + let command_size = command_size(opcode); + let expected_end = start + .checked_add(u64::try_from(command_size).map_err(|_| DecodeError::IntegerOverflow)?) + .ok_or(DecodeError::IntegerOverflow)?; + if expected_end > bytes.len() as u64 { + return Err(FxError::CommandOutOfBounds { + index, + expected_end, + payload_size: bytes.len() as u64, + }); + } + let body_size = command_size + .checked_sub(4) + .ok_or(DecodeError::IntegerOverflow)?; + let body = cursor.read_exact(body_size)?; + let raw_body = Arc::from(body.to_vec().into_boxed_slice()); + let resource_refs = resource_refs(index, opcode, body)?; + commands.push(FxCommand { + word, + opcode, + enabled: ((word >> 8) & 1) != 0, + raw_body, + resource_refs, + }); + } + cursor.require_eof()?; + Ok(FxDocument { + bytes, + header, + commands, + }) +} + +/// Creates an FX instance. +/// +/// # Errors +/// +/// Currently returns [`FxError`] only for future resource/lifecycle validation +/// hooks; creation is deterministic for a decoded document. +pub fn create_instance( + document: Arc, + seed: FxSeed, + transform: Transform, +) -> Result { + Ok(FxState { + id: FxInstanceId(seed.0), + document, + seed, + transform, + time: GameTime::default(), + rng_calls: 0, + lifecycle: FxLifecycle::Running, + }) +} + +/// Updates FX simulation time without emitting side effects. +/// +/// # Errors +/// +/// Reserved for future runtime validation. +pub fn update(state: &mut FxState, time: GameTime) -> Result<(), FxError> { + state.time = time; + Ok(()) +} + +/// Emits active commands without advancing state. +/// +/// # Errors +/// +/// Reserved for future resource/runtime validation. +pub fn emit(state: &FxState, out: &mut Vec) -> Result<(), FxError> { + if state.lifecycle != FxLifecycle::Running { + return Ok(()); + } + for (index, command) in state.document.commands.iter().enumerate() { + if !command.enabled { + continue; + } + let command_index = u32::try_from(index).map_err(|_| DecodeError::IntegerOverflow)?; + if command.opcode == FxOpcode::Op2 { + out.push(FxEmission::Sound(FxSoundEvent { command_index })); + } else { + out.push(FxEmission::Primitive(FxPrimitive { + command_index, + opcode: command.opcode, + })); + } + } + Ok(()) +} + +/// Stops an FX instance. +pub fn stop(state: &mut FxState) { + state.lifecycle = FxLifecycle::Stopped; +} + +/// Restarts a stopped FX instance from a time. +pub fn restart(state: &mut FxState, time: GameTime) { + state.lifecycle = FxLifecycle::Running; + state.time = time; +} + +/// Ends an FX instance permanently. +pub fn end(state: &mut FxState) { + state.lifecycle = FxLifecycle::Ended; +} + +/// Validates resource references through a caller-provided dependency probe. +/// +/// # Errors +/// +/// Returns [`FxError::MissingDependency`] with effect, command, archive and +/// resource name context when the probe reports a missing resource. +pub fn validate_dependencies( + document: &FxDocument, + effect: &str, + exists: impl Fn(&[u8], &[u8]) -> bool, +) -> Result<(), FxError> { + for (index, command) in document.commands.iter().enumerate() { + for reference in &command.resource_refs { + if !exists(reference.archive_name(), reference.resource_name()) { + return Err(FxError::MissingDependency { + effect: effect.to_string(), + command_index: u32::try_from(index) + .map_err(|_| DecodeError::IntegerOverflow)?, + archive: String::from_utf8_lossy(reference.archive_name()).into_owned(), + name: String::from_utf8_lossy(reference.resource_name()).into_owned(), + }); + } + } + } + Ok(()) +} + +/// Builds a byte-stable capture for emitted commands. +/// +/// # Errors +/// +/// Returns [`FxError`] when emission fails. +pub fn canonical_emission_capture(state: &FxState) -> Result, FxError> { + let mut emissions = Vec::new(); + emit(state, &mut emissions)?; + let mut out = Vec::new(); + for emission in emissions { + match emission { + FxEmission::Primitive(primitive) => { + out.extend_from_slice( + format!("P,{}, {:?}\n", primitive.command_index, primitive.opcode).as_bytes(), + ); + } + FxEmission::Sound(sound) => { + out.extend_from_slice(format!("S,{}\n", sound.command_index).as_bytes()); + } + } + } + Ok(out) +} + +impl FxDocument { + /// Returns original bytes. + #[must_use] + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + + /// Returns the parsed header. + #[must_use] + pub fn header(&self) -> &FxHeader { + &self.header + } + + /// Returns commands in disk order. + #[must_use] + pub fn commands(&self) -> &[FxCommand] { + &self.commands + } +} + +impl FxResourceRef { + /// Archive name before first NUL, ASCII-trimmed. + #[must_use] + pub fn archive_name(&self) -> &[u8] { + bounded_cstr(&self.archive_raw) + } + + /// Resource name before first NUL, ASCII-trimmed. + #[must_use] + pub fn resource_name(&self) -> &[u8] { + bounded_cstr(&self.name_raw) + } +} + +fn read_header(cursor: &mut Cursor<'_>) -> Result { + Ok(FxHeader { + command_count: cursor.read_u32_le()?, + time_mode: cursor.read_u32_le()?, + duration_seconds: cursor.read_f32_le()?, + phase_jitter: cursor.read_f32_le()?, + flags: cursor.read_u32_le()?, + settings_id: cursor.read_u32_le()?, + random_shift: [ + cursor.read_f32_le()?, + cursor.read_f32_le()?, + cursor.read_f32_le()?, + ], + pivot: [ + cursor.read_f32_le()?, + cursor.read_f32_le()?, + cursor.read_f32_le()?, + ], + scale: [ + cursor.read_f32_le()?, + cursor.read_f32_le()?, + cursor.read_f32_le()?, + ], + }) +} + +fn opcode_from_byte(opcode: u8) -> Option { + match opcode { + 1 => Some(FxOpcode::Op1), + 2 => Some(FxOpcode::Op2), + 3 => Some(FxOpcode::Op3), + 4 => Some(FxOpcode::Op4), + 5 => Some(FxOpcode::Op5), + 6 => Some(FxOpcode::Op6), + 7 => Some(FxOpcode::Op7), + 8 => Some(FxOpcode::Op8), + 9 => Some(FxOpcode::Op9), + 10 => Some(FxOpcode::Op10), + _ => None, + } +} + +fn command_size(opcode: FxOpcode) -> usize { + match opcode { + FxOpcode::Op1 => 224, + FxOpcode::Op2 => 148, + FxOpcode::Op3 => 200, + FxOpcode::Op4 => 204, + FxOpcode::Op5 => 112, + FxOpcode::Op6 => 4, + FxOpcode::Op7 | FxOpcode::Op9 | FxOpcode::Op10 => 208, + FxOpcode::Op8 => 248, + } +} + +fn resource_refs(index: u32, opcode: FxOpcode, body: &[u8]) -> Result, FxError> { + if !has_resource_ref(opcode) { + return Ok(Vec::new()); + } + let raw = body + .get(..64) + .ok_or(FxError::InvalidResourceRef { index, opcode })?; + let mut archive_raw = [0; 32]; + let mut name_raw = [0; 32]; + archive_raw.copy_from_slice(&raw[..32]); + name_raw.copy_from_slice(&raw[32..64]); + Ok(vec![FxResourceRef { + archive_raw, + name_raw, + }]) +} + +fn has_resource_ref(opcode: FxOpcode) -> bool { + matches!( + opcode, + FxOpcode::Op2 + | FxOpcode::Op3 + | FxOpcode::Op4 + | FxOpcode::Op5 + | FxOpcode::Op7 + | FxOpcode::Op8 + | FxOpcode::Op9 + | FxOpcode::Op10 + ) +} + +fn bounded_cstr(raw: &[u8]) -> &[u8] { + let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len()); + trim_ascii(&raw[..len]) +} + +fn trim_ascii(bytes: &[u8]) -> &[u8] { + let mut start = 0usize; + let mut end = bytes.len(); + while start < end && bytes[start].is_ascii_whitespace() { + start += 1; + } + while end > start && bytes[end - 1].is_ascii_whitespace() { + end -= 1; + } + &bytes[start..end] +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_nres::ReadProfile; + use std::collections::BTreeMap; + use std::path::{Path, PathBuf}; + + #[test] + fn decodes_synthetic_opcodes_and_refs() { + let mut bytes = header(2); + bytes.extend_from_slice(&command_with_ref(0x0102, 148, b"sounds.lib", b"boom.wav")); + bytes.extend_from_slice(&command(0x0106, 4)); + let document = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"); + + assert_eq!(document.header().command_count, 2); + assert_eq!(document.commands()[0].opcode, FxOpcode::Op2); + assert!(document.commands()[0].enabled); + assert_eq!( + document.commands()[0].resource_refs[0].archive_name(), + b"sounds.lib" + ); + assert_eq!(document.commands()[1].opcode, FxOpcode::Op6); + assert!(document.commands()[1].raw_body.is_empty()); + } + + #[test] + fn header_is_exactly_sixty_bytes_and_command_sizes_are_fixed() { + let mut bytes = header(10); + for opcode in 1..=10_u32 { + bytes.extend_from_slice(&command(0x0100 | opcode, opcode_size(opcode))); + } + let document = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"); + + assert_eq!(header(0).len(), HEADER_SIZE); + assert_eq!(document.commands().len(), 10); + for (index, command) in document.commands().iter().enumerate() { + let opcode = u32::try_from(index + 1).expect("opcode"); + assert_eq!(command.raw_body.len() + 4, opcode_size(opcode)); + } + } + + #[test] + fn opcode6_four_byte_command_is_accepted() { + let mut bytes = header(1); + bytes.extend_from_slice(&command(0x0106, 4)); + let document = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"); + + assert_eq!(document.commands()[0].opcode, FxOpcode::Op6); + assert!(document.commands()[0].raw_body.is_empty()); + } + + #[test] + fn rejects_unknown_opcode_at_command_index() { + let mut bytes = header(1); + bytes.extend_from_slice(&99_u32.to_le_bytes()); + let err = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect_err("unknown opcode"); + + assert!(matches!( + err, + FxError::UnknownOpcode { + index: 0, + opcode: 99 + } + )); + } + + #[test] + fn rejects_command_count_that_exceeds_payload() { + let mut bytes = header(2); + bytes.extend_from_slice(&command(0x0106, 4)); + let err = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect_err("out of bounds"); + + assert!(matches!( + err, + FxError::Decode(DecodeError::UnexpectedEof { .. }) | FxError::CommandOutOfBounds { .. } + )); + } + + #[test] + fn rejects_trailing_bytes_after_command_stream() { + let mut bytes = header(0); + bytes.push(0); + let err = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect_err("trailing"); + + assert!(matches!( + err, + FxError::Decode(DecodeError::TrailingBytes { .. }) + )); + } + + #[test] + fn fixed_resource_refs_preserve_tails() { + let mut bytes = header(1); + let mut command = command_with_ref(0x0102, 148, b"sounds.lib", b"boom.wav"); + command[4 + 20] = 0xAB; + command[36 + 20] = 0xCD; + bytes.extend_from_slice(&command); + let document = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"); + + let reference = &document.commands()[0].resource_refs[0]; + assert_eq!(reference.archive_name(), b"sounds.lib"); + assert_eq!(reference.resource_name(), b"boom.wav"); + assert_eq!(reference.archive_raw[20], 0xAB); + assert_eq!(reference.name_raw[20], 0xCD); + } + + #[test] + fn missing_dependency_error_contains_effect_command_archive_and_name() { + let mut bytes = header(1); + bytes.extend_from_slice(&command_with_ref( + 0x0102, + 148, + b"sounds.lib", + b"missing.wav", + )); + let document = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"); + + let err = validate_dependencies(&document, "spark", |_, _| false) + .expect_err("missing dependency"); + + assert!(matches!( + err, + FxError::MissingDependency { + ref effect, + command_index: 0, + ref archive, + ref name, + } if effect == "spark" && archive == "sounds.lib" && name == "missing.wav" + )); + assert!(err.to_string().contains("spark")); + assert!(err.to_string().contains("missing.wav")); + } + + #[test] + fn update_and_emit_are_separate() { + let mut bytes = header(1); + bytes.extend_from_slice(&command(0x0101, 224)); + let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx")); + let mut state = create_instance(document, FxSeed(7), Transform::default()).expect("state"); + update(&mut state, GameTime(42)).expect("update"); + let before = state.time; + let mut out = Vec::new(); + + emit(&state, &mut out).expect("emit"); + + assert_eq!(state.time, before); + assert_eq!(out.len(), 1); + } + + #[test] + fn create_records_seed_transform_and_start_time() { + let bytes = header(0); + let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx")); + let transform = Transform { + translation: [1.0, 2.0, 3.0], + rotation: [0.0, 0.0, 0.0, 1.0], + scale: [4.0, 5.0, 6.0], + }; + + let state = create_instance(document, FxSeed(77), transform).expect("state"); + + assert_eq!(state.id, FxInstanceId(77)); + assert_eq!(state.seed, FxSeed(77)); + assert_eq!(state.transform, transform); + assert_eq!(state.time, GameTime(0)); + assert_eq!(state.rng_calls, 0); + assert_eq!(state.lifecycle, FxLifecycle::Running); + } + + #[test] + fn stable_command_order_and_emission_capture_are_seed_stable() { + let mut bytes = header(3); + bytes.extend_from_slice(&command(0x0101, 224)); + bytes.extend_from_slice(&command(0x0102, 148)); + bytes.extend_from_slice(&command(0x0106, 4)); + let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx")); + let mut first = + create_instance(document.clone(), FxSeed(5), Transform::default()).expect("first"); + let mut second = + create_instance(document, FxSeed(5), Transform::default()).expect("second"); + + update(&mut first, GameTime(9)).expect("update"); + update(&mut second, GameTime(9)).expect("update"); + + assert_eq!( + canonical_emission_capture(&first).expect("first capture"), + canonical_emission_capture(&second).expect("second capture") + ); + assert_eq!( + canonical_emission_capture(&first).expect("capture"), + b"P,0, Op1\nS,1\nP,2, Op6\n" + ); + } + + #[test] + fn stop_restart_end_lifecycle_controls_emission() { + let mut bytes = header(1); + bytes.extend_from_slice(&command(0x0101, 224)); + let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx")); + let mut state = create_instance(document, FxSeed(1), Transform::default()).expect("state"); + + assert!(!canonical_emission_capture(&state) + .expect("running") + .is_empty()); + stop(&mut state); + assert_eq!(state.lifecycle, FxLifecycle::Stopped); + assert!(canonical_emission_capture(&state) + .expect("stopped") + .is_empty()); + restart(&mut state, GameTime(12)); + assert_eq!(state.lifecycle, FxLifecycle::Running); + assert_eq!(state.time, GameTime(12)); + assert!(!canonical_emission_capture(&state) + .expect("restarted") + .is_empty()); + end(&mut state); + assert_eq!(state.lifecycle, FxLifecycle::Ended); + assert!(canonical_emission_capture(&state) + .expect("ended") + .is_empty()); + } + + #[test] + fn unrelated_rng_stream_use_does_not_perturb_fx_capture() { + let mut bytes = header(1); + bytes.extend_from_slice(&command(0x0101, 224)); + let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx")); + let state = create_instance(document, FxSeed(3), Transform::default()).expect("state"); + let before = canonical_emission_capture(&state).expect("before"); + + let mut unrelated = 0x1234_u64; + for _ in 0..32 { + unrelated = unrelated.rotate_left(7).wrapping_mul(17); + } + + assert_ne!(unrelated, 0); + assert_eq!(canonical_emission_capture(&state).expect("after"), before); + } + + #[test] + fn arbitrary_command_streams_are_bounded_and_panic_free() { + for len in 0..256usize { + let mut bytes = vec![0xA5; len]; + if len >= HEADER_SIZE { + bytes[0..4].copy_from_slice(&1_u32.to_le_bytes()); + } + let result = std::panic::catch_unwind(|| { + let _ = decode_fxid(Arc::from(bytes.into_boxed_slice())); + }); + assert!(result.is_ok()); + } + } + + #[test] + fn licensed_corpus_fxid_exact_eof_and_distribution() { + for (corpus, expected_count) in [("IS", 923_usize), ("IS2", 1065_usize)] { + let Some(root) = corpus_root(corpus) else { + continue; + }; + let mut count = 0usize; + let mut opcodes = BTreeMap::::new(); + let mut time_modes = BTreeMap::::new(); + for path in files_under(&root) { + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let Ok(archive) = fparkan_nres::decode( + Arc::from(bytes.into_boxed_slice()), + ReadProfile::Compatible, + ) else { + continue; + }; + for entry in archive + .entries() + .iter() + .filter(|entry| entry.meta().type_id == FXID_KIND) + { + let payload = archive.payload(entry.id()).expect("payload"); + let document = decode_fxid(Arc::from(payload.to_vec().into_boxed_slice())) + .unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) + }); + count += 1; + *time_modes.entry(document.header().time_mode).or_insert(0) += 1; + for command in document.commands() { + *opcodes.entry(command.opcode).or_insert(0) += 1; + } + } + } + + assert_eq!(count, expected_count, "{corpus} FXID count"); + assert!(!opcodes.contains_key(&FxOpcode::Op6), "{corpus} opcode 6"); + for mode in time_modes.keys() { + assert!( + matches!(*mode, 0 | 1 | 2 | 4 | 5 | 14 | 15 | 16 | 17), + "{corpus} unexpected time mode {mode}" + ); + } + } + } + + #[test] + fn licensed_corpus_fxid_emission_captures_are_approved() { + for (corpus, expected_count, expected_emitting, expected_hash) in [ + ("IS", 923_usize, 467_usize, 10_553_431_922_547_057_702_u64), + ("IS2", 1065_usize, 532_usize, 9_217_284_592_334_143_531_u64), + ] { + let Some(root) = corpus_root(corpus) else { + continue; + }; + let mut count = 0usize; + let mut emitting = 0usize; + let mut hash = FNV_OFFSET; + for path in files_under(&root) { + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let Ok(archive) = fparkan_nres::decode( + Arc::from(bytes.into_boxed_slice()), + ReadProfile::Compatible, + ) else { + continue; + }; + for entry in archive + .entries() + .iter() + .filter(|entry| entry.meta().type_id == FXID_KIND) + { + let payload = archive.payload(entry.id()).expect("payload"); + let document = Arc::new( + decode_fxid(Arc::from(payload.to_vec().into_boxed_slice())).unwrap_or_else( + |err| panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()), + ), + ); + let state = + create_instance(document, FxSeed(count as u64), Transform::default()) + .expect("fx state"); + let capture = canonical_emission_capture(&state).expect("capture"); + if !capture.is_empty() { + emitting += 1; + } + hash_bytes(&mut hash, entry.name_bytes()); + hash_bytes(&mut hash, &capture); + count += 1; + } + } + + assert_eq!(count, expected_count, "{corpus} FXID count"); + assert_eq!(emitting, expected_emitting, "{corpus} emitting FXID count"); + assert_eq!(hash, expected_hash, "{corpus} FXID capture hash"); + } + } + + fn header(command_count: u32) -> Vec { + let mut out = Vec::with_capacity(HEADER_SIZE); + out.extend_from_slice(&command_count.to_le_bytes()); + out.extend_from_slice(&1_u32.to_le_bytes()); + out.extend_from_slice(&1.0_f32.to_bits().to_le_bytes()); + out.extend_from_slice(&0.0_f32.to_bits().to_le_bytes()); + out.extend_from_slice(&0_u32.to_le_bytes()); + out.extend_from_slice(&0_u32.to_le_bytes()); + for _ in 0..9 { + out.extend_from_slice(&0.0_f32.to_bits().to_le_bytes()); + } + assert_eq!(out.len(), HEADER_SIZE); + out + } + + fn command(word: u32, size: usize) -> Vec { + let mut out = Vec::with_capacity(size); + out.extend_from_slice(&word.to_le_bytes()); + out.resize(size, 0); + out + } + + fn command_with_ref(word: u32, size: usize, archive: &[u8], name: &[u8]) -> Vec { + let mut out = command(word, size); + copy_cstr(&mut out[4..36], archive); + copy_cstr(&mut out[36..68], name); + out + } + + fn opcode_size(opcode: u32) -> usize { + match opcode { + 1 => 224, + 2 => 148, + 3 => 200, + 4 => 204, + 5 => 112, + 6 => 4, + 7 | 9 | 10 => 208, + 8 => 248, + _ => unreachable!("test opcode"), + } + } + + fn copy_cstr(dst: &mut [u8], src: &[u8]) { + let len = dst.len().saturating_sub(1).min(src.len()); + dst[..len].copy_from_slice(&src[..len]); + } + + fn corpus_root(name: &str) -> Option { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("testdata") + .join(name); + root.is_dir().then_some(root) + } + + fn files_under(root: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(path) = stack.pop() { + let Ok(read_dir) = std::fs::read_dir(path) else { + continue; + }; + for entry in read_dir.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + out.push(path); + } + } + } + out.sort(); + out + } + + const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; + const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + + fn hash_bytes(hash: &mut u64, bytes: &[u8]) { + for byte in bytes { + *hash ^= u64::from(*byte); + *hash = hash.wrapping_mul(FNV_PRIME); + } + } +} diff --git a/crates/fparkan-material/Cargo.toml b/crates/fparkan-material/Cargo.toml new file mode 100644 index 0000000..6f5c755 --- /dev/null +++ b/crates/fparkan-material/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "fparkan-material" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +encoding_rs = "0.8" +fparkan-path = { path = "../fparkan-path" } +fparkan-resource = { path = "../fparkan-resource" } + +[dev-dependencies] +fparkan-nres = { path = "../fparkan-nres" } +fparkan-vfs = { path = "../fparkan-vfs" } + +[lints] +workspace = true diff --git a/crates/fparkan-material/src/lib.rs b/crates/fparkan-material/src/lib.rs new file mode 100644 index 0000000..780a1ae --- /dev/null +++ b/crates/fparkan-material/src/lib.rs @@ -0,0 +1,1272 @@ +#![forbid(unsafe_code)] +//! WEAR/MAT0 material contracts. + +use encoding_rs::WINDOWS_1251; +use fparkan_path::ResourceName; +use fparkan_resource::{archive_path, ResourceError, ResourceRepository}; + +/// `MAT0` `NRes` entry type. +pub const MAT0_KIND: u32 = 0x3054_414D; +/// `WEAR` `NRes` entry type. +pub const WEAR_KIND: u32 = 0x5241_4557; + +/// WEAR table. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct WearTable { + /// Entries. + pub entries: Vec, + /// Lightmap entries. + pub lightmaps: Vec, +} + +/// WEAR entry. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WearEntry { + /// Legacy id text. + pub legacy_id: LegacyText, + /// Material. + pub material: ResourceName, +} + +/// Legacy text token. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LegacyText(pub String); + +/// Lightmap entry. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LightmapEntry { + /// Legacy id text. + pub legacy_id: LegacyText, + /// Lightmap resource. + pub lightmap: ResourceName, +} + +/// MAT0 document. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Mat0Document { + /// Version/profile supplied by archive metadata. + pub version: u32, + /// Declared animation block count. + pub animation_block_count: u16, + /// Phase records. + pub phases: Vec, + /// Version-gated bytes between header and phase table. + pub prefix: Vec, + /// Opaque bytes at offsets 2..4. + pub header_opaque: [u8; 2], + /// Animation blocks parsed after phases. + pub animation_blocks: Vec, +} + +/// Material phase. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaterialPhase { + /// Parameters. + pub parameters: [u8; 18], + /// Texture raw. + pub texture_raw: [u8; 16], +} + +/// Material animation block. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaterialAnimationBlock { + /// Raw block header. + pub header_raw: u32, + /// Parsed keys. + pub keys: Vec, + /// Raw block bytes. + pub bytes: Vec, +} + +/// Material key. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct MaterialKey { + /// Key part. + pub k0: u16, + /// Key part. + pub k1: u16, + /// Key part. + pub k2: u16, +} + +/// Material fallback. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MaterialFallback { + /// Exact. + Exact, + /// Default. + Default, + /// First entry. + FirstEntry, +} + +/// Material timeline mode. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MaterialTimelineMode { + /// Play once from phase zero. + OneShot, + /// Clamp frame to the last phase. + Clamp, + /// Loop over all phases. + Loop, + /// Ping-pong over all phases. + PingPong, +} + +/// Material runtime sampling profile. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MaterialTimelineProfile { + /// Timeline mode. + pub mode: MaterialTimelineMode, + /// Apply deterministic material-only random offset. + pub random_offset: bool, +} + +/// Sampled material phase. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaterialPhaseSample { + /// Selected phase index. + pub phase_index: usize, + /// Effective frame after mode and random offset. + pub effective_frame: u32, + /// Sampled parameter bytes. + pub parameters: [u8; 18], + /// Sampled texture bytes. + pub texture_raw: [u8; 16], +} + +/// Resolved material. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResolvedMaterial { + /// Resolved material name. + pub name: ResourceName, + /// Fallback path. + pub fallback: MaterialFallback, + /// Decoded document. + pub document: Mat0Document, +} + +/// Material parse or resolution error. +#[derive(Debug)] +pub enum MaterialError { + /// Text payload is empty. + EmptyWear, + /// Count line is invalid. + InvalidWearCount(String), + /// Count is zero. + ZeroWearCount, + /// A material row is missing. + MissingWearRow { + /// Row index. + index: usize, + /// Expected row count. + count: usize, + }, + /// A material row is malformed. + InvalidWearRow { + /// Row index. + index: usize, + /// Original line. + line: String, + }, + /// Required blank line before `LIGHTMAPS` is missing. + MissingLightmapSeparator, + /// `LIGHTMAPS` marker is missing. + MissingLightmapMarker, + /// Lightmap count line is invalid. + InvalidLightmapCount(String), + /// Lightmap row is missing. + MissingLightmapRow { + /// Row index. + index: usize, + /// Expected row count. + count: usize, + }, + /// Lightmap row is malformed. + InvalidLightmapRow { + /// Row index. + index: usize, + /// Original line. + line: String, + }, + /// MAT0 payload is too small. + Mat0TooSmall { + /// Payload size. + size: usize, + }, + /// MAT0 phase count is unsupported. + InvalidPhaseCount { + /// Phase count. + count: usize, + }, + /// MAT0 range is outside payload. + Mat0OutOfBounds, + /// MAT0 has trailing bytes not accounted for by the current grammar. + Mat0TrailingBytes { + /// Expected EOF. + expected: usize, + /// Actual payload size. + actual: usize, + }, + /// Material index is outside WEAR table. + WearIndexOutOfBounds { + /// Requested index. + index: u16, + /// Entry count. + count: usize, + }, + /// Repository error. + Resource(String), + /// Material archive or entry is missing. + MissingMaterial(String), + /// A material document has no phases for runtime sampling. + EmptyMaterial, +} + +impl From for MaterialError { + fn from(value: ResourceError) -> Self { + Self::Resource(value.to_string()) + } +} + +impl std::fmt::Display for MaterialError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EmptyWear => write!(f, "WEAR payload is empty"), + Self::InvalidWearCount(line) => write!(f, "invalid WEAR count line: {line}"), + Self::ZeroWearCount => write!(f, "WEAR count must be greater than zero"), + Self::MissingWearRow { index, count } => { + write!(f, "missing WEAR row {index} of {count}") + } + Self::InvalidWearRow { index, line } => { + write!(f, "invalid WEAR row {index}: {line}") + } + Self::MissingLightmapSeparator => { + write!(f, "missing blank separator before LIGHTMAPS") + } + Self::MissingLightmapMarker => write!(f, "missing LIGHTMAPS marker"), + Self::InvalidLightmapCount(line) => { + write!(f, "invalid LIGHTMAPS count line: {line}") + } + Self::MissingLightmapRow { index, count } => { + write!(f, "missing LIGHTMAPS row {index} of {count}") + } + Self::InvalidLightmapRow { index, line } => { + write!(f, "invalid LIGHTMAPS row {index}: {line}") + } + Self::Mat0TooSmall { size } => write!(f, "MAT0 payload too small: {size}"), + Self::InvalidPhaseCount { count } => { + write!(f, "invalid MAT0 phase count: {count}") + } + Self::Mat0OutOfBounds => write!(f, "MAT0 data out of bounds"), + Self::Mat0TrailingBytes { expected, actual } => { + write!( + f, + "MAT0 trailing bytes: expected EOF {expected}, actual {actual}" + ) + } + Self::WearIndexOutOfBounds { index, count } => { + write!(f, "WEAR index {index} outside {count} entries") + } + Self::Resource(message) => write!(f, "{message}"), + Self::MissingMaterial(name) => write!(f, "missing material: {name}"), + Self::EmptyMaterial => write!(f, "material has no phases"), + } + } +} + +impl std::error::Error for MaterialError {} + +/// Decodes WEAR material/lightmap table. +/// +/// # Errors +/// +/// Returns [`MaterialError`] when count lines, rows, or the `LIGHTMAPS` +/// section framing are malformed. +pub fn decode_wear(bytes: &[u8]) -> Result { + let text = decode_cp1251(bytes).replace('\r', ""); + let mut lines = text.lines(); + let Some(first) = lines.next() else { + return Err(MaterialError::EmptyWear); + }; + let count = parse_count(first).map_err(|_| MaterialError::InvalidWearCount(first.into()))?; + if count == 0 { + return Err(MaterialError::ZeroWearCount); + } + + let mut entries = Vec::with_capacity(count); + for index in 0..count { + let line = lines + .next() + .ok_or(MaterialError::MissingWearRow { index, count })?; + let (legacy_id, material) = + parse_pair(line).ok_or_else(|| MaterialError::InvalidWearRow { + index, + line: line.to_string(), + })?; + entries.push(WearEntry { + legacy_id, + material, + }); + } + + let remainder = lines.collect::>(); + let lightmaps = parse_lightmaps(&remainder)?; + Ok(WearTable { entries, lightmaps }) +} + +/// Decodes MAT0 material phase data. +/// +/// # Errors +/// +/// Returns [`MaterialError`] when version-gated prefix bytes, phase records, or +/// EOF framing are malformed. +pub fn decode_mat0(bytes: &[u8], version: u32) -> Result { + if bytes.len() < 4 { + return Err(MaterialError::Mat0TooSmall { size: bytes.len() }); + } + let phase_count = usize::from(u16::from_le_bytes([bytes[0], bytes[1]])); + let animation_block_count = u16::from_le_bytes([bytes[2], bytes[3]]); + if animation_block_count >= 20 { + return Err(MaterialError::InvalidPhaseCount { + count: usize::from(animation_block_count), + }); + } + let header_opaque = [bytes[2], bytes[3]]; + let prefix_len = mat0_prefix_len(version); + let phase_start = 4usize + .checked_add(prefix_len) + .ok_or(MaterialError::Mat0OutOfBounds)?; + let phase_bytes = phase_count + .checked_mul(34) + .ok_or(MaterialError::Mat0OutOfBounds)?; + let phase_end = phase_start + .checked_add(phase_bytes) + .ok_or(MaterialError::Mat0OutOfBounds)?; + if phase_end > bytes.len() { + return Err(MaterialError::Mat0OutOfBounds); + } + + let mut phases = Vec::with_capacity(phase_count); + for index in 0..phase_count { + let offset = phase_start + .checked_add( + index + .checked_mul(34) + .ok_or(MaterialError::Mat0OutOfBounds)?, + ) + .ok_or(MaterialError::Mat0OutOfBounds)?; + let record = bytes + .get(offset..offset + 34) + .ok_or(MaterialError::Mat0OutOfBounds)?; + let mut parameters = [0; 18]; + let mut texture_raw = [0; 16]; + parameters.copy_from_slice(&record[..18]); + texture_raw.copy_from_slice(&record[18..34]); + phases.push(MaterialPhase { + parameters, + texture_raw, + }); + } + + let animation_blocks = parse_animation_blocks(&bytes[phase_end..], animation_block_count)?; + Ok(Mat0Document { + version, + animation_block_count, + phases, + prefix: bytes[4..phase_start].to_vec(), + header_opaque, + animation_blocks, + }) +} + +/// Resolves a material selected by WEAR index. +/// +/// # Errors +/// +/// Returns [`MaterialError`] when the WEAR index is invalid, `material.lib` is +/// missing, or no exact/DEFAULT material can be found. +pub fn resolve_material( + repository: &dyn ResourceRepository, + table: &WearTable, + index: u16, +) -> Result { + let entry = + table + .entries + .get(usize::from(index)) + .ok_or(MaterialError::WearIndexOutOfBounds { + index, + count: table.entries.len(), + })?; + let archive = repository.open_archive( + &archive_path(b"material.lib").map_err(|err| MaterialError::Resource(err.to_string()))?, + )?; + + if let Some(resolved) = load_material_entry( + repository, + archive, + &entry.material, + MaterialFallback::Exact, + )? { + return Ok(resolved); + } + let default = ResourceName(b"DEFAULT".to_vec()); + if let Some(resolved) = + load_material_entry(repository, archive, &default, MaterialFallback::Default)? + { + return Ok(resolved); + } + if let Some(first) = table.entries.first() { + if let Some(resolved) = load_material_entry( + repository, + archive, + &first.material, + MaterialFallback::FirstEntry, + )? { + return Ok(resolved); + } + } + Err(MaterialError::MissingMaterial( + String::from_utf8_lossy(&entry.material.0).into_owned(), + )) +} + +/// Samples a material phase with deterministic runtime timeline semantics. +/// +/// # Errors +/// +/// Returns [`MaterialError::EmptyMaterial`] when the MAT0 document has no +/// phases. +pub fn sample_material_phase( + document: &Mat0Document, + profile: MaterialTimelineProfile, + frame: u32, + seed: u64, +) -> Result { + if document.phases.is_empty() { + return Err(MaterialError::EmptyMaterial); + } + let phase_count = document.phases.len(); + let offset = if profile.random_offset { + material_random_offset(seed, phase_count) + } else { + 0 + }; + let effective_frame = frame.wrapping_add(offset); + let phase_index = select_phase_index(profile.mode, effective_frame, phase_count); + let phase = &document.phases[phase_index]; + Ok(MaterialPhaseSample { + phase_index, + effective_frame, + parameters: phase.parameters, + texture_raw: phase.texture_raw, + }) +} + +/// Interpolates selected parameter bytes according to a bit mask. +/// +/// Unmasked fields are copied from `left`; masked fields are linearly blended +/// and rounded to nearest integer. +#[must_use] +pub fn interpolate_parameter_bytes( + left: [u8; 18], + right: [u8; 18], + interpolation_mask: u32, + t: f32, +) -> [u8; 18] { + let mut out = left; + for (index, value) in out.iter_mut().enumerate() { + if interpolation_mask & (1_u32 << index) == 0 { + continue; + } + let blended = + f32::from(left[index]) + (f32::from(right[index]) - f32::from(left[index])) * t; + *value = rounded_clamped_byte(blended); + } + out +} + +fn rounded_clamped_byte(value: f32) -> u8 { + let rounded = value.round(); + if !rounded.is_finite() || rounded <= 0.0 { + return 0; + } + if rounded >= f32::from(u8::MAX) { + return u8::MAX; + } + (0_u8..=u8::MAX) + .find(|candidate| f32::from(*candidate) >= rounded) + .unwrap_or(u8::MAX) +} + +/// Builds a deterministic capture for material phase sampling. +/// +/// # Errors +/// +/// Returns [`MaterialError`] when sampling fails. +pub fn material_phase_capture( + document: &Mat0Document, + profile: MaterialTimelineProfile, + frames: &[u32], + seed: u64, +) -> Result, MaterialError> { + let mut out = Vec::new(); + for frame in frames { + let sample = sample_material_phase(document, profile, *frame, seed)?; + out.extend_from_slice( + format!( + "M,{},{},{}\n", + frame, sample.effective_frame, sample.phase_index + ) + .as_bytes(), + ); + } + Ok(out) +} + +impl Mat0Document { + /// Returns the first non-empty texture name from material phases. + #[must_use] + pub fn primary_texture(&self) -> Option { + self.phases.iter().find_map(|phase| { + let bytes = bounded_cstr(&phase.texture_raw); + (!bytes.is_empty()).then(|| ResourceName(bytes.to_vec())) + }) + } + + /// Returns every non-empty texture name from material phases in disk order. + #[must_use] + pub fn texture_requests(&self) -> Vec { + self.phases + .iter() + .filter_map(|phase| { + let bytes = bounded_cstr(&phase.texture_raw); + (!bytes.is_empty()).then(|| ResourceName(bytes.to_vec())) + }) + .collect() + } +} + +fn select_phase_index(mode: MaterialTimelineMode, frame: u32, phase_count: usize) -> usize { + let count = u32::try_from(phase_count).unwrap_or(u32::MAX).max(1); + let index = match mode { + MaterialTimelineMode::OneShot | MaterialTimelineMode::Clamp => frame.min(count - 1), + MaterialTimelineMode::Loop => frame % count, + MaterialTimelineMode::PingPong => { + if count == 1 { + 0 + } else { + let period = count.saturating_mul(2).saturating_sub(2); + let local = frame % period; + if local < count { + local + } else { + period - local + } + } + } + }; + usize::try_from(index).unwrap_or(phase_count.saturating_sub(1)) +} + +fn material_random_offset(seed: u64, phase_count: usize) -> u32 { + let count = u64::try_from(phase_count).unwrap_or(u64::MAX).max(1); + let mut state = 0xa076_1d64_78bd_642f_u64 ^ seed; + for byte in b"material" { + state ^= u64::from(*byte); + state = splitmix64(state); + } + u32::try_from(splitmix64(state) % count).unwrap_or(0) +} + +fn splitmix64(mut value: u64) -> u64 { + value = value.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut mixed = value; + mixed = (mixed ^ (mixed >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + mixed = (mixed ^ (mixed >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + mixed ^ (mixed >> 31) +} + +fn load_material_entry( + repository: &dyn ResourceRepository, + archive: fparkan_resource::ArchiveId, + name: &ResourceName, + fallback: MaterialFallback, +) -> Result, MaterialError> { + let Some(handle) = repository.find(archive, name)? 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, + document, + })) +} + +fn parse_lightmaps(lines: &[&str]) -> Result, MaterialError> { + if lines.is_empty() || lines.iter().all(|line| line.trim().is_empty()) { + return Ok(Vec::new()); + } + let mut cursor = 0usize; + if !lines[cursor].trim().is_empty() { + return Err(MaterialError::MissingLightmapSeparator); + } + cursor += 1; + if lines.get(cursor).map(|line| line.trim()) != Some("LIGHTMAPS") { + return Err(MaterialError::MissingLightmapMarker); + } + cursor += 1; + let count_line = lines + .get(cursor) + .ok_or_else(|| MaterialError::InvalidLightmapCount(String::new()))?; + let count = parse_count(count_line) + .map_err(|_| MaterialError::InvalidLightmapCount((*count_line).to_string()))?; + cursor += 1; + let mut lightmaps = Vec::with_capacity(count); + for index in 0..count { + let line = lines + .get(cursor) + .ok_or(MaterialError::MissingLightmapRow { index, count })?; + let (legacy_id, lightmap) = + parse_pair(line).ok_or_else(|| MaterialError::InvalidLightmapRow { + index, + line: (*line).to_string(), + })?; + lightmaps.push(LightmapEntry { + legacy_id, + lightmap, + }); + cursor += 1; + } + if lines[cursor..].iter().any(|line| !line.trim().is_empty()) { + return Err(MaterialError::InvalidLightmapRow { + index: count, + line: lines[cursor..].join("\n"), + }); + } + Ok(lightmaps) +} + +fn parse_pair(line: &str) -> Option<(LegacyText, ResourceName)> { + let mut parts = line.split_whitespace(); + let legacy = parts.next()?; + let resource = parts.next()?; + Some(( + LegacyText(legacy.to_string()), + ResourceName(resource.as_bytes().to_vec()), + )) +} + +fn parse_count(line: &str) -> Result { + line.trim().parse::() +} + +fn mat0_prefix_len(version: u32) -> usize { + let mut len = 0usize; + if version >= 2 { + len += 2; + } + if version >= 3 { + len += 4; + } + if version >= 4 { + len += 4; + } + len +} + +fn parse_animation_blocks( + bytes: &[u8], + block_count: u16, +) -> Result, MaterialError> { + if block_count == 0 && bytes.is_empty() { + return Ok(Vec::new()); + } + let mut cursor = 0usize; + let mut out = Vec::with_capacity(usize::from(block_count)); + for _ in 0..block_count { + let start = cursor; + let header_end = cursor + .checked_add(6) + .ok_or(MaterialError::Mat0OutOfBounds)?; + let header = bytes + .get(cursor..header_end) + .ok_or(MaterialError::Mat0OutOfBounds)?; + let header_raw = u32::from_le_bytes( + header[0..4] + .try_into() + .map_err(|_| MaterialError::Mat0OutOfBounds)?, + ); + let key_count = usize::from(u16::from_le_bytes([header[4], header[5]])); + cursor = header_end; + let keys_bytes = key_count + .checked_mul(6) + .ok_or(MaterialError::Mat0OutOfBounds)?; + let keys_end = cursor + .checked_add(keys_bytes) + .ok_or(MaterialError::Mat0OutOfBounds)?; + if keys_end > bytes.len() { + return Err(MaterialError::Mat0OutOfBounds); + } + let mut keys = Vec::with_capacity(key_count); + for chunk in bytes[cursor..keys_end].chunks_exact(6) { + keys.push(MaterialKey { + k0: u16::from_le_bytes([chunk[0], chunk[1]]), + k1: u16::from_le_bytes([chunk[2], chunk[3]]), + k2: u16::from_le_bytes([chunk[4], chunk[5]]), + }); + } + cursor = keys_end; + out.push(MaterialAnimationBlock { + header_raw, + keys, + bytes: bytes[start..cursor].to_vec(), + }); + } + if cursor != bytes.len() { + return Err(MaterialError::Mat0TrailingBytes { + expected: cursor, + actual: bytes.len(), + }); + } + Ok(out) +} + +fn bounded_cstr(bytes: &[u8]) -> &[u8] { + let len = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + trim_ascii(&bytes[..len]) +} + +fn trim_ascii(bytes: &[u8]) -> &[u8] { + let mut start = 0usize; + let mut end = bytes.len(); + while start < end && bytes[start].is_ascii_whitespace() { + start += 1; + } + while end > start && bytes[end - 1].is_ascii_whitespace() { + end -= 1; + } + &bytes[start..end] +} + +fn decode_cp1251(bytes: &[u8]) -> String { + let (decoded, _, _) = WINDOWS_1251.decode(bytes); + decoded.into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_nres::ReadProfile; + use fparkan_resource::CachedResourceRepository; + use fparkan_vfs::MemoryVfs; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + + #[test] + fn wear_preserves_legacy_id_but_selects_by_index() { + let table = decode_wear(b"2\r\n100 MAT_A\r\n5 MAT_B\r\n").expect("wear"); + + assert_eq!(table.entries[0].legacy_id, LegacyText("100".to_string())); + assert_eq!(table.entries[1].material.0, b"MAT_B"); + } + + #[test] + fn wear_requires_declared_rows() { + let err = decode_wear(b"2\n0 ONLY_ONE\n").expect_err("missing row"); + assert!(matches!(err, MaterialError::MissingWearRow { .. })); + } + + #[test] + fn wear_requires_blank_separator_before_lightmaps() { + let err = decode_wear(b"1\n0 MAT\nLIGHTMAPS\n1\n0 LM\n").expect_err("separator"); + assert!(matches!(err, MaterialError::MissingLightmapSeparator)); + } + + #[test] + fn wear_parses_lightmaps() { + let table = decode_wear(b"1\n0 MAT\n\nLIGHTMAPS\n1\n0 LM_A\n").expect("wear"); + assert_eq!(table.lightmaps.len(), 1); + assert_eq!(table.lightmaps[0].lightmap.0, b"LM_A"); + } + + #[test] + fn mat0_version_prefix_and_primary_texture() { + let mut bytes = vec![0; 4 + 10 + 68]; + bytes[0..2].copy_from_slice(&2_u16.to_le_bytes()); + bytes[4 + 10 + 18..4 + 10 + 25].copy_from_slice(b"TEXMAIN"); + bytes[4 + 10 + 34 + 18..4 + 10 + 34 + 24].copy_from_slice(b"TEXALT"); + let document = decode_mat0(&bytes, 4).expect("mat0"); + + assert_eq!(document.prefix.len(), 10); + assert_eq!(document.phases.len(), 2); + assert_eq!(document.primary_texture().expect("texture").0, b"TEXMAIN"); + let textures = document.texture_requests(); + assert_eq!(textures.len(), 2); + assert_eq!(textures[0].0, b"TEXMAIN"); + assert_eq!(textures[1].0, b"TEXALT"); + } + + #[test] + fn mat0_accepts_zero_phase_material() { + let document = decode_mat0(&[0, 0, 0, 0], 0).expect("zero phase"); + + assert!(document.phases.is_empty()); + assert!(document.texture_requests().is_empty()); + } + + #[test] + fn mat0_phase34_exact_framing_and_full_texture_name() { + let mut bytes = vec![0; 4 + 34]; + bytes[0..2].copy_from_slice(&1_u16.to_le_bytes()); + bytes[4..22].copy_from_slice(&[0xAB; 18]); + bytes[22..38].copy_from_slice(b"1234567890ABCDEF"); + + let document = decode_mat0(&bytes, 0).expect("mat0"); + + assert_eq!(document.phases.len(), 1); + assert_eq!(document.phases[0].parameters, [0xAB; 18]); + assert_eq!( + document.primary_texture().expect("texture").0, + b"1234567890ABCDEF" + ); + } + + #[test] + fn mat0_animation_block_has_no_implicit_padding() { + let mut bytes = vec![0, 0, 1, 0]; + bytes.extend_from_slice(&0xAABB_CCDD_u32.to_le_bytes()); + bytes.extend_from_slice(&1_u16.to_le_bytes()); + bytes.extend_from_slice(&7_u16.to_le_bytes()); + bytes.extend_from_slice(&8_u16.to_le_bytes()); + bytes.extend_from_slice(&9_u16.to_le_bytes()); + + let document = decode_mat0(&bytes, 0).expect("animation block"); + + assert_eq!(document.animation_blocks.len(), 1); + assert_eq!(document.animation_blocks[0].header_raw, 0xAABB_CCDD); + assert_eq!( + document.animation_blocks[0].keys, + vec![MaterialKey { + k0: 7, + k1: 8, + k2: 9, + }] + ); + assert_eq!(document.animation_blocks[0].bytes.len(), 12); + } + + #[test] + fn mat0_rejects_animation_block_count_limit() { + let err = decode_mat0(&[0, 0, 20, 0], 0).expect_err("animation block count"); + + assert!(matches!( + err, + MaterialError::InvalidPhaseCount { count: 20 } + )); + } + + #[test] + fn mat0_rejects_trailing_bytes() { + let bytes = vec![0, 0, 0, 0, 1]; + let err = decode_mat0(&bytes, 0).expect_err("trailing byte"); + assert!(matches!(err, MaterialError::Mat0TrailingBytes { .. })); + } + + #[test] + fn resolve_material_uses_exact_match() { + let repo = material_repo(&[ + material_entry(b"MAT_A", &mat0_with_texture(b"TEX_A")), + material_entry(b"DEFAULT", &mat0_with_texture(b"TEX_DEFAULT")), + ]); + let table = decode_wear(b"1\n0 MAT_A\n").expect("wear"); + + let resolved = resolve_material(&repo, &table, 0).expect("resolved"); + + assert_eq!(resolved.name.0, b"MAT_A"); + assert_eq!(resolved.fallback, MaterialFallback::Exact); + assert_eq!( + resolved.document.primary_texture().expect("texture").0, + b"TEX_A" + ); + } + + #[test] + fn resolve_material_falls_back_to_default() { + let repo = material_repo(&[material_entry( + b"DEFAULT", + &mat0_with_texture(b"TEX_DEFAULT"), + )]); + let table = decode_wear(b"1\n0 MISSING\n").expect("wear"); + + let resolved = resolve_material(&repo, &table, 0).expect("resolved"); + + assert_eq!(resolved.name.0, b"DEFAULT"); + assert_eq!(resolved.fallback, MaterialFallback::Default); + } + + #[test] + fn resolve_material_uses_first_entry_only_after_missing_default() { + let repo = material_repo(&[material_entry(b"MAT_FIRST", &mat0_with_texture(b"TEX_A"))]); + let table = decode_wear(b"2\n0 MAT_FIRST\n1 MISSING\n").expect("wear"); + + let resolved = resolve_material(&repo, &table, 1).expect("resolved"); + + assert_eq!(resolved.name.0, b"MAT_FIRST"); + assert_eq!(resolved.fallback, MaterialFallback::FirstEntry); + } + + #[test] + fn resolve_material_empty_texture_means_untextured() { + let repo = material_repo(&[material_entry(b"MAT_EMPTY", &mat0_with_texture(b""))]); + let table = decode_wear(b"1\n0 MAT_EMPTY\n").expect("wear"); + + let resolved = resolve_material(&repo, &table, 0).expect("resolved"); + + assert!(resolved.document.primary_texture().is_none()); + assert!(resolved.document.texture_requests().is_empty()); + } + + #[test] + fn resolve_material_without_lightmap_keeps_lightmap_absent() { + let repo = material_repo(&[material_entry(b"MAT_A", &mat0_with_texture(b"TEX_A"))]); + let table = decode_wear(b"1\n0 MAT_A\n").expect("wear"); + + let resolved = resolve_material(&repo, &table, 0).expect("resolved"); + + assert_eq!(resolved.fallback, MaterialFallback::Exact); + assert!(table.lightmaps.is_empty()); + } + + #[test] + fn material_modes_zero_to_three_choose_stable_phases() { + let document = + decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0"); + + let cases = [ + (MaterialTimelineMode::OneShot, 9, 2), + (MaterialTimelineMode::Clamp, 9, 2), + (MaterialTimelineMode::Loop, 4, 1), + (MaterialTimelineMode::PingPong, 3, 1), + ]; + for (mode, frame, expected_phase) in cases { + let sample = sample_material_phase( + &document, + MaterialTimelineProfile { + mode, + random_offset: false, + }, + frame, + 0, + ) + .expect("sample"); + assert_eq!(sample.phase_index, expected_phase, "{mode:?}"); + } + } + + #[test] + fn material_exact_key_boundary_selects_exact_phase() { + let document = + decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0"); + + let sample = sample_material_phase( + &document, + MaterialTimelineProfile { + mode: MaterialTimelineMode::Clamp, + random_offset: false, + }, + 1, + 0, + ) + .expect("sample"); + + assert_eq!(sample.phase_index, 1); + assert_eq!(&sample.texture_raw[..1], b"B"); + } + + #[test] + fn material_interpolation_mask_affects_only_selected_fields() { + let mut left = [10_u8; 18]; + let mut right = [20_u8; 18]; + left[1] = 100; + right[1] = 200; + + let out = interpolate_parameter_bytes(left, right, 0b101, 0.5); + + assert_eq!(out[0], 15); + assert_eq!(out[1], 100); + assert_eq!(out[2], 15); + assert_eq!(out[3], 10); + } + + #[test] + fn material_timeline_profile_cases_are_evidence_labeled() { + let document = + decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0"); + + assert_eq!( + material_phase_capture( + &document, + MaterialTimelineProfile { + mode: MaterialTimelineMode::OneShot, + random_offset: false, + }, + &[0, 1, 4], + 0, + ) + .expect("one-shot"), + b"M,0,0,0\nM,1,1,1\nM,4,4,2\n" + ); + assert_eq!( + material_phase_capture( + &document, + MaterialTimelineProfile { + mode: MaterialTimelineMode::Loop, + random_offset: false, + }, + &[0, 1, 4], + 0, + ) + .expect("loop"), + b"M,0,0,0\nM,1,1,1\nM,4,4,1\n" + ); + assert_eq!( + material_phase_capture( + &document, + MaterialTimelineProfile { + mode: MaterialTimelineMode::PingPong, + random_offset: false, + }, + &[0, 1, 3], + 0, + ) + .expect("ping-pong"), + b"M,0,0,0\nM,1,1,1\nM,3,3,1\n" + ); + } + + #[test] + fn material_random_offset_uses_material_stream_only() { + let document = + decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0"); + let profile = MaterialTimelineProfile { + mode: MaterialTimelineMode::Loop, + random_offset: true, + }; + let before = material_phase_capture(&document, profile, &[0, 1, 2], 99).expect("capture"); + let mut unrelated = 0x5555_u64; + for _ in 0..16 { + unrelated = unrelated.rotate_left(11).wrapping_mul(31); + } + + assert_ne!(unrelated, 0); + assert_eq!( + material_phase_capture(&document, profile, &[0, 1, 2], 99).expect("capture"), + before + ); + } + + #[test] + fn material_same_seed_and_timeline_produces_same_phase_capture() { + let document = + decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0"); + let profile = MaterialTimelineProfile { + mode: MaterialTimelineMode::Loop, + random_offset: true, + }; + + assert_eq!( + material_phase_capture(&document, profile, &[0, 4, 7], 123).expect("first"), + material_phase_capture(&document, profile, &[0, 4, 7], 123).expect("second") + ); + } + + #[test] + fn licensed_corpus_mat0_and_wear_parse() { + for (corpus, expected_mat0, expected_archive_wear, expected_standalone_wear) in [ + ("IS", 905_usize, 439_usize, 95_usize), + ("IS2", 1127_usize, 515_usize, 95_usize), + ] { + let Some(root) = corpus_root(corpus) else { + continue; + }; + let mut mat0_count = 0usize; + let mut archive_wear_count = 0usize; + let mut standalone_wear_count = 0usize; + for path in files_under(&root) { + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + if path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("wea")) + { + decode_wear(&bytes) + .unwrap_or_else(|err| panic!("{corpus} standalone {path:?}: {err}")); + standalone_wear_count += 1; + continue; + } + let Ok(archive) = fparkan_nres::decode( + Arc::from(bytes.into_boxed_slice()), + ReadProfile::Compatible, + ) else { + continue; + }; + for entry in archive.entries() { + let payload = archive.payload(entry.id()).expect("payload"); + match entry.meta().type_id { + MAT0_KIND => { + decode_mat0(payload, entry.meta().attr2).unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) + }); + mat0_count += 1; + } + WEAR_KIND => { + decode_wear(payload).unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) + }); + archive_wear_count += 1; + } + _ => {} + } + } + } + assert_eq!(mat0_count, expected_mat0, "{corpus} MAT0 count"); + assert_eq!( + archive_wear_count, expected_archive_wear, + "{corpus} archive WEAR count" + ); + assert_eq!( + standalone_wear_count, expected_standalone_wear, + "{corpus} standalone WEAR count" + ); + } + } + + fn corpus_root(name: &str) -> Option { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("testdata") + .join(name); + root.is_dir().then_some(root) + } + + fn files_under(root: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(path) = stack.pop() { + let Ok(read_dir) = std::fs::read_dir(path) else { + continue; + }; + for entry in read_dir.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + out.push(path); + } + } + } + out.sort(); + out + } + + struct TestMaterialEntry<'a> { + name: &'a [u8], + type_id: u32, + attr2: u32, + payload: &'a [u8], + } + + fn material_entry<'a>(name: &'a [u8], payload: &'a [u8]) -> TestMaterialEntry<'a> { + TestMaterialEntry { + name, + type_id: MAT0_KIND, + attr2: 0, + payload, + } + } + + fn material_repo(entries: &[TestMaterialEntry<'_>]) -> CachedResourceRepository { + let path = archive_path(b"material.lib").expect("material path"); + let mut vfs = MemoryVfs::default(); + vfs.insert( + path, + Arc::from(build_material_nres(entries).into_boxed_slice()), + ); + CachedResourceRepository::new(Arc::new(vfs)) + } + + fn mat0_with_texture(texture: &[u8]) -> Vec { + let mut bytes = vec![0; 4 + 34]; + bytes[0..2].copy_from_slice(&1_u16.to_le_bytes()); + let len = texture.len().min(16); + bytes[22..22 + len].copy_from_slice(&texture[..len]); + bytes + } + + fn mat0_with_phase_textures(textures: &[&[u8]]) -> Vec { + let mut bytes = vec![0; 4 + textures.len() * 34]; + bytes[0..2].copy_from_slice( + &u16::try_from(textures.len()) + .expect("phase count") + .to_le_bytes(), + ); + for (index, texture) in textures.iter().enumerate() { + let offset = 4 + index * 34; + bytes[offset] = u8::try_from(index).expect("index"); + let len = texture.len().min(16); + bytes[offset + 18..offset + 18 + len].copy_from_slice(&texture[..len]); + } + bytes + } + + fn build_material_nres(entries: &[TestMaterialEntry<'_>]) -> Vec { + 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 = (0..entries.len()).collect(); + order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name)); + for (idx, entry) in entries.iter().enumerate() { + push_u32(&mut out, entry.type_id); + push_u32(&mut out, 0); + push_u32(&mut out, entry.attr2); + push_u32( + &mut out, + u32::try_from(entry.payload.len()).expect("payload"), + ); + push_u32(&mut out, 0); + let mut name_raw = [0; 36]; + let len = name_raw.len().saturating_sub(1).min(entry.name.len()); + name_raw[..len].copy_from_slice(&entry.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, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); + } +} diff --git a/crates/fparkan-mission-format/Cargo.toml b/crates/fparkan-mission-format/Cargo.toml new file mode 100644 index 0000000..52103f8 --- /dev/null +++ b/crates/fparkan-mission-format/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "fparkan-mission-format" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +encoding_rs = "0.8" +fparkan-binary = { path = "../fparkan-binary" } + +[lints] +workspace = true diff --git a/crates/fparkan-mission-format/src/lib.rs b/crates/fparkan-mission-format/src/lib.rs new file mode 100644 index 0000000..edbe908 --- /dev/null +++ b/crates/fparkan-mission-format/src/lib.rs @@ -0,0 +1,1172 @@ +#![forbid(unsafe_code)] +//! Count-driven mission format primitives. + +use encoding_rs::WINDOWS_1251; +use fparkan_binary::{checked_count_bytes, read_lp_bytes, Cursor, DecodeError}; +use std::sync::Arc; + +const FORMAT_VERSION: u32 = 1; +const CLAN_SECTION_VERSION: u32 = 6; +const OBJECT_SECTION_VERSION: u32 = 10; +const PROPERTY_SCHEMA_VERSION: u32 = 1; +const EXTRA_SECTION_VERSION: u32 = 1; +const OBJECT_CLASS_OR_FLAGS: u32 = 0x8000_0002; +const MAX_PATHS: u32 = 16_384; +const MAX_POINTS: u32 = 1_000_000; +const MAX_CLANS: u32 = 16_384; +const MAX_RELATIONS: u32 = 65_536; +const MAX_SPATIAL_GROUPS: u32 = 65_536; +const MAX_SPATIAL_RECORDS: u32 = 1_000_000; +const MAX_OBJECTS: u32 = 1_000_000; +const MAX_PROPERTIES: u32 = 1_000_000; +const MAX_EXTRAS: u32 = 1_000_000; +const MAX_STRING_BYTES: u32 = 64 * 1024; + +/// Mission document. +#[derive(Clone, Debug, PartialEq)] +pub struct MissionDocument { + /// Top-level format version. + pub format_version: u32, + /// Clan section version. + pub clan_section_version: u32, + /// Object section version. + pub object_section_version: u32, + /// Extra section version. + pub extra_section_version: u32, + /// Version words preserved for compact compatibility checks. + pub versions: Vec, + /// Paths. + pub paths: Vec, + /// Clans. + pub clans: Vec, + /// Placed objects. + pub objects: Vec, + /// Landscape path. + pub land_path: LpString, + /// Mission flag. + pub mission_flag: u32, + /// Raw mission description. + pub description_raw: LpString, + /// Extras. + pub extras: Vec, + /// Original bytes. + pub raw: Arc<[u8]>, +} + +/// Length-prefixed string with decoded CP1251 helper text. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct LpString { + /// Raw bytes from the file. + pub raw: Vec, + /// Decoded text. + pub decoded: String, +} + +/// Mission path. +#[derive(Clone, Debug, PartialEq)] +pub struct MissionPath { + /// Path id. + pub id: i32, + /// Points. + pub points: Vec<[f32; 3]>, +} + +/// Clan record. +#[derive(Clone, Debug, PartialEq)] +pub struct ClanRecord { + /// Clan name. + pub name: LpString, + /// Raw id, usually `-1` in checked corpora. + pub raw_id: i32, + /// Two-dimensional clan anchor. + pub anchor: [f32; 2], + /// Mode selector. + pub mode: u32, + /// Mode-dependent payload. + pub body: ClanBody, + /// Relation table. + pub relations: Vec, +} + +/// Clan mode-dependent body. +#[derive(Clone, Debug, PartialEq)] +pub enum ClanBody { + /// Standard modes 1..=3. + Standard { + /// First tagged resource. + first_resource: TaggedResource, + /// Second tagged resource. + second_resource: TaggedResource, + }, + /// Mode 0 spatial body. + Spatial { + /// First untagged resource. + first_resource: LpString, + /// Spatial groups. + spatial_groups: Vec, + /// Second tagged resource. + second_resource: TaggedResource, + }, +} + +/// Tagged clan resource reference. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TaggedResource { + /// Resource path. + pub path: LpString, + /// Raw tag. + pub tag: i32, +} + +/// Mode 0 spatial group. +#[derive(Clone, Debug, PartialEq)] +pub struct SpatialGroup { + /// Raw spatial records, five floats each. + pub records: Vec<[f32; 5]>, +} + +/// Clan relation entry. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClanRelation { + /// Other clan name. + pub other_clan_name: LpString, + /// Raw relation value. + pub relation_value: i32, +} + +/// Placed object. +#[derive(Clone, Debug, PartialEq)] +pub struct PlacedObject { + /// Raw object kind. + pub raw_kind: u32, + /// Class/flags word. + pub class_or_flags: u32, + /// Resource reference. + pub resource_name: LpString, + /// Raw resource bytes retained for older callers. + pub resource_raw: Vec, + /// Raw word after resource. + pub raw_after_resource: u32, + /// Raw identity/clan word. + pub identity_or_clan_raw: u32, + /// Position. + pub position: [f32; 3], + /// Orientation. + pub orientation: [f32; 3], + /// Scale. + pub scale: [f32; 3], + /// Instance name. + pub instance_name: LpString, + /// Raw word after instance name. + pub raw_after_name: u32, + /// First link word. + pub link0: i32, + /// Second link word. + pub link1: i32, + /// Property schema version. + pub property_schema_version: u32, + /// Ordered properties. + pub properties: Vec, +} + +/// Ordered property. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OrderedProperty { + /// Raw words. + pub raw_value: [u32; 4], + /// Property name. + pub name: LpString, + /// Raw name bytes retained for older callers. + pub name_raw: Vec, +} + +/// Mission epilogue marker. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct MissionEpilogue; + +/// 28-byte extra record. +#[derive(Clone, Debug, PartialEq)] +pub struct ExtraRecord28 { + /// Raw 28-byte record. + pub raw: [u8; 28], + /// Position. + pub position: [f32; 3], + /// Preserved trailing words. + pub raw_words: [u32; 4], +} + +/// TMA profile. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TmaProfile { + /// Strict profile. + Strict, +} + +/// Mission error. +#[derive(Debug)] +pub enum MissionError { + /// Decode error. + Decode(DecodeError), + /// Unsupported branch. + Unsupported(&'static str), + /// Invalid section version. + InvalidVersion { + /// Section name. + section: &'static str, + /// Expected version. + expected: u32, + /// Observed version. + got: u32, + }, + /// Unknown clan mode. + UnknownClanMode { + /// Clan index. + clan: usize, + /// Observed mode. + mode: u32, + }, + /// Invalid placed object flags. + InvalidObjectFlags { + /// Object index. + object: usize, + /// Observed flags. + flags: u32, + }, + /// Non-finite transform field. + NonFiniteTransform { + /// Object index. + object: usize, + }, +} + +impl From for MissionError { + fn from(value: DecodeError) -> Self { + Self::Decode(value) + } +} + +impl std::fmt::Display for MissionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Decode(source) => write!(f, "{source}"), + Self::Unsupported(reason) => write!(f, "unsupported TMA branch: {reason}"), + Self::InvalidVersion { + section, + expected, + got, + } => write!( + f, + "invalid TMA {section} version {got}, expected {expected}" + ), + Self::UnknownClanMode { clan, mode } => { + write!(f, "unknown TMA clan mode {mode} at clan {clan}") + } + Self::InvalidObjectFlags { object, flags } => { + write!(f, "invalid TMA object {object} flags {flags:#x}") + } + Self::NonFiniteTransform { object } => { + write!(f, "TMA object {object} contains non-finite transform") + } + } + } +} + +impl std::error::Error for MissionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Decode(source) => Some(source), + Self::Unsupported(_) + | Self::InvalidVersion { .. } + | Self::UnknownClanMode { .. } + | Self::InvalidObjectFlags { .. } + | Self::NonFiniteTransform { .. } => None, + } + } +} + +/// Decodes an exact, count-driven TMA document. +/// +/// # Errors +/// +/// Returns [`MissionError`] when a count/length is out of bounds, a known +/// section version does not match strict expectations, a mode-dependent branch +/// is unknown, object transforms are invalid, or the cursor does not end at EOF. +pub fn decode_tma(bytes: Arc<[u8]>, profile: TmaProfile) -> Result { + let mut cursor = Cursor::new(&bytes); + let format_version = cursor.read_u32_le()?; + require_version("format", format_version, FORMAT_VERSION, profile)?; + + let paths = parse_paths(&mut cursor)?; + + let clan_section_version = cursor.read_u32_le()?; + require_version( + "clan section", + clan_section_version, + CLAN_SECTION_VERSION, + profile, + )?; + let clans = parse_clans(&mut cursor)?; + + let object_section_version = cursor.read_u32_le()?; + require_version( + "object section", + object_section_version, + OBJECT_SECTION_VERSION, + profile, + )?; + let objects = parse_objects(&mut cursor, profile)?; + + let land_path = read_lp_string(&mut cursor)?; + let mission_flag = cursor.read_u32_le()?; + let description_raw = read_lp_string(&mut cursor)?; + + let extra_section_version = cursor.read_u32_le()?; + require_version( + "extra section", + extra_section_version, + EXTRA_SECTION_VERSION, + profile, + )?; + let extras = parse_extras(&mut cursor)?; + cursor.require_eof()?; + + Ok(MissionDocument { + format_version, + clan_section_version, + object_section_version, + extra_section_version, + versions: vec![ + format_version, + clan_section_version, + object_section_version, + extra_section_version, + ], + paths, + clans, + objects, + land_path, + mission_flag, + description_raw, + extras, + raw: bytes, + }) +} + +/// Decodes only the TMA landscape path needed to load terrain before the full +/// mission document is materialized. +/// +/// # Errors +/// +/// Returns [`MissionError`] when any section preceding the landscape path is +/// malformed or unsupported. +pub fn decode_tma_land_path(bytes: &[u8], profile: TmaProfile) -> Result { + let mut cursor = Cursor::new(bytes); + let format_version = cursor.read_u32_le()?; + require_version("format", format_version, FORMAT_VERSION, profile)?; + let _paths = parse_paths(&mut cursor)?; + + let clan_section_version = cursor.read_u32_le()?; + require_version( + "clan section", + clan_section_version, + CLAN_SECTION_VERSION, + profile, + )?; + let _clans = parse_clans(&mut cursor)?; + + let object_section_version = cursor.read_u32_le()?; + require_version( + "object section", + object_section_version, + OBJECT_SECTION_VERSION, + profile, + )?; + let _objects = parse_objects(&mut cursor, profile)?; + read_lp_string(&mut cursor) +} + +fn require_version( + section: &'static str, + got: u32, + expected: u32, + _profile: TmaProfile, +) -> Result<(), MissionError> { + if got == expected { + Ok(()) + } else { + Err(MissionError::InvalidVersion { + section, + expected, + got, + }) + } +} + +fn parse_paths(cursor: &mut Cursor<'_>) -> Result, MissionError> { + let count = checked_count(cursor.read_u32_le()?, MAX_PATHS)?; + let mut paths = Vec::with_capacity(count); + for _ in 0..count { + let id = cursor.read_i32_le()?; + let point_count = cursor.read_u32_le()?; + checked_count_bytes(u64::from(point_count), 12, cursor.remaining() as u64)?; + let point_count = checked_count(point_count, MAX_POINTS)?; + let mut points = Vec::with_capacity(point_count); + for _ in 0..point_count { + points.push(read_vec3(cursor)?); + } + paths.push(MissionPath { id, points }); + } + Ok(paths) +} + +fn parse_clans(cursor: &mut Cursor<'_>) -> Result, MissionError> { + let count = checked_count(cursor.read_u32_le()?, MAX_CLANS)?; + let mut clans = Vec::with_capacity(count); + for clan_index in 0..count { + let name = read_lp_string(cursor)?; + let raw_id = cursor.read_i32_le()?; + let anchor = [cursor.read_f32_le()?, cursor.read_f32_le()?]; + let mode = cursor.read_u32_le()?; + let (body, relations) = match mode { + 0 => parse_spatial_clan(cursor)?, + 1..=3 => parse_standard_clan(cursor)?, + _ => { + return Err(MissionError::UnknownClanMode { + clan: clan_index, + mode, + }) + } + }; + clans.push(ClanRecord { + name, + raw_id, + anchor, + mode, + body, + relations, + }); + } + Ok(clans) +} + +fn parse_standard_clan( + cursor: &mut Cursor<'_>, +) -> Result<(ClanBody, Vec), MissionError> { + let first_resource = parse_tagged_resource(cursor)?; + let second_resource = parse_tagged_resource(cursor)?; + let relations = parse_relations(cursor)?; + Ok(( + ClanBody::Standard { + first_resource, + second_resource, + }, + relations, + )) +} + +fn parse_spatial_clan( + cursor: &mut Cursor<'_>, +) -> Result<(ClanBody, Vec), MissionError> { + let first_resource = read_lp_string(cursor)?; + let group_count = checked_count(cursor.read_u32_le()?, MAX_SPATIAL_GROUPS)?; + let mut spatial_groups = Vec::with_capacity(group_count); + for _ in 0..group_count { + let record_count = cursor.read_u32_le()?; + checked_count_bytes(u64::from(record_count), 20, cursor.remaining() as u64)?; + let record_count = checked_count(record_count, MAX_SPATIAL_RECORDS)?; + let mut records = Vec::with_capacity(record_count); + for _ in 0..record_count { + records.push([ + cursor.read_f32_le()?, + cursor.read_f32_le()?, + cursor.read_f32_le()?, + cursor.read_f32_le()?, + cursor.read_f32_le()?, + ]); + } + spatial_groups.push(SpatialGroup { records }); + } + let second_resource = parse_tagged_resource(cursor)?; + let relations = parse_relations(cursor)?; + Ok(( + ClanBody::Spatial { + first_resource, + spatial_groups, + second_resource, + }, + relations, + )) +} + +fn parse_tagged_resource(cursor: &mut Cursor<'_>) -> Result { + Ok(TaggedResource { + path: read_lp_string(cursor)?, + tag: cursor.read_i32_le()?, + }) +} + +fn parse_relations(cursor: &mut Cursor<'_>) -> Result, MissionError> { + let count = checked_count(cursor.read_u32_le()?, MAX_RELATIONS)?; + let mut relations = Vec::with_capacity(count); + for _ in 0..count { + relations.push(ClanRelation { + other_clan_name: read_lp_string(cursor)?, + relation_value: cursor.read_i32_le()?, + }); + } + Ok(relations) +} + +fn parse_objects( + cursor: &mut Cursor<'_>, + profile: TmaProfile, +) -> Result, MissionError> { + let count = checked_count(cursor.read_u32_le()?, MAX_OBJECTS)?; + let mut objects = Vec::with_capacity(count); + for object_index in 0..count { + let raw_kind = cursor.read_u32_le()?; + let class_or_flags = cursor.read_u32_le()?; + if profile == TmaProfile::Strict && class_or_flags != OBJECT_CLASS_OR_FLAGS { + return Err(MissionError::InvalidObjectFlags { + object: object_index, + flags: class_or_flags, + }); + } + let resource_name = read_lp_string(cursor)?; + let resource_raw = resource_name.raw.clone(); + let raw_after_resource = cursor.read_u32_le()?; + let identity_or_clan_raw = cursor.read_u32_le()?; + let position = read_vec3(cursor)?; + let orientation = read_vec3(cursor)?; + let scale = read_vec3(cursor)?; + if !all_finite(&position) || !all_finite(&orientation) || !all_finite(&scale) { + return Err(MissionError::NonFiniteTransform { + object: object_index, + }); + } + let instance_name = read_lp_string(cursor)?; + let raw_after_name = cursor.read_u32_le()?; + let link0 = cursor.read_i32_le()?; + let link1 = cursor.read_i32_le()?; + let property_schema_version = cursor.read_u32_le()?; + require_version( + "property schema", + property_schema_version, + PROPERTY_SCHEMA_VERSION, + profile, + )?; + let properties = parse_properties(cursor)?; + objects.push(PlacedObject { + raw_kind, + class_or_flags, + resource_name, + resource_raw, + raw_after_resource, + identity_or_clan_raw, + position, + orientation, + scale, + instance_name, + raw_after_name, + link0, + link1, + property_schema_version, + properties, + }); + } + Ok(objects) +} + +fn parse_properties(cursor: &mut Cursor<'_>) -> Result, MissionError> { + let count = checked_count(cursor.read_u32_le()?, MAX_PROPERTIES)?; + let mut properties = Vec::with_capacity(count); + for _ in 0..count { + let raw_value = [ + cursor.read_u32_le()?, + cursor.read_u32_le()?, + cursor.read_u32_le()?, + cursor.read_u32_le()?, + ]; + let name = read_lp_string(cursor)?; + let name_raw = name.raw.clone(); + properties.push(OrderedProperty { + raw_value, + name, + name_raw, + }); + } + Ok(properties) +} + +fn parse_extras(cursor: &mut Cursor<'_>) -> Result, MissionError> { + let count = checked_count(cursor.read_u32_le()?, MAX_EXTRAS)?; + checked_count_bytes(count as u64, 28, cursor.remaining() as u64)?; + let mut extras = Vec::with_capacity(count); + for _ in 0..count { + let chunk = cursor.read_exact(28)?; + let mut raw = [0; 28]; + raw.copy_from_slice(chunk); + extras.push(ExtraRecord28 { + raw, + position: [ + read_f32_from(chunk, 0)?, + read_f32_from(chunk, 4)?, + read_f32_from(chunk, 8)?, + ], + raw_words: [ + read_u32_from(chunk, 12)?, + read_u32_from(chunk, 16)?, + read_u32_from(chunk, 20)?, + read_u32_from(chunk, 24)?, + ], + }); + } + Ok(extras) +} + +fn read_lp_string(cursor: &mut Cursor<'_>) -> Result { + let raw = read_lp_bytes(cursor, MAX_STRING_BYTES)?; + let (decoded, _, _) = WINDOWS_1251.decode(&raw); + let decoded = decoded.into_owned(); + Ok(LpString { raw, decoded }) +} + +fn read_vec3(cursor: &mut Cursor<'_>) -> Result<[f32; 3], MissionError> { + Ok([ + cursor.read_f32_le()?, + cursor.read_f32_le()?, + cursor.read_f32_le()?, + ]) +} + +fn all_finite(value: &[f32; 3]) -> bool { + value.iter().all(|component| component.is_finite()) +} + +fn checked_count(count: u32, limit: u32) -> Result { + if count > limit { + return Err(DecodeError::LimitExceeded { + count: u64::from(count), + limit: u64::from(limit), + } + .into()); + } + usize::try_from(count).map_err(|_| DecodeError::IntegerOverflow.into()) +} + +fn read_u32_from(bytes: &[u8], offset: usize) -> Result { + let raw = bytes + .get(offset..offset + 4) + .ok_or(DecodeError::IntegerOverflow)?; + Ok(u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]])) +} + +fn read_f32_from(bytes: &[u8], offset: usize) -> Result { + Ok(f32::from_bits(read_u32_from(bytes, offset)?)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::{Path, PathBuf}; + + #[test] + fn minimal_synthetic_exact_eof() { + let mut bytes = Vec::new(); + push_u32(&mut bytes, FORMAT_VERSION); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, CLAN_SECTION_VERSION); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, OBJECT_SECTION_VERSION); + push_u32(&mut bytes, 0); + push_lp(&mut bytes, b"DATA\\MAPS\\Tut_1\\land"); + push_u32(&mut bytes, 0); + push_lp(&mut bytes, b""); + push_u32(&mut bytes, EXTRA_SECTION_VERSION); + push_u32(&mut bytes, 0); + + let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma"); + assert_eq!( + doc.versions, + vec![ + FORMAT_VERSION, + CLAN_SECTION_VERSION, + OBJECT_SECTION_VERSION, + EXTRA_SECTION_VERSION + ] + ); + assert_eq!(doc.land_path.decoded, "DATA\\MAPS\\Tut_1\\land"); + } + + #[test] + fn land_path_prefix_decode_matches_full_document() { + let bytes = minimal_tma_bytes(); + let prefix = decode_tma_land_path(&bytes, TmaProfile::Strict).expect("land path prefix"); + let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma"); + + assert_eq!(prefix, doc.land_path); + } + + #[test] + fn lp_string_does_not_consume_implicit_nul() { + let mut bytes = Vec::new(); + push_u32(&mut bytes, FORMAT_VERSION); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, CLAN_SECTION_VERSION); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, OBJECT_SECTION_VERSION); + push_u32(&mut bytes, 0); + push_lp(&mut bytes, b"A\0B"); + push_u32(&mut bytes, 0x55aa); + push_lp(&mut bytes, b""); + push_u32(&mut bytes, EXTRA_SECTION_VERSION); + push_u32(&mut bytes, 0); + + let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma"); + assert_eq!(doc.land_path.raw, b"A\0B"); + assert_eq!(doc.mission_flag, 0x55aa); + } + + #[test] + fn synthetic_standard_clan_and_object_preserve_ordered_properties() { + let mut bytes = Vec::new(); + push_u32(&mut bytes, FORMAT_VERSION); + push_u32(&mut bytes, 1); + push_i32(&mut bytes, 42); + push_u32(&mut bytes, 1); + push_f32(&mut bytes, 1.0); + push_f32(&mut bytes, 2.0); + push_f32(&mut bytes, 3.0); + push_u32(&mut bytes, CLAN_SECTION_VERSION); + push_u32(&mut bytes, 1); + push_lp(&mut bytes, b"Alpha"); + push_i32(&mut bytes, -1); + push_f32(&mut bytes, 10.0); + push_f32(&mut bytes, 20.0); + push_u32(&mut bytes, 1); + push_lp(&mut bytes, b"Scripts\\a"); + push_i32(&mut bytes, 7); + push_lp(&mut bytes, b""); + push_i32(&mut bytes, 8); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, OBJECT_SECTION_VERSION); + push_u32(&mut bytes, 1); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, OBJECT_CLASS_OR_FLAGS); + push_lp(&mut bytes, b"s_tree_04"); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, 0); + for value in [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0] { + push_f32(&mut bytes, value); + } + push_lp(&mut bytes, b"tree_01"); + push_u32(&mut bytes, 0); + push_i32(&mut bytes, -1); + push_i32(&mut bytes, -1); + push_u32(&mut bytes, PROPERTY_SCHEMA_VERSION); + push_u32(&mut bytes, 2); + for name in [b"Life state".as_slice(), b"Life state".as_slice()] { + push_u32(&mut bytes, 1); + push_u32(&mut bytes, 2); + push_u32(&mut bytes, 3); + push_u32(&mut bytes, 4); + push_lp(&mut bytes, name); + } + push_lp(&mut bytes, b"DATA\\MAPS\\Tut_1\\land"); + push_u32(&mut bytes, 0); + push_lp(&mut bytes, b""); + push_u32(&mut bytes, EXTRA_SECTION_VERSION); + push_u32(&mut bytes, 0); + + let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma"); + assert_eq!(doc.paths[0].id, 42); + assert_eq!(doc.clans[0].name.decoded, "Alpha"); + assert_eq!(doc.objects[0].resource_name.decoded, "s_tree_04"); + assert_eq!(doc.objects[0].properties.len(), 2); + assert_eq!(doc.objects[0].properties[0].raw_value, [1, 2, 3, 4]); + assert_eq!(doc.objects[0].properties[0].name.decoded, "Life state"); + } + + #[test] + fn path_ids_retain_nonsequential_order_and_truncated_points_fail() { + let mut bytes = Vec::new(); + push_u32(&mut bytes, FORMAT_VERSION); + push_u32(&mut bytes, 3); + for id in [30, -5, 10] { + push_i32(&mut bytes, id); + push_u32(&mut bytes, 0); + } + push_empty_tail(&mut bytes); + + let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma"); + assert_eq!( + doc.paths.iter().map(|path| path.id).collect::>(), + vec![30, -5, 10] + ); + + let mut truncated = Vec::new(); + push_u32(&mut truncated, FORMAT_VERSION); + push_u32(&mut truncated, 1); + push_i32(&mut truncated, 1); + push_u32(&mut truncated, 1); + assert!(decode_tma(Arc::from(truncated.into_boxed_slice()), TmaProfile::Strict).is_err()); + } + + #[test] + fn clan_modes_one_to_three_and_spatial_mode_zero_decode() { + for mode in 1..=3 { + let mut bytes = Vec::new(); + push_u32(&mut bytes, FORMAT_VERSION); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, CLAN_SECTION_VERSION); + push_u32(&mut bytes, 1); + push_standard_clan(&mut bytes, mode); + push_object_section_and_tail(&mut bytes, 0, b"", &[]); + + let doc = + decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma"); + assert_eq!(doc.clans[0].mode, mode); + assert!(matches!(doc.clans[0].body, ClanBody::Standard { .. })); + } + + let mut bytes = Vec::new(); + push_u32(&mut bytes, FORMAT_VERSION); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, CLAN_SECTION_VERSION); + push_u32(&mut bytes, 1); + push_lp(&mut bytes, b"Spatial"); + push_i32(&mut bytes, -1); + push_f32(&mut bytes, 0.0); + push_f32(&mut bytes, 0.0); + push_u32(&mut bytes, 0); + push_lp(&mut bytes, b"first"); + push_u32(&mut bytes, 1); + push_u32(&mut bytes, 1); + for value in [1.0, 2.0, 3.0, 4.0, 5.0] { + push_f32(&mut bytes, value); + } + push_lp(&mut bytes, b"second"); + push_i32(&mut bytes, 9); + push_u32(&mut bytes, 0); + push_object_section_and_tail(&mut bytes, 0, b"", &[]); + + let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma"); + let ClanBody::Spatial { spatial_groups, .. } = &doc.clans[0].body else { + panic!("spatial body"); + }; + assert_eq!(spatial_groups[0].records[0], [1.0, 2.0, 3.0, 4.0, 5.0]); + } + + #[test] + fn unknown_clan_mode_nonfinite_transform_and_trailing_bytes_are_rejected() { + let mut unknown_mode = Vec::new(); + push_u32(&mut unknown_mode, FORMAT_VERSION); + push_u32(&mut unknown_mode, 0); + push_u32(&mut unknown_mode, CLAN_SECTION_VERSION); + push_u32(&mut unknown_mode, 1); + push_lp(&mut unknown_mode, b"Bad"); + push_i32(&mut unknown_mode, -1); + push_f32(&mut unknown_mode, 0.0); + push_f32(&mut unknown_mode, 0.0); + push_u32(&mut unknown_mode, 99); + let err = decode_tma( + Arc::from(unknown_mode.into_boxed_slice()), + TmaProfile::Strict, + ) + .expect_err("mode"); + assert!(matches!( + err, + MissionError::UnknownClanMode { mode: 99, .. } + )); + + let mut nonfinite = Vec::new(); + push_u32(&mut nonfinite, FORMAT_VERSION); + push_u32(&mut nonfinite, 0); + push_u32(&mut nonfinite, CLAN_SECTION_VERSION); + push_u32(&mut nonfinite, 0); + push_u32(&mut nonfinite, OBJECT_SECTION_VERSION); + push_u32(&mut nonfinite, 1); + push_object(&mut nonfinite, f32::NAN, &[]); + push_epilogue(&mut nonfinite, b"DATA\\MAPS\\Tut_1\\land", b"", &[]); + let err = decode_tma(Arc::from(nonfinite.into_boxed_slice()), TmaProfile::Strict) + .expect_err("nan"); + assert!(matches!( + err, + MissionError::NonFiniteTransform { object: 0 } + )); + + let mut trailing = minimal_tma_bytes(); + trailing.push(0); + assert!(decode_tma(Arc::from(trailing.into_boxed_slice()), TmaProfile::Strict).is_err()); + } + + #[test] + fn description_and_extras_are_exact_raw_records() { + let mut extra = Vec::new(); + for value in 0_u8..28 { + extra.push(value); + } + let mut bytes = Vec::new(); + push_u32(&mut bytes, FORMAT_VERSION); + push_u32(&mut bytes, 0); + push_empty_tail_with_description(&mut bytes, b"A\x00B", &[extra.as_slice()]); + + let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma"); + assert_eq!(doc.description_raw.raw, b"A\x00B"); + assert_eq!(doc.extras.len(), 1); + assert_eq!(doc.extras[0].raw[27], 27); + + let mut truncated_extra = Vec::new(); + push_u32(&mut truncated_extra, FORMAT_VERSION); + push_u32(&mut truncated_extra, 0); + push_empty_tail_with_description(&mut truncated_extra, b"", &[&extra[..27]]); + assert!(decode_tma( + Arc::from(truncated_extra.into_boxed_slice()), + TmaProfile::Strict + ) + .is_err()); + } + + #[test] + fn signatures_inside_strings_do_not_create_records_and_truncations_are_bounded() { + let mut bytes = Vec::new(); + push_u32(&mut bytes, FORMAT_VERSION); + push_u32(&mut bytes, 0); + push_empty_tail_with_description(&mut bytes, &[1, 0, 0, 0, 6, 0, 0, 0], &[]); + + let doc = decode_tma( + Arc::from(bytes.clone().into_boxed_slice()), + TmaProfile::Strict, + ) + .expect("tma"); + assert!(doc.paths.is_empty()); + assert_eq!(doc.description_raw.raw, [1, 0, 0, 0, 6, 0, 0, 0]); + + for len in 0..bytes.len() { + let _ = decode_tma( + Arc::from(bytes[..len].to_vec().into_boxed_slice()), + TmaProfile::Strict, + ); + } + } + + #[test] + fn generated_valid_documents_and_arbitrary_inputs_are_bounded() { + for seed in 0_u32..64 { + let mut bytes = Vec::new(); + push_u32(&mut bytes, FORMAT_VERSION); + push_u32(&mut bytes, 1); + push_i32(&mut bytes, i32::try_from(seed).expect("seed")); + push_u32(&mut bytes, 1); + push_f32(&mut bytes, seed as f32); + push_f32(&mut bytes, 1.0); + push_f32(&mut bytes, 2.0); + push_empty_tail_with_description(&mut bytes, &[seed as u8, 0, 1], &[]); + + let doc = decode_tma( + Arc::from(bytes.clone().into_boxed_slice()), + TmaProfile::Strict, + ) + .expect("generated"); + assert_eq!(doc.raw.as_ref(), bytes.as_slice()); + assert_eq!(doc.paths[0].id, i32::try_from(seed).expect("seed")); + + let arbitrary = (0..seed % 31) + .map(|offset| seed.wrapping_mul(17).wrapping_add(offset) as u8) + .collect::>(); + let _ = decode_tma(Arc::from(arbitrary.into_boxed_slice()), TmaProfile::Strict); + } + } + + #[test] + fn licensed_corpus_tma_validate() { + for ( + corpus, + expected_files, + expected_paths, + expected_clans, + expected_objects, + expected_extras, + ) in [ + ("IS", 29_usize, 34_usize, 101_usize, 864_usize, 28_usize), + ("IS2", 31_usize, 61_usize, 91_usize, 885_usize, 41_usize), + ] { + let Some(root) = corpus_root(corpus) else { + continue; + }; + let mut files = 0usize; + let mut paths = 0usize; + let mut clans = 0usize; + let mut objects = 0usize; + let mut extras = 0usize; + for path in files_under(&root) { + if !path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("data.tma")) + { + continue; + } + let bytes = std::fs::read(&path).expect("read data.tma"); + let document = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict) + .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + files += 1; + paths += document.paths.len(); + clans += document.clans.len(); + objects += document.objects.len(); + extras += document.extras.len(); + assert_eq!(document.format_version, FORMAT_VERSION, "{corpus} {path:?}"); + assert_eq!( + document.clan_section_version, CLAN_SECTION_VERSION, + "{corpus} {path:?}" + ); + assert_eq!( + document.object_section_version, OBJECT_SECTION_VERSION, + "{corpus} {path:?}" + ); + assert_eq!( + document.extra_section_version, EXTRA_SECTION_VERSION, + "{corpus} {path:?}" + ); + assert!( + document + .land_path + .decoded + .to_ascii_uppercase() + .contains("DATA\\MAPS\\"), + "{corpus} {path:?} land path" + ); + } + + assert_eq!(files, expected_files, "{corpus} TMA count"); + assert_eq!(paths, expected_paths, "{corpus} path count"); + assert_eq!(clans, expected_clans, "{corpus} clan count"); + assert_eq!(objects, expected_objects, "{corpus} object count"); + assert_eq!(extras, expected_extras, "{corpus} extra count"); + } + } + + fn push_lp(out: &mut Vec, bytes: &[u8]) { + push_u32(out, u32::try_from(bytes.len()).expect("lp len")); + out.extend_from_slice(bytes); + } + + fn push_u32(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); + } + + fn push_i32(out: &mut Vec, value: i32) { + out.extend_from_slice(&value.to_le_bytes()); + } + + fn push_f32(out: &mut Vec, value: f32) { + out.extend_from_slice(&value.to_le_bytes()); + } + + fn minimal_tma_bytes() -> Vec { + let mut bytes = Vec::new(); + push_u32(&mut bytes, FORMAT_VERSION); + push_u32(&mut bytes, 0); + push_empty_tail(&mut bytes); + bytes + } + + fn push_empty_tail(out: &mut Vec) { + push_empty_tail_with_description(out, b"", &[]); + } + + fn push_empty_tail_with_description(out: &mut Vec, description: &[u8], extras: &[&[u8]]) { + push_u32(out, CLAN_SECTION_VERSION); + push_u32(out, 0); + push_object_section_and_tail(out, 0, description, extras); + } + + fn push_object_section_and_tail( + out: &mut Vec, + object_count: u32, + description: &[u8], + extras: &[&[u8]], + ) { + push_u32(out, OBJECT_SECTION_VERSION); + push_u32(out, object_count); + push_epilogue(out, b"DATA\\MAPS\\Tut_1\\land", description, extras); + } + + fn push_epilogue(out: &mut Vec, land_path: &[u8], description: &[u8], extras: &[&[u8]]) { + push_lp(out, land_path); + push_u32(out, 0); + push_lp(out, description); + push_u32(out, EXTRA_SECTION_VERSION); + push_u32(out, u32::try_from(extras.len()).expect("extra count")); + for extra in extras { + out.extend_from_slice(extra); + } + } + + fn push_standard_clan(out: &mut Vec, mode: u32) { + push_lp(out, b"Clan"); + push_i32(out, -1); + push_f32(out, 0.0); + push_f32(out, 0.0); + push_u32(out, mode); + push_lp(out, b"first"); + push_i32(out, 1); + push_lp(out, b"second"); + push_i32(out, 2); + push_u32(out, 0); + } + + fn push_object(out: &mut Vec, first_position: f32, properties: &[(&[u8], [u32; 4])]) { + push_u32(out, 0); + push_u32(out, OBJECT_CLASS_OR_FLAGS); + push_lp(out, b"s_tree_04"); + push_u32(out, 0); + push_u32(out, 0); + for value in [first_position, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0] { + push_f32(out, value); + } + push_lp(out, b"tree_01"); + push_u32(out, 0); + push_i32(out, -1); + push_i32(out, -1); + push_u32(out, PROPERTY_SCHEMA_VERSION); + push_u32( + out, + u32::try_from(properties.len()).expect("property count"), + ); + for (name, raw) in properties { + for value in raw { + push_u32(out, *value); + } + push_lp(out, name); + } + } + + fn corpus_root(name: &str) -> Option { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("testdata") + .join(name); + root.is_dir().then_some(root) + } + + fn files_under(root: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(path) = stack.pop() { + let Ok(read_dir) = std::fs::read_dir(path) else { + continue; + }; + for entry in read_dir.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + out.push(path); + } + } + } + out.sort(); + out + } +} diff --git a/crates/fparkan-msh/Cargo.toml b/crates/fparkan-msh/Cargo.toml new file mode 100644 index 0000000..01cd53b --- /dev/null +++ b/crates/fparkan-msh/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "fparkan-msh" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +encoding_rs = "0.8" +fparkan-nres = { path = "../fparkan-nres" } + +[dev-dependencies] +fparkan-animation = { path = "../fparkan-animation" } + +[lints] +workspace = true diff --git a/crates/fparkan-msh/src/lib.rs b/crates/fparkan-msh/src/lib.rs new file mode 100644 index 0000000..f06c8d6 --- /dev/null +++ b/crates/fparkan-msh/src/lib.rs @@ -0,0 +1,1767 @@ +#![forbid(unsafe_code)] +//! Stage-3 MSH asset contract. + +use encoding_rs::WINDOWS_1251; +use fparkan_nres::{EntryMeta, NresDocument, NresError}; + +/// Node table stream. +pub const STREAM_NODE_TABLE: u32 = 1; +/// Slot stream. +pub const STREAM_SLOTS: u32 = 2; +/// Position stream. +pub const STREAM_POSITIONS: u32 = 3; +/// Normal stream. +pub const STREAM_NORMALS: u32 = 4; +/// Texture coordinate stream. +pub const STREAM_UV0: u32 = 5; +/// Triangle index stream. +pub const STREAM_INDICES: u32 = 6; +/// Animation key stream. +pub const STREAM_ANIMATION_KEYS: u32 = 8; +/// Node names stream. +pub const STREAM_NAMES: u32 = 10; +/// Batch stream. +pub const STREAM_BATCHES: u32 = 13; +/// Animation frame map stream. +pub const STREAM_ANIMATION_FRAME_MAP: u32 = 19; + +const REQUIRED_STREAMS: &[(u32, &str)] = &[ + (STREAM_NODE_TABLE, "Res1"), + (STREAM_SLOTS, "Res2"), + (STREAM_POSITIONS, "Res3"), + (STREAM_INDICES, "Res6"), + (STREAM_BATCHES, "Res13"), +]; + +/// MSH document backed by a lossless nested `NRes` archive. +#[derive(Clone, Debug)] +pub struct MshDocument { + nres: NresDocument, + streams: Vec, +} + +/// Stream descriptor in original archive order. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StreamDescriptor { + /// Stream type identifier. + pub type_id: u32, + /// Opaque stream attributes. + pub attributes: EntryAttributes, + /// Raw stream name bytes before the first NUL terminator. + pub name: Vec, + /// Payload size in bytes. + pub size: u32, +} + +/// Opaque `NRes` entry attributes preserved for roundtrip. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct EntryAttributes { + /// Opaque attribute 1. + pub attr1: u32, + /// Opaque attribute 2. + pub attr2: u32, + /// Opaque attribute 3. + pub attr3: u32, +} + +/// MSH variant id. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct MshVariantId(pub u32); + +/// Validated model asset. +#[derive(Clone, Debug, PartialEq)] +pub struct ModelAsset { + /// Original node stride. + pub node_stride: usize, + /// Number of nodes. + pub node_count: usize, + /// Raw node table. + pub nodes_raw: Vec, + /// Slot table. + pub slots: Vec, + /// Vertex positions. + pub positions: Vec<[f32; 3]>, + /// Optional normals. + pub normals: Option>, + /// Optional texture coordinates. + pub uv0: Option>, + /// Triangle indices. + pub indices: Vec, + /// Draw batches. + pub batches: Vec, + /// Optional decoded node names. + pub node_names: Option>>, +} + +/// Node id. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct NodeId(pub u32); + +/// Slot id. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SlotId(pub u32); + +/// Raw node view. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Node { + /// Raw node bytes. + pub raw: Vec, +} + +/// Slot descriptor. +#[derive(Clone, Debug, PartialEq)] +pub struct Slot { + /// First triangle descriptor. + pub tri_start: u16, + /// Triangle descriptor count. + pub tri_count: u16, + /// First batch index. + pub batch_start: u16, + /// Batch count. + pub batch_count: u16, + /// AABB minimum. + pub aabb_min: [f32; 3], + /// AABB maximum. + pub aabb_max: [f32; 3], + /// Bounding sphere center. + pub sphere_center: [f32; 3], + /// Bounding sphere radius. + pub sphere_radius: f32, + /// Opaque slot tail. + pub opaque: [u32; 5], +} + +/// Draw batch. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Batch { + /// Batch flags. + pub batch_flags: u16, + /// Material index. + pub material_index: u16, + /// Opaque field. + pub opaque4: u16, + /// Opaque field. + pub opaque6: u16, + /// Index count. + pub index_count: u16, + /// First index offset. + pub index_start: u32, + /// Opaque field. + pub opaque14: u16, + /// Base vertex. + pub base_vertex: u32, +} + +/// Preserved triangle descriptor stream marker. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TriangleDescriptor; + +/// Vertex stream view. +#[derive(Clone, Debug, PartialEq)] +pub struct VertexStreams { + /// Vertex positions. + pub positions: Vec<[f32; 3]>, + /// Optional normals. + pub normals: Option>, + /// Optional texture coordinates. + pub uv0: Option>, +} + +/// Preserved non-core stream. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreservedStream { + /// Stream type id. + pub type_id: u32, + /// Stream attributes. + pub attributes: EntryAttributes, + /// Original payload bytes. + pub bytes: std::sync::Arc<[u8]>, +} + +/// LOD id. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Lod(pub u8); + +/// Group id. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Group(pub u8); + +/// MSH decode or validation error. +#[derive(Debug)] +pub enum MshError { + /// Nested `NRes` error. + Nres(NresError), + /// Required stream is absent. + MissingStream { + /// Stream type id. + type_id: u32, + /// Human-readable stream label. + label: &'static str, + }, + /// Required stream appears more than once. + DuplicateStream { + /// Stream type id. + type_id: u32, + /// Human-readable stream label. + label: &'static str, + }, + /// Legacy compatibility backend rejected the geometry. + InvalidGeometry(String), + /// Slot id is outside the validated model. + SlotOutOfBounds { + /// Requested slot id. + slot: u32, + /// Slot count. + slot_count: usize, + }, + /// Batch range is outside the validated model. + BatchRangeOutOfBounds { + /// First requested batch. + start: usize, + /// Exclusive end. + end: usize, + /// Batch count. + batch_count: usize, + }, + /// Batch references a vertex outside position stream. + VertexIndexOutOfBounds { + /// Batch index. + batch: usize, + /// Resolved vertex index. + vertex: u64, + /// Position count. + position_count: usize, + }, + /// Non-finite or inverted bounds. + InvalidBounds { + /// Slot index. + slot: usize, + }, +} + +impl From for MshError { + fn from(value: NresError) -> Self { + Self::Nres(value) + } +} + +impl std::fmt::Display for MshError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Nres(source) => write!(f, "{source}"), + Self::MissingStream { type_id, label } => { + write!(f, "missing MSH stream {label} ({type_id})") + } + Self::DuplicateStream { type_id, label } => { + write!(f, "duplicate MSH stream {label} ({type_id})") + } + Self::InvalidGeometry(message) => write!(f, "{message}"), + Self::SlotOutOfBounds { slot, slot_count } => { + write!(f, "slot {slot} is outside slot table of {slot_count}") + } + Self::BatchRangeOutOfBounds { + start, + end, + batch_count, + } => write!( + f, + "batch range {start}..{end} is outside batch table of {batch_count}" + ), + Self::VertexIndexOutOfBounds { + batch, + vertex, + position_count, + } => write!( + f, + "batch {batch} references vertex {vertex}, position_count={position_count}" + ), + Self::InvalidBounds { slot } => write!(f, "slot {slot} has invalid bounds"), + } + } +} + +impl std::error::Error for MshError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Nres(source) => Some(source), + Self::MissingStream { .. } + | Self::DuplicateStream { .. } + | Self::InvalidGeometry(_) + | Self::SlotOutOfBounds { .. } + | Self::BatchRangeOutOfBounds { .. } + | Self::VertexIndexOutOfBounds { .. } + | Self::InvalidBounds { .. } => None, + } + } +} + +/// Decodes a nested MSH `NRes` document. +/// +/// # Errors +/// +/// Returns [`MshError`] when required streams are absent or duplicated. +pub fn decode_msh(document: &NresDocument) -> Result { + for (type_id, label) in REQUIRED_STREAMS { + let count = document + .entries() + .iter() + .filter(|entry| entry.meta().type_id == *type_id) + .count(); + if count == 0 { + return Err(MshError::MissingStream { + type_id: *type_id, + label, + }); + } + if count > 1 { + return Err(MshError::DuplicateStream { + type_id: *type_id, + label, + }); + } + } + + let streams = document + .entries() + .iter() + .map(|entry| stream_descriptor(entry.meta(), entry.name_bytes())) + .collect(); + + Ok(MshDocument { + nres: document.clone(), + streams, + }) +} + +/// Validates static geometry and returns a backend-neutral model asset. +/// +/// # Errors +/// +/// Returns [`MshError`] when stream sizes, slot ranges, batch ranges, bounds, +/// or indexed vertices are invalid. +pub fn validate_msh(document: &MshDocument) -> Result { + let model = parse_model_document(&document.nres)?; + validate_bounds(&model)?; + validate_vertex_indices(&model)?; + Ok(model) +} + +/// Returns the selected slot for a node/lod/group tuple. +#[must_use] +pub fn selected_slot(model: &ModelAsset, node: NodeId, lod: Lod, group: Group) -> Option { + if model.node_stride != 38 || lod.0 >= 3 || group.0 >= 5 { + return None; + } + let node_index = usize::try_from(node.0).ok()?; + if node_index >= model.node_count { + return None; + } + let node_off = node_index.checked_mul(model.node_stride)?; + let slot_off = node_off + .checked_add(8)? + .checked_add((usize::from(lod.0) * 5 + usize::from(group.0)) * 2)?; + let raw = read_u16(&model.nodes_raw, slot_off)?; + if raw == u16::MAX { + return None; + } + let slot = usize::from(raw); + (slot < model.slots.len()).then_some(SlotId(u32::from(raw))) +} + +/// Returns draw batches for a validated slot. +/// +/// # Errors +/// +/// Returns [`MshError`] when the slot id or its batch range is invalid. +pub fn draw_batches(model: &ModelAsset, slot: SlotId) -> Result<&[Batch], MshError> { + let slot_index = usize::try_from(slot.0).map_err(|_| MshError::SlotOutOfBounds { + slot: slot.0, + slot_count: model.slots.len(), + })?; + let slot_ref = model + .slots + .get(slot_index) + .ok_or(MshError::SlotOutOfBounds { + slot: slot.0, + slot_count: model.slots.len(), + })?; + let start = usize::from(slot_ref.batch_start); + let end = start.checked_add(usize::from(slot_ref.batch_count)).ok_or( + MshError::BatchRangeOutOfBounds { + start, + end: usize::MAX, + batch_count: model.batches.len(), + }, + )?; + model + .batches + .get(start..end) + .ok_or(MshError::BatchRangeOutOfBounds { + start, + end, + batch_count: model.batches.len(), + }) +} + +impl MshDocument { + /// Returns original stream descriptors. + #[must_use] + pub fn streams(&self) -> &[StreamDescriptor] { + &self.streams + } + + /// Returns the recognized MSH variant id. + #[must_use] + pub fn variant_id(&self) -> MshVariantId { + if self + .streams + .iter() + .any(|stream| stream.name.eq_ignore_ascii_case(b"MTCHECK")) + { + MshVariantId(1) + } else { + MshVariantId(0) + } + } + + /// Returns preserved non-core streams. + /// + /// # Errors + /// + /// Returns [`MshError`] when the underlying `NRes` payload lookup fails. + pub fn preserved_streams(&self) -> Result, MshError> { + let mut preserved = Vec::new(); + for entry in self.nres.entries() { + let type_id = entry.meta().type_id; + if REQUIRED_STREAMS + .iter() + .any(|(required, _)| *required == type_id) + { + continue; + } + preserved.push(PreservedStream { + type_id, + attributes: attributes(entry.meta()), + bytes: std::sync::Arc::from( + self.nres.payload(entry.id())?.to_vec().into_boxed_slice(), + ), + }); + } + Ok(preserved) + } +} + +fn stream_descriptor(meta: &EntryMeta, name: &[u8]) -> StreamDescriptor { + StreamDescriptor { + type_id: meta.type_id, + attributes: attributes(meta), + name: name.to_vec(), + size: meta.data_size, + } +} + +fn attributes(meta: &EntryMeta) -> EntryAttributes { + EntryAttributes { + attr1: meta.attr1, + attr2: meta.attr2, + attr3: meta.attr3, + } +} + +fn parse_model_document(document: &NresDocument) -> Result { + let nodes_stream = read_required_stream(document, STREAM_NODE_TABLE, "Res1")?; + let slots_stream = read_required_stream(document, STREAM_SLOTS, "Res2")?; + let positions_stream = read_required_stream(document, STREAM_POSITIONS, "Res3")?; + let indices_stream = read_required_stream(document, STREAM_INDICES, "Res6")?; + let batches_stream = read_required_stream(document, STREAM_BATCHES, "Res13")?; + + let node_stride = usize::try_from(nodes_stream.attributes.attr3) + .map_err(|_| MshError::InvalidGeometry("MSH node stride does not fit usize".to_string()))?; + if node_stride != 38 && node_stride != 24 { + return Err(MshError::InvalidGeometry(format!( + "unsupported MSH node stride: {node_stride}" + ))); + } + if !nodes_stream.bytes.len().is_multiple_of(node_stride) { + return Err(invalid_resource_size( + "Res1", + nodes_stream.bytes.len(), + node_stride, + )); + } + let node_count = nodes_stream.bytes.len() / node_stride; + + let slots = parse_slots(&slots_stream.bytes)?; + let positions = parse_positions(&positions_stream.bytes)?; + let indices = parse_u16_array(&indices_stream.bytes, "Res6")?; + let batches = parse_batches(&batches_stream.bytes)?; + validate_slot_batch_ranges(&slots, batches.len())?; + validate_batch_index_ranges(&batches, indices.len())?; + + let normals = read_optional_stream(document, STREAM_NORMALS)? + .map(|raw| parse_i8x4_array(&raw.bytes, "Res4")) + .transpose()?; + let uv0 = read_optional_stream(document, STREAM_UV0)? + .map(|raw| parse_i16x2_array(&raw.bytes, "Res5")) + .transpose()?; + let node_names = read_optional_stream(document, STREAM_NAMES)? + .map(|raw| parse_res10_names(&raw.bytes, node_count)) + .transpose()?; + + Ok(ModelAsset { + node_stride, + node_count, + nodes_raw: nodes_stream.bytes, + slots, + positions, + normals, + uv0, + indices, + batches, + node_names, + }) +} + +struct RawStream { + attributes: EntryAttributes, + bytes: Vec, +} + +fn read_required_stream( + document: &NresDocument, + type_id: u32, + label: &'static str, +) -> Result { + let entry = document + .entries() + .iter() + .find(|entry| entry.meta().type_id == type_id) + .ok_or(MshError::MissingStream { type_id, label })?; + Ok(RawStream { + attributes: attributes(entry.meta()), + bytes: document.payload(entry.id())?.to_vec(), + }) +} + +fn read_optional_stream( + document: &NresDocument, + type_id: u32, +) -> Result, MshError> { + let Some(entry) = document + .entries() + .iter() + .find(|entry| entry.meta().type_id == type_id) + else { + return Ok(None); + }; + Ok(Some(RawStream { + attributes: attributes(entry.meta()), + bytes: document.payload(entry.id())?.to_vec(), + })) +} + +fn parse_slots(data: &[u8]) -> Result, MshError> { + if data.len() < 0x8C { + return Err(MshError::InvalidGeometry(format!( + "invalid Res2 size: {}", + data.len() + ))); + } + let slot_bytes = data + .len() + .checked_sub(0x8C) + .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?; + if !slot_bytes.is_multiple_of(68) { + return Err(invalid_resource_size("Res2.slots", slot_bytes, 68)); + } + let count = slot_bytes / 68; + let mut slots = Vec::with_capacity(count); + for index in 0..count { + let offset = 0x8Cusize + .checked_add( + index + .checked_mul(68) + .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?, + ) + .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?; + slots.push(Slot { + tri_start: read_u16_required(data, offset)?, + tri_count: read_u16_required(data, offset + 2)?, + batch_start: read_u16_required(data, offset + 4)?, + batch_count: read_u16_required(data, offset + 6)?, + aabb_min: [ + read_f32(data, offset + 8)?, + read_f32(data, offset + 12)?, + read_f32(data, offset + 16)?, + ], + aabb_max: [ + read_f32(data, offset + 20)?, + read_f32(data, offset + 24)?, + read_f32(data, offset + 28)?, + ], + sphere_center: [ + read_f32(data, offset + 32)?, + read_f32(data, offset + 36)?, + read_f32(data, offset + 40)?, + ], + sphere_radius: read_f32(data, offset + 44)?, + opaque: [ + read_u32(data, offset + 48)?, + read_u32(data, offset + 52)?, + read_u32(data, offset + 56)?, + read_u32(data, offset + 60)?, + read_u32(data, offset + 64)?, + ], + }); + } + Ok(slots) +} + +fn parse_positions(data: &[u8]) -> Result, MshError> { + if !data.len().is_multiple_of(12) { + return Err(invalid_resource_size("Res3", data.len(), 12)); + } + let mut out = Vec::with_capacity(data.len() / 12); + for offset in (0..data.len()).step_by(12) { + out.push([ + read_f32(data, offset)?, + read_f32(data, offset + 4)?, + read_f32(data, offset + 8)?, + ]); + } + Ok(out) +} + +fn parse_batches(data: &[u8]) -> Result, MshError> { + if !data.len().is_multiple_of(20) { + return Err(invalid_resource_size("Res13", data.len(), 20)); + } + let mut out = Vec::with_capacity(data.len() / 20); + for offset in (0..data.len()).step_by(20) { + out.push(Batch { + batch_flags: read_u16_required(data, offset)?, + material_index: read_u16_required(data, offset + 2)?, + opaque4: read_u16_required(data, offset + 4)?, + opaque6: read_u16_required(data, offset + 6)?, + index_count: read_u16_required(data, offset + 8)?, + index_start: read_u32(data, offset + 10)?, + opaque14: read_u16_required(data, offset + 14)?, + base_vertex: read_u32(data, offset + 16)?, + }); + } + Ok(out) +} + +fn parse_u16_array(data: &[u8], label: &'static str) -> Result, MshError> { + if !data.len().is_multiple_of(2) { + return Err(invalid_resource_size(label, data.len(), 2)); + } + let mut out = Vec::with_capacity(data.len() / 2); + for offset in (0..data.len()).step_by(2) { + out.push(read_u16_required(data, offset)?); + } + Ok(out) +} + +fn parse_i8x4_array(data: &[u8], label: &'static str) -> Result, MshError> { + if !data.len().is_multiple_of(4) { + return Err(invalid_resource_size(label, data.len(), 4)); + } + let mut out = Vec::with_capacity(data.len() / 4); + for offset in (0..data.len()).step_by(4) { + out.push([ + read_i8(data, offset)?, + read_i8(data, offset + 1)?, + read_i8(data, offset + 2)?, + read_i8(data, offset + 3)?, + ]); + } + Ok(out) +} + +fn parse_i16x2_array(data: &[u8], label: &'static str) -> Result, MshError> { + if !data.len().is_multiple_of(4) { + return Err(invalid_resource_size(label, data.len(), 4)); + } + let mut out = Vec::with_capacity(data.len() / 4); + for offset in (0..data.len()).step_by(4) { + out.push([read_i16(data, offset)?, read_i16(data, offset + 2)?]); + } + Ok(out) +} + +fn parse_res10_names(data: &[u8], node_count: usize) -> Result>, MshError> { + let mut out = Vec::with_capacity(node_count); + let mut offset = 0usize; + for _ in 0..node_count { + let len = usize::try_from(read_u32(data, offset)?) + .map_err(|_| MshError::InvalidGeometry("integer overflow".to_string()))?; + offset = offset + .checked_add(4) + .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?; + if len == 0 { + out.push(None); + continue; + } + let need = len + .checked_add(1) + .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?; + let end = offset + .checked_add(need) + .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?; + let slice = data + .get(offset..end) + .ok_or_else(|| invalid_resource_size("Res10", data.len(), 1))?; + let text = if slice.last().copied() == Some(0) { + &slice[..slice.len().saturating_sub(1)] + } else { + slice + }; + let (decoded, _, _) = WINDOWS_1251.decode(text); + out.push(Some(decoded.into_owned())); + offset = end; + } + Ok(out) +} + +fn validate_slot_batch_ranges(slots: &[Slot], batch_count: usize) -> Result<(), MshError> { + for slot in slots { + let start = usize::from(slot.batch_start); + let end = start + .checked_add(usize::from(slot.batch_count)) + .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?; + if end > batch_count { + return Err(MshError::BatchRangeOutOfBounds { + start, + end, + batch_count, + }); + } + } + Ok(()) +} + +fn validate_batch_index_ranges(batches: &[Batch], index_count: usize) -> Result<(), MshError> { + for (batch_index, batch) in batches.iter().enumerate() { + let start = usize::try_from(batch.index_start) + .map_err(|_| MshError::InvalidGeometry("integer overflow".to_string()))?; + let end = start + .checked_add(usize::from(batch.index_count)) + .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?; + if end > index_count { + return Err(MshError::VertexIndexOutOfBounds { + batch: batch_index, + vertex: u64::try_from(end).unwrap_or(u64::MAX), + position_count: index_count, + }); + } + } + Ok(()) +} + +fn invalid_resource_size(label: &'static str, size: usize, stride: usize) -> MshError { + MshError::InvalidGeometry(format!( + "invalid {label} size: size={size}, stride={stride}" + )) +} + +fn validate_bounds(model: &ModelAsset) -> Result<(), MshError> { + for (index, slot) in model.slots.iter().enumerate() { + let ordered = slot + .aabb_min + .iter() + .zip(slot.aabb_max.iter()) + .all(|(min, max)| min.is_finite() && max.is_finite() && min <= max); + let sphere = slot.sphere_center.iter().all(|value| value.is_finite()) + && slot.sphere_radius.is_finite() + && slot.sphere_radius >= 0.0; + if !ordered || !sphere { + return Err(MshError::InvalidBounds { slot: index }); + } + } + Ok(()) +} + +fn validate_vertex_indices(model: &ModelAsset) -> Result<(), MshError> { + let position_count = + u64::try_from(model.positions.len()).map_err(|_| MshError::VertexIndexOutOfBounds { + batch: usize::MAX, + vertex: u64::MAX, + position_count: model.positions.len(), + })?; + for (batch_index, batch) in model.batches.iter().enumerate() { + let start = + usize::try_from(batch.index_start).map_err(|_| MshError::VertexIndexOutOfBounds { + batch: batch_index, + vertex: u64::MAX, + position_count: model.positions.len(), + })?; + let end = start.checked_add(usize::from(batch.index_count)).ok_or( + MshError::VertexIndexOutOfBounds { + batch: batch_index, + vertex: u64::MAX, + position_count: model.positions.len(), + }, + )?; + for raw in &model.indices[start..end] { + let vertex = u64::from(batch.base_vertex) + u64::from(*raw); + if vertex >= position_count { + return Err(MshError::VertexIndexOutOfBounds { + batch: batch_index, + vertex, + position_count: model.positions.len(), + }); + } + } + } + Ok(()) +} + +fn read_u16(bytes: &[u8], offset: usize) -> Option { + let raw = bytes.get(offset..offset.checked_add(2)?)?; + let arr: [u8; 2] = raw.try_into().ok()?; + Some(u16::from_le_bytes(arr)) +} + +fn read_u16_required(bytes: &[u8], offset: usize) -> Result { + let raw = bytes + .get(offset..offset.saturating_add(2)) + .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?; + let arr: [u8; 2] = raw + .try_into() + .map_err(|_| MshError::InvalidGeometry("integer overflow".to_string()))?; + Ok(u16::from_le_bytes(arr)) +} + +fn read_i16(bytes: &[u8], offset: usize) -> Result { + let raw = bytes + .get(offset..offset.saturating_add(2)) + .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?; + let arr: [u8; 2] = raw + .try_into() + .map_err(|_| MshError::InvalidGeometry("integer overflow".to_string()))?; + Ok(i16::from_le_bytes(arr)) +} + +fn read_i8(bytes: &[u8], offset: usize) -> Result { + let byte = bytes + .get(offset) + .copied() + .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?; + Ok(i8::from_le_bytes([byte])) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Result { + let raw = bytes + .get(offset..offset.saturating_add(4)) + .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?; + let arr: [u8; 4] = raw + .try_into() + .map_err(|_| MshError::InvalidGeometry("integer overflow".to_string()))?; + Ok(u32::from_le_bytes(arr)) +} + +fn read_f32(bytes: &[u8], offset: usize) -> Result { + Ok(f32::from_bits(read_u32(bytes, offset)?)) +} + +/// Returns migration status. +#[must_use] +pub fn migration_facade_ready() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_animation::{ + canonical_timed_pose_capture, AnimKey24, AnimationTime, TimedPoseKey, TimedPoseTrack, + }; + use fparkan_nres::ReadProfile; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + + #[test] + fn validates_minimal_msh_document() { + let document = decode_nested(&minimal_msh_bytes()).expect("nested NRes"); + let msh = decode_msh(&document).expect("msh document"); + let model = validate_msh(&msh).expect("model"); + + assert_eq!(model.node_stride, 38); + assert_eq!(model.node_count, 0); + assert!(model.slots.is_empty()); + } + + #[test] + fn missing_required_stream_is_error() { + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &[0; 0x8c]), + ])) + .expect("nested NRes"); + + let err = decode_msh(&document).expect_err("missing stream"); + assert!(matches!( + err, + MshError::MissingStream { + type_id: STREAM_POSITIONS, + .. + } + )); + } + + #[test] + fn base_vertex_plus_index_must_reference_position() { + let mut indices = Vec::new(); + indices.extend_from_slice(&1_u16.to_le_bytes()); + let mut batch = Vec::new(); + push_u16(&mut batch, 0); + push_u16(&mut batch, 0); + push_u16(&mut batch, 0); + push_u16(&mut batch, 0); + push_u16(&mut batch, 1); + push_u32(&mut batch, 0); + push_u16(&mut batch, 0); + push_u32(&mut batch, 0); + let bytes = build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &[0; 0x8c]), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &indices), + stream(STREAM_BATCHES, 0, b"Res13", &batch), + ]); + let document = decode_nested(&bytes).expect("nested NRes"); + let msh = decode_msh(&document).expect("msh document"); + + let err = validate_msh(&msh).expect_err("invalid vertex"); + assert!(matches!(err, MshError::VertexIndexOutOfBounds { .. })); + } + + #[test] + fn canonical_stream_set_is_independent_of_entry_order() { + let slots = slots_payload(&[]); + let ordered = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + ])) + .expect("ordered"); + let reversed = decode_nested(&build_nres(&[ + stream(STREAM_BATCHES, 0, b"Res13", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots), + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + ])) + .expect("reversed"); + + assert_eq!( + validate_msh(&decode_msh(&ordered).expect("ordered msh")).expect("ordered model"), + validate_msh(&decode_msh(&reversed).expect("reversed msh")).expect("reversed model") + ); + } + + #[test] + fn duplicate_required_stream_type_is_error() { + let slots = slots_payload(&[]); + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_NODE_TABLE, 38, b"Res1Dup", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + ])) + .expect("nested NRes"); + + assert!(matches!( + decode_msh(&document), + Err(MshError::DuplicateStream { + type_id: STREAM_NODE_TABLE, + .. + }) + )); + } + + #[test] + fn node38_stride_is_exact() { + let slots = slots_payload(&[]); + let valid_node = node38([u16::MAX; 15]); + let valid = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &valid_node), + stream(STREAM_SLOTS, 0, b"Res2", &slots), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + ])) + .expect("valid"); + let model = validate_msh(&decode_msh(&valid).expect("msh")).expect("model"); + assert_eq!(model.node_stride, 38); + assert_eq!(model.node_count, 1); + + let invalid = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &valid_node[..37]), + stream(STREAM_SLOTS, 0, b"Res2", &slots), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + ])) + .expect("invalid"); + let err = validate_msh(&decode_msh(&invalid).expect("msh")).expect_err("stride"); + assert!(matches!(err, MshError::InvalidGeometry(_))); + } + + #[test] + fn node38_uses_three_by_five_slot_mapping_and_absent_marker() { + let mut mapping = [u16::MAX; 15]; + mapping[0] = 0; + mapping[7] = 1; + let node = node38(mapping); + let slots = slots_payload(&[ + slot_record(0, 0, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0], 1.0), + slot_record(0, 0, [0.0, 0.0, 0.0], [2.0, 2.0, 2.0], 1.0), + ]); + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &node), + stream(STREAM_SLOTS, 0, b"Res2", &slots), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + ])) + .expect("nested"); + let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model"); + + assert_eq!( + selected_slot(&model, NodeId(0), Lod(0), Group(0)), + Some(SlotId(0)) + ); + assert_eq!( + selected_slot(&model, NodeId(0), Lod(1), Group(2)), + Some(SlotId(1)) + ); + assert_eq!(selected_slot(&model, NodeId(0), Lod(2), Group(4)), None); + } + + #[test] + fn type2_header_and_slot_tail_framing_are_exact() { + let too_small = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &[0; 0x8b]), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + ])) + .expect("nested"); + let err = validate_msh(&decode_msh(&too_small).expect("msh")).expect_err("header"); + assert!(matches!(err, MshError::InvalidGeometry(_))); + + let not_divisible = vec![0; 0x8c + 67]; + let bad_tail = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", ¬_divisible), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + ])) + .expect("nested"); + let err = validate_msh(&decode_msh(&bad_tail).expect("msh")).expect_err("tail"); + assert!(matches!(err, MshError::InvalidGeometry(_))); + } + + #[test] + fn slot_batch_range_out_of_bounds_is_error() { + let slots = slots_payload(&[slot_record(1, 1, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0], 1.0)]); + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + ])) + .expect("nested"); + + assert!(matches!( + validate_msh(&decode_msh(&document).expect("msh")), + Err(MshError::BatchRangeOutOfBounds { + start: 1, + end: 2, + batch_count: 0 + }) + )); + } + + #[test] + fn vertex_stream_strides_are_exact() { + for (stream_type, name, payload) in [ + (STREAM_POSITIONS, b"Res3".as_slice(), vec![0; 11]), + (STREAM_NORMALS, b"Res4".as_slice(), vec![0; 3]), + (STREAM_UV0, b"Res5".as_slice(), vec![0; 3]), + (STREAM_INDICES, b"Res6".as_slice(), vec![0; 1]), + ] { + let slots = slots_payload(&[]); + let mut entries = vec![ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + ]; + if stream_type == STREAM_POSITIONS { + entries[2] = stream(stream_type, 0, name, &payload); + } else if stream_type == STREAM_INDICES { + entries[3] = stream(stream_type, 0, name, &payload); + } else { + entries.push(stream(stream_type, 0, name, &payload)); + } + let document = decode_nested(&build_nres(&entries)).expect("nested"); + let err = validate_msh(&decode_msh(&document).expect("msh")).expect_err("stride"); + assert!(matches!(err, MshError::InvalidGeometry(_))); + } + } + + #[test] + fn batch20_uses_unaligned_field_offsets() { + let positions = positions_payload(&[[0.0, 0.0, 0.0]]); + let mut indices = Vec::new(); + push_u16(&mut indices, 0); + let batch = batch_record(0x1100, 0x2200, 0x3300, 0x4400, 1, 0, 0x5500, 0); + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])), + stream(STREAM_POSITIONS, 0, b"Res3", &positions), + stream(STREAM_INDICES, 0, b"Res6", &indices), + stream(STREAM_BATCHES, 0, b"Res13", &batch), + ])) + .expect("nested"); + let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model"); + + assert_eq!(model.batches[0].batch_flags, 0x1100); + assert_eq!(model.batches[0].material_index, 0x2200); + assert_eq!(model.batches[0].opaque4, 0x3300); + assert_eq!(model.batches[0].opaque6, 0x4400); + assert_eq!(model.batches[0].index_count, 1); + assert_eq!(model.batches[0].index_start, 0); + assert_eq!(model.batches[0].opaque14, 0x5500); + assert_eq!(model.batches[0].base_vertex, 0); + } + + #[test] + fn auxiliary_and_extended_streams_are_preserved() { + let aux = [1, 2, 3, 4]; + let ext18 = [5, 6]; + let ext20 = [7, 8, 9]; + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + stream(99, 0, b"Aux", &aux), + stream(18, 0, b"Res18", &ext18), + stream(20, 0, b"Res20", &ext20), + ])) + .expect("nested"); + let msh = decode_msh(&document).expect("msh"); + let preserved = msh.preserved_streams().expect("preserved"); + + assert_eq!( + preserved + .iter() + .map(|stream| (stream.type_id, stream.bytes.as_ref().to_vec())) + .collect::>(), + vec![ + (99, aux.to_vec()), + (18, ext18.to_vec()), + (20, ext20.to_vec()) + ] + ); + } + + #[test] + fn mtcheck_variant_is_preserved_and_recognized() { + let marker = [0x4D, 0x54]; + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + stream(21, 0, b"MTCHECK", &marker), + ])) + .expect("nested"); + let msh = decode_msh(&document).expect("msh"); + + assert_eq!(msh.variant_id(), MshVariantId(1)); + assert_eq!(msh.streams().last().expect("marker").name, b"MTCHECK"); + } + + #[test] + fn invalid_bounds_are_rejected() { + let slots = slots_payload(&[slot_record(0, 0, [2.0, 0.0, 0.0], [1.0, 1.0, 1.0], 1.0)]); + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + ])) + .expect("nested"); + + assert!(matches!( + validate_msh(&decode_msh(&document).expect("msh")), + Err(MshError::InvalidBounds { slot: 0 }) + )); + } + + #[test] + fn arbitrary_nested_payloads_are_bounded_and_panic_free() { + for payload in [ + Vec::new(), + vec![0; 16], + build_nres(&[stream(STREAM_NODE_TABLE, 38, b"Res1", &[1, 2, 3])]), + build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &[0; 0x8b]), + stream(STREAM_POSITIONS, 0, b"Res3", &[1]), + stream(STREAM_INDICES, 0, b"Res6", &[2]), + stream(STREAM_BATCHES, 0, b"Res13", &[3]), + ]), + ] { + if let Ok(document) = decode_nested(&payload) { + let _ = decode_msh(&document).and_then(|msh| validate_msh(&msh).map(|_| ())); + } + } + } + + #[test] + fn licensed_corpus_msh_assets_validate() { + for (corpus, expected) in [("IS", 435_usize), ("IS2", 511_usize)] { + let Some(root) = corpus_root(corpus) else { + continue; + }; + let mut count = 0usize; + for path in files_under(&root) { + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let Ok(archive) = fparkan_nres::decode( + Arc::from(bytes.into_boxed_slice()), + ReadProfile::Compatible, + ) else { + continue; + }; + for entry in archive + .entries() + .iter() + .filter(|entry| has_msh_extension(entry.name_bytes())) + { + let payload = archive.payload(entry.id()).expect("payload"); + let nested = fparkan_nres::decode( + Arc::from(payload.to_vec().into_boxed_slice()), + ReadProfile::Compatible, + ) + .unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) + }); + let msh = decode_msh(&nested).unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) + }); + validate_msh(&msh).unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) + }); + count += 1; + } + } + assert_eq!(count, expected, "{corpus} MSH count"); + } + } + + #[test] + fn licensed_corpus_animation_streams_sample_approved_pose_captures() { + for ( + corpus, + expected_models, + expected_animated_models, + expected_node_samples, + expected_hash, + ) in [ + ( + "IS", + 435_usize, + 157_usize, + 3_469_usize, + 7_119_731_908_371_799_613_u64, + ), + ( + "IS2", + 511_usize, + 200_usize, + 5_233_usize, + 13_040_438_305_408_523_893_u64, + ), + ] { + let Some(root) = corpus_root(corpus) else { + continue; + }; + let mut models = 0usize; + let mut animated_models = 0usize; + let mut node_samples = 0usize; + let mut hash = FNV_OFFSET; + for path in files_under(&root) { + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let Ok(archive) = fparkan_nres::decode( + Arc::from(bytes.into_boxed_slice()), + ReadProfile::Compatible, + ) else { + continue; + }; + for entry in archive + .entries() + .iter() + .filter(|entry| has_msh_extension(entry.name_bytes())) + { + let payload = archive.payload(entry.id()).expect("payload"); + let nested = fparkan_nres::decode( + Arc::from(payload.to_vec().into_boxed_slice()), + ReadProfile::Compatible, + ) + .unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) + }); + let msh = decode_msh(&nested).unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) + }); + let model = validate_msh(&msh).unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) + }); + let preserved = msh.preserved_streams().unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) + }); + let keys_stream = preserved + .iter() + .find(|stream| stream.type_id == STREAM_ANIMATION_KEYS) + .unwrap_or_else(|| { + panic!("{corpus} {path:?} {:?}: missing type 8", entry.name_bytes()) + }); + let frame_map_stream = preserved + .iter() + .find(|stream| stream.type_id == STREAM_ANIMATION_FRAME_MAP) + .unwrap_or_else(|| { + panic!( + "{corpus} {path:?} {:?}: missing type 19", + entry.name_bytes() + ) + }); + if !keys_stream.bytes.len().is_multiple_of(24) + || !frame_map_stream.bytes.len().is_multiple_of(2) + { + panic!( + "{corpus} {path:?} {:?}: invalid animation stream size", + entry.name_bytes() + ); + } + + let keys = decode_anim_keys(&keys_stream.bytes).unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: type 8: {err}", entry.name_bytes()) + }); + let frame_map = decode_frame_map_words(&frame_map_stream.bytes); + let frame_count = usize::try_from(frame_map_stream.attributes.attr2) + .expect("frame count fits usize"); + models += 1; + hash_bytes(&mut hash, entry.name_bytes()); + hash_usize(&mut hash, keys.len()); + hash_usize(&mut hash, frame_map.len()); + + let mut model_is_animated = false; + if model.node_stride == 38 { + for node_index in 0..model.node_count { + let offset = node_index * model.node_stride; + let anim_map_start = + read_u16(&model.nodes_raw, offset + 4).expect("anim map"); + let fallback_key = + read_u16(&model.nodes_raw, offset + 6).expect("fallback key"); + let fallback_index = usize::from(fallback_key); + assert!( + fallback_index < keys.len(), + "{corpus} {path:?} {:?}: fallback key out of range", + entry.name_bytes() + ); + let sample_frames = representative_frames(frame_count, anim_map_start); + if anim_map_start != u16::MAX { + let start = usize::from(anim_map_start); + assert!( + start + .checked_add(frame_count) + .is_some_and(|end| end <= frame_map.len()), + "{corpus} {path:?} {:?}: frame map range out of bounds", + entry.name_bytes() + ); + model_is_animated = true; + } + for frame in sample_frames { + let pose = sample_node_pose( + &keys, + &frame_map, + frame_count, + anim_map_start, + fallback_index, + frame, + ) + .unwrap_or_else(|err| { + let selected = selected_animation_key( + &frame_map, + frame_count, + anim_map_start, + fallback_index, + frame, + ); + let selected_key = &keys[selected]; + let next_key = keys.get(selected.saturating_add(1)); + let fallback_key = &keys[fallback_index]; + panic!( + "{corpus} {path:?} {:?}: node {node_index} frame {frame}: {err}; map_start={anim_map_start} fallback={fallback_index} selected={selected:?} frame_count={frame_count} selected_time={:?} selected_rot={:?} next={:?} fallback_time={:?} fallback_rot={:?}", + entry.name_bytes(), + selected_key.time, + selected_key.pose.rotation, + next_key.map(|key| (key.time, key.pose.rotation)), + fallback_key.time, + fallback_key.pose.rotation + ) + }); + let track = TimedPoseTrack::new( + pose, + vec![TimedPoseKey { + time: AnimationTime(frame as f32), + pose, + }], + ) + .expect("single pose track"); + let capture = canonical_timed_pose_capture( + &track, + &[AnimationTime(frame as f32)], + ) + .expect("pose capture"); + hash_usize(&mut hash, node_index); + hash_usize(&mut hash, frame); + hash_bytes(&mut hash, &capture); + node_samples += 1; + } + } + } + if model_is_animated { + animated_models += 1; + } + } + } + + assert_eq!( + models, expected_models, + "{corpus} animated stream model count" + ); + assert_eq!( + animated_models, expected_animated_models, + "{corpus} animated model count" + ); + assert_eq!(node_samples, expected_node_samples, "{corpus} node samples"); + assert_eq!(hash, expected_hash, "{corpus} animation capture hash"); + } + } + + fn decode_anim_keys(bytes: &[u8]) -> Result, fparkan_animation::AnimationError> { + bytes.chunks_exact(24).map(AnimKey24::decode).collect() + } + + fn decode_frame_map_words(bytes: &[u8]) -> Vec { + bytes + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect() + } + + fn representative_frames(frame_count: usize, anim_map_start: u16) -> Vec { + if anim_map_start == u16::MAX || frame_count == 0 { + return vec![0]; + } + let mut frames = vec![0, frame_count / 2, frame_count - 1]; + frames.sort_unstable(); + frames.dedup(); + frames + } + + fn sample_node_pose( + keys: &[AnimKey24], + frame_map: &[u16], + frame_count: usize, + anim_map_start: u16, + fallback_index: usize, + frame: usize, + ) -> Result { + let key_index = selected_animation_key( + frame_map, + frame_count, + anim_map_start, + fallback_index, + frame, + ); + sample_key_pair(keys, key_index, fallback_index, frame) + } + + fn selected_animation_key( + frame_map: &[u16], + frame_count: usize, + anim_map_start: u16, + fallback_index: usize, + frame: usize, + ) -> usize { + if anim_map_start == u16::MAX || frame >= frame_count { + return fallback_index; + } + let mapped = frame_map[usize::from(anim_map_start) + frame]; + if usize::from(mapped) >= fallback_index { + fallback_index + } else { + usize::from(mapped) + } + } + + fn sample_key_pair( + keys: &[AnimKey24], + key_index: usize, + fallback_index: usize, + frame: usize, + ) -> Result { + if key_index == fallback_index { + return Ok(keys[fallback_index].sampling_pose()); + } + let next_index = key_index.saturating_add(1); + if next_index >= keys.len() || keys[next_index].time.0 <= keys[key_index].time.0 { + return Ok(keys[key_index].sampling_pose()); + } + let track = TimedPoseTrack::new( + keys[key_index].sampling_pose(), + vec![ + TimedPoseKey { + time: keys[key_index].time, + pose: keys[key_index].sampling_pose(), + }, + TimedPoseKey { + time: keys[next_index].time, + pose: keys[next_index].sampling_pose(), + }, + ], + )?; + track.sample(AnimationTime(frame as f32)) + } + + fn decode_nested(bytes: &[u8]) -> Result { + fparkan_nres::decode( + Arc::from(bytes.to_vec().into_boxed_slice()), + ReadProfile::Compatible, + ) + } + + fn minimal_msh_bytes() -> Vec { + build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + ]) + } + + fn stream<'a>(type_id: u32, attr3: u32, name: &'a [u8], payload: &'a [u8]) -> TestEntry<'a> { + TestEntry { + type_id, + attr3, + name, + payload, + } + } + + struct TestEntry<'a> { + type_id: u32, + attr3: u32, + name: &'a [u8], + payload: &'a [u8], + } + + fn build_nres(entries: &[TestEntry<'_>]) -> Vec { + 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 = (0..entries.len()).collect(); + order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name)); + for (idx, 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"), + ); + push_u32(&mut out, entry.attr3); + let mut name_raw = [0; 36]; + copy_cstr(&mut name_raw, entry.name); + 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_u16(out: &mut Vec, value: u16) { + out.extend_from_slice(&value.to_le_bytes()); + } + + fn push_u32(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); + } + + fn push_f32(out: &mut Vec, value: f32) { + out.extend_from_slice(&value.to_le_bytes()); + } + + fn node38(slots: [u16; 15]) -> Vec { + let mut out = vec![0; 8]; + for slot in slots { + push_u16(&mut out, slot); + } + out + } + + fn slots_payload(records: &[Vec]) -> Vec { + let mut out = vec![0; 0x8c]; + for record in records { + assert_eq!(record.len(), 68); + out.extend_from_slice(record); + } + out + } + + fn slot_record( + batch_start: u16, + batch_count: u16, + aabb_min: [f32; 3], + aabb_max: [f32; 3], + sphere_radius: f32, + ) -> Vec { + let mut out = Vec::new(); + push_u16(&mut out, 0); + push_u16(&mut out, 0); + push_u16(&mut out, batch_start); + push_u16(&mut out, batch_count); + for value in aabb_min { + push_f32(&mut out, value); + } + for value in aabb_max { + push_f32(&mut out, value); + } + for value in [0.0, 0.0, 0.0] { + push_f32(&mut out, value); + } + push_f32(&mut out, sphere_radius); + for _ in 0..5 { + push_u32(&mut out, 0); + } + out + } + + fn positions_payload(values: &[[f32; 3]]) -> Vec { + let mut out = Vec::new(); + for position in values { + for value in position { + push_f32(&mut out, *value); + } + } + out + } + + #[allow(clippy::too_many_arguments)] + fn batch_record( + batch_flags: u16, + material_index: u16, + opaque4: u16, + opaque6: u16, + index_count: u16, + index_start: u32, + opaque14: u16, + base_vertex: u32, + ) -> Vec { + let mut out = Vec::new(); + push_u16(&mut out, batch_flags); + push_u16(&mut out, material_index); + push_u16(&mut out, opaque4); + push_u16(&mut out, opaque6); + push_u16(&mut out, index_count); + push_u32(&mut out, index_start); + push_u16(&mut out, opaque14); + push_u32(&mut out, base_vertex); + out + } + + fn copy_cstr(dst: &mut [u8], src: &[u8]) { + let len = dst.len().saturating_sub(1).min(src.len()); + dst[..len].copy_from_slice(&src[..len]); + } + + fn has_msh_extension(name: &[u8]) -> bool { + name.len() >= 4 && name[name.len() - 4..].eq_ignore_ascii_case(b".msh") + } + + fn corpus_root(name: &str) -> Option { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("testdata") + .join(name); + root.is_dir().then_some(root) + } + + fn files_under(root: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(path) = stack.pop() { + let Ok(read_dir) = std::fs::read_dir(path) else { + continue; + }; + for entry in read_dir.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + out.push(path); + } + } + } + out.sort(); + out + } + + const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; + const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + + fn hash_bytes(hash: &mut u64, bytes: &[u8]) { + for byte in bytes { + *hash ^= u64::from(*byte); + *hash = hash.wrapping_mul(FNV_PRIME); + } + } + + fn hash_usize(hash: &mut u64, value: usize) { + hash_bytes(hash, &value.to_le_bytes()); + } +} diff --git a/crates/fparkan-nres/Cargo.toml b/crates/fparkan-nres/Cargo.toml new file mode 100644 index 0000000..c0433eb --- /dev/null +++ b/crates/fparkan-nres/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "fparkan-nres" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-binary = { path = "../fparkan-binary" } +fparkan-path = { path = "../fparkan-path" } + +[lints] +workspace = true diff --git a/crates/fparkan-nres/src/lib.rs b/crates/fparkan-nres/src/lib.rs new file mode 100644 index 0000000..f2bd106 --- /dev/null +++ b/crates/fparkan-nres/src/lib.rs @@ -0,0 +1,1935 @@ +#![forbid(unsafe_code)] +//! Strict and lossless `NRes` archive support. + +use fparkan_binary::{Cursor, DecodeError}; +use fparkan_path::{ascii_lookup_key, LookupKey}; +use std::cmp::Ordering; +use std::fmt; +use std::ops::Range; +use std::sync::Arc; + +const HEADER_LEN: usize = 16; +const HEADER_LEN_U32: u32 = 16; +const ENTRY_LEN: usize = 64; +const NAME_LEN: usize = 36; +const VERSION_0100: u32 = 0x100; + +/// Read profile. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReadProfile { + /// Reject malformed lookup tables and directory invariants. + Strict, + /// Keep the document readable when the lookup table is invalid. + Compatible, +} + +/// Write profile. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WriteProfile { + /// Return the original byte image when no edit model is active. + Lossless, + /// Repack active payloads and rebuild the lookup table. + CanonicalCompact, +} + +/// `NRes` archive header. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NresHeader { + /// Archive format version. + pub version: u32, + /// Number of directory entries. + pub entry_count: u32, + /// Total byte size declared by the header. + pub total_size: u32, + /// Directory byte offset. + pub directory_offset: u32, +} + +/// `NRes` entry identifier in original directory order. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct EntryId(pub u32); + +/// `NRes` entry metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EntryMeta { + /// Entry type identifier. + pub type_id: u32, + /// Opaque attribute 1. + pub attr1: u32, + /// Opaque attribute 2. + pub attr2: u32, + /// Opaque attribute 3. + pub attr3: u32, + /// Decoded byte-for-byte ASCII-style resource name. + pub name: String, + /// Payload byte offset. + pub data_offset: u32, + /// Payload byte size. + pub data_size: u32, + /// Lookup table value stored at this sorted position. + pub sort_index: u32, +} + +/// `NRes` entry. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NresEntry { + id: EntryId, + meta: EntryMeta, + name_raw: [u8; NAME_LEN], + data_range: Range, +} + +/// Preserved bytes that are not referenced by any entry. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreservedRegion { + /// Byte range in the original archive. + pub range: Range, + /// Whether the whole range consists of zero bytes. + pub all_zero: bool, +} + +/// Parsed `NRes` document. +#[derive(Clone, Debug)] +pub struct NresDocument { + bytes: Arc<[u8]>, + header: NresHeader, + entries: Vec, + lookup_order_valid: bool, + preserved_regions: Vec, +} + +/// Editable `NRes` document. +#[derive(Clone, Debug)] +pub struct NresEditor { + entries: Vec, +} + +#[derive(Clone, Debug)] +struct EditableEntry { + type_id: u32, + attr1: u32, + attr2: u32, + attr3: u32, + name_raw: [u8; NAME_LEN], + payload: Vec, +} + +/// `NRes` parse or write error. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum NresError { + /// The input is not an `NRes` archive. + InvalidMagic { + /// First four bytes, padded when the file is shorter. + got: [u8; 4], + }, + /// Unsupported format version. + UnsupportedVersion { + /// Observed version. + got: u32, + }, + /// Entry count is negative. + InvalidEntryCount { + /// Observed signed count. + got: i32, + }, + /// Header size does not match the byte slice length. + TotalSizeMismatch { + /// Header value. + header: u32, + /// Actual byte length. + actual: u64, + }, + /// Directory range is outside the archive. + DirectoryOutOfBounds { + /// Computed directory offset. + offset: u64, + /// Computed directory length. + len: u64, + /// Actual byte length. + file_len: u64, + }, + /// Entry payload range is outside the data region. + EntryDataOutOfBounds { + /// Entry id. + id: u32, + /// Payload offset. + offset: u32, + /// Payload size. + size: u32, + /// Directory offset. + directory_offset: u32, + }, + /// Active payload ranges overlap. + EntryDataOverlap { + /// Earlier entry id. + first: u32, + /// Later entry id. + second: u32, + }, + /// Entry name has no zero terminator inside the fixed field. + MissingNameTerminator { + /// Entry id. + id: u32, + }, + /// Entry name is empty. + EmptyName { + /// Entry id. + id: u32, + }, + /// Lookup value points outside the directory. + SortIndexOutOfRange { + /// Sorted table position. + position: u32, + /// Stored index. + index: u32, + /// Entry count. + entry_count: u32, + }, + /// Lookup table is not a permutation. + SortIndexDuplicate { + /// Duplicated original entry index. + index: u32, + }, + /// Lookup table is a permutation but not sorted by ASCII-casefolded names. + SortOrderMismatch { + /// Sorted table position. + position: u32, + }, + /// Entry id is outside this archive. + EntryIdOutOfRange { + /// Entry id. + id: u32, + /// Entry count. + entry_count: u32, + }, + /// Authoring name is too long for the fixed `NRes` field. + AuthoringNameTooLong { + /// Observed byte length. + len: usize, + /// Maximum useful byte length before the required NUL terminator. + max: usize, + }, + /// Authoring name contains an embedded NUL byte. + AuthoringNameContainsNul { + /// Byte offset. + offset: usize, + }, + /// Arithmetic overflow or failed bounded read. + Binary(DecodeError), +} + +impl fmt::Display for NresError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidMagic { got } => write!(f, "invalid NRes magic: {got:02X?}"), + Self::UnsupportedVersion { got } => { + write!(f, "unsupported NRes version: {got:#x}") + } + Self::InvalidEntryCount { got } => write!(f, "invalid NRes entry count: {got}"), + Self::TotalSizeMismatch { header, actual } => { + write!(f, "NRes total size mismatch: header={header}, actual={actual}") + } + Self::DirectoryOutOfBounds { + offset, + len, + file_len, + } => write!( + f, + "NRes directory out of bounds: offset={offset}, len={len}, file={file_len}" + ), + Self::EntryDataOutOfBounds { + id, + offset, + size, + directory_offset, + } => write!( + f, + "NRes entry #{id} data out of bounds: offset={offset}, size={size}, directory={directory_offset}" + ), + Self::EntryDataOverlap { first, second } => { + write!(f, "NRes entries #{first} and #{second} overlap") + } + Self::MissingNameTerminator { id } => { + write!(f, "NRes entry #{id} name has no NUL terminator") + } + Self::EmptyName { id } => write!(f, "NRes entry #{id} name is empty"), + Self::SortIndexOutOfRange { + position, + index, + entry_count, + } => write!( + f, + "NRes sort index out of range at position {position}: {index} >= {entry_count}" + ), + Self::SortIndexDuplicate { index } => { + write!(f, "NRes duplicate sort index: {index}") + } + Self::SortOrderMismatch { position } => { + write!(f, "NRes sort order mismatch at position {position}") + } + Self::EntryIdOutOfRange { id, entry_count } => { + write!(f, "NRes entry id out of range: {id} >= {entry_count}") + } + Self::AuthoringNameTooLong { len, max } => { + write!(f, "NRes authoring name too long: {len} > {max}") + } + Self::AuthoringNameContainsNul { offset } => { + write!(f, "NRes authoring name contains NUL at byte {offset}") + } + Self::Binary(source) => write!(f, "{source}"), + } + } +} + +impl std::error::Error for NresError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Binary(source) => Some(source), + Self::InvalidMagic { .. } + | Self::UnsupportedVersion { .. } + | Self::InvalidEntryCount { .. } + | Self::TotalSizeMismatch { .. } + | Self::DirectoryOutOfBounds { .. } + | Self::EntryDataOutOfBounds { .. } + | Self::EntryDataOverlap { .. } + | Self::MissingNameTerminator { .. } + | Self::EmptyName { .. } + | Self::SortIndexOutOfRange { .. } + | Self::SortIndexDuplicate { .. } + | Self::SortOrderMismatch { .. } + | Self::EntryIdOutOfRange { .. } + | Self::AuthoringNameTooLong { .. } + | Self::AuthoringNameContainsNul { .. } => None, + } + } +} + +impl From for NresError { + fn from(value: DecodeError) -> Self { + Self::Binary(value) + } +} + +/// Decodes `NRes` bytes. +/// +/// # Errors +/// +/// Returns [`NresError`] when the header, directory, payload ranges, or strict +/// lookup permutation are malformed for the selected [`ReadProfile`]. +pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result { + let header = parse_header(&bytes)?; + let entries = parse_entries(&bytes, &header)?; + validate_names(&entries)?; + validate_payload_ranges(&entries)?; + let lookup_order_valid = match validate_lookup_order(&entries) { + Ok(valid) => valid, + Err(err) if profile == ReadProfile::Strict => return Err(err), + Err(_) => false, + }; + let preserved_regions = find_preserved_regions(&bytes, &entries, header.directory_offset)?; + Ok(NresDocument { + bytes, + header, + entries, + lookup_order_valid, + preserved_regions, + }) +} + +impl NresDocument { + /// Returns the archive header. + #[must_use] + pub fn header(&self) -> &NresHeader { + &self.header + } + + /// Entry count. + #[must_use] + pub fn entry_count(&self) -> usize { + self.entries.len() + } + + /// Returns all entries in original directory order. + #[must_use] + pub fn entries(&self) -> &[NresEntry] { + &self.entries + } + + /// Whether the lookup table is valid and sorted. + #[must_use] + pub fn lookup_order_valid(&self) -> bool { + self.lookup_order_valid + } + + /// Returns preserved ranges outside active payloads. + #[must_use] + pub fn preserved_regions(&self) -> &[PreservedRegion] { + &self.preserved_regions + } + + /// Whether any unindexed preserved region contains non-zero bytes. + #[must_use] + pub fn has_nonzero_preserved_region(&self) -> bool { + self.preserved_regions.iter().any(|region| !region.all_zero) + } + + /// Finds an entry by ASCII-case-insensitive name. + #[must_use] + pub fn find(&self, name: &str) -> Option { + self.find_bytes(name.as_bytes()) + } + + /// Finds an entry by ASCII-case-insensitive raw name bytes. + #[must_use] + pub fn find_bytes(&self, name: &[u8]) -> Option { + if self.lookup_order_valid { + return self.find_by_lookup(name); + } + self.entries + .iter() + .find(|entry| cmp_ascii_casefold(name, entry.name_bytes()) == Ordering::Equal) + .map(NresEntry::id) + } + + /// Returns an entry by id. + #[must_use] + pub fn entry(&self, id: EntryId) -> Option<&NresEntry> { + self.entries.get(usize::try_from(id.0).ok()?) + } + + /// Returns an entry payload. + /// + /// # Errors + /// + /// Returns [`NresError::EntryIdOutOfRange`] when `id` is not present in + /// this document. + pub fn payload(&self, id: EntryId) -> Result<&[u8], NresError> { + let entry = self.entry(id).ok_or_else(|| NresError::EntryIdOutOfRange { + id: id.0, + entry_count: saturating_u32_len(self.entries.len()), + })?; + Ok(&self.bytes[entry.data_range.clone()]) + } + + /// Encodes the document according to the selected write profile. + #[must_use] + pub fn encode(&self, profile: WriteProfile) -> Vec { + match profile { + WriteProfile::Lossless => self.bytes.to_vec(), + WriteProfile::CanonicalCompact => self.encode_canonical_compact(), + } + } + + /// Creates an editor initialized from this document. + /// + /// # Errors + /// + /// Returns [`NresError`] if any source payload cannot be copied by id. + pub fn editor(&self) -> Result { + NresEditor::from_document(self) + } + + fn find_by_lookup(&self, needle: &[u8]) -> Option { + let mut low = 0usize; + let mut high = self.entries.len(); + while low < high { + let mid = low + (high - low) / 2; + let entry_idx = usize::try_from(self.entries[mid].meta.sort_index).ok()?; + let entry = self.entries.get(entry_idx)?; + match cmp_ascii_casefold(needle, entry.name_bytes()) { + Ordering::Less => high = mid, + Ordering::Greater => low = mid.saturating_add(1), + Ordering::Equal => { + return self + .entries + .iter() + .find(|entry| { + cmp_ascii_casefold(needle, entry.name_bytes()) == Ordering::Equal + }) + .map(NresEntry::id); + } + } + } + None + } + + fn encode_canonical_compact(&self) -> Vec { + let mut out = vec![0; HEADER_LEN]; + let mut offsets = Vec::with_capacity(self.entries.len()); + let mut sizes = Vec::with_capacity(self.entries.len()); + for entry in &self.entries { + offsets.push(saturating_u32_len(out.len())); + let payload = &self.bytes[entry.data_range.clone()]; + sizes.push(saturating_u32_len(payload.len())); + out.extend_from_slice(payload); + let padding = (8 - (out.len() % 8)) % 8; + out.resize(out.len() + padding, 0); + } + + let sort_order = build_sort_order(&self.entries); + for (index, entry) in self.entries.iter().enumerate() { + push_u32(&mut out, entry.meta.type_id); + push_u32(&mut out, entry.meta.attr1); + push_u32(&mut out, entry.meta.attr2); + push_u32(&mut out, sizes[index]); + push_u32(&mut out, entry.meta.attr3); + out.extend_from_slice(&entry.name_raw); + push_u32(&mut out, offsets[index]); + push_u32(&mut out, saturating_u32_len(sort_order[index])); + } + + let total_size = saturating_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(&saturating_u32_len(self.entries.len()).to_le_bytes()); + out[12..16].copy_from_slice(&total_size.to_le_bytes()); + out + } +} + +impl NresEditor { + /// Creates an editor from an existing document. + /// + /// # Errors + /// + /// Returns [`NresError`] if any source payload cannot be copied by id. + pub fn from_document(document: &NresDocument) -> Result { + let mut entries = Vec::with_capacity(document.entries.len()); + for entry in &document.entries { + let meta = entry.meta(); + entries.push(EditableEntry { + type_id: meta.type_id, + attr1: meta.attr1, + attr2: meta.attr2, + attr3: meta.attr3, + name_raw: entry.name_raw, + payload: document.payload(entry.id())?.to_vec(), + }); + } + Ok(Self { entries }) + } + + /// Replaces an entry payload. + /// + /// # Errors + /// + /// Returns [`NresError::EntryIdOutOfRange`] when `id` is not present. + pub fn set_payload( + &mut self, + id: EntryId, + payload: impl Into>, + ) -> Result<(), NresError> { + let entry = self.entry_mut(id)?; + entry.payload = payload.into(); + Ok(()) + } + + /// Renames an entry. + /// + /// # Errors + /// + /// Returns [`NresError::EntryIdOutOfRange`] when `id` is not present, or + /// a name authoring error when `name` cannot be stored in the fixed field. + pub fn rename(&mut self, id: EntryId, name: impl AsRef<[u8]>) -> Result<(), NresError> { + let name_raw = authoring_name_raw(name.as_ref())?; + let entry = self.entry_mut(id)?; + entry.name_raw = name_raw; + Ok(()) + } + + /// Encodes the edited document in canonical compact form. + /// + /// # Errors + /// + /// Returns [`NresError`] when offsets or sizes exceed the on-disk `u32` + /// representation. + pub fn encode(&self) -> Result, NresError> { + let mut out = vec![0; HEADER_LEN]; + let mut offsets = Vec::with_capacity(self.entries.len()); + let mut sizes = Vec::with_capacity(self.entries.len()); + for entry in &self.entries { + offsets.push(checked_u32_len(out.len())?); + sizes.push(checked_u32_len(entry.payload.len())?); + out.extend_from_slice(&entry.payload); + let padding = (8 - (out.len() % 8)) % 8; + out.resize( + out.len() + .checked_add(padding) + .ok_or(DecodeError::IntegerOverflow)?, + 0, + ); + } + + let sort_order = build_edit_sort_order(&self.entries); + for (index, entry) in self.entries.iter().enumerate() { + push_u32(&mut out, entry.type_id); + push_u32(&mut out, entry.attr1); + push_u32(&mut out, entry.attr2); + push_u32(&mut out, sizes[index]); + push_u32(&mut out, entry.attr3); + out.extend_from_slice(&entry.name_raw); + push_u32(&mut out, offsets[index]); + push_u32(&mut out, checked_u32_len(sort_order[index])?); + } + + let total_size = checked_u32_len(out.len())?; + out[0..4].copy_from_slice(b"NRes"); + out[4..8].copy_from_slice(&VERSION_0100.to_le_bytes()); + out[8..12].copy_from_slice(&checked_u32_len(self.entries.len())?.to_le_bytes()); + out[12..16].copy_from_slice(&total_size.to_le_bytes()); + Ok(out) + } + + fn entry_mut(&mut self, id: EntryId) -> Result<&mut EditableEntry, NresError> { + let entry_count = saturating_u32_len(self.entries.len()); + self.entries + .get_mut( + usize::try_from(id.0).map_err(|_| NresError::EntryIdOutOfRange { + id: id.0, + entry_count, + })?, + ) + .ok_or(NresError::EntryIdOutOfRange { + id: id.0, + entry_count, + }) + } +} + +impl NresEntry { + /// Entry id in original directory order. + #[must_use] + pub fn id(&self) -> EntryId { + self.id + } + + /// Entry metadata. + #[must_use] + pub fn meta(&self) -> &EntryMeta { + &self.meta + } + + /// Raw fixed-size name field. + #[must_use] + pub fn name_raw(&self) -> &[u8; NAME_LEN] { + &self.name_raw + } + + /// Active payload range in the original archive. + #[must_use] + pub fn data_range(&self) -> Range { + self.data_range.clone() + } + + /// Raw name bytes before the first NUL terminator. + #[must_use] + pub fn name_bytes(&self) -> &[u8] { + let len = name_len(&self.name_raw).unwrap_or(NAME_LEN); + &self.name_raw[..len] + } +} + +fn parse_header(bytes: &[u8]) -> Result { + if bytes.len() < HEADER_LEN { + let mut got = [0; 4]; + let copy_len = bytes.len().min(4); + got[..copy_len].copy_from_slice(&bytes[..copy_len]); + return Err(NresError::InvalidMagic { got }); + } + if &bytes[..4] != b"NRes" { + let mut got = [0; 4]; + got.copy_from_slice(&bytes[..4]); + return Err(NresError::InvalidMagic { got }); + } + + let mut cursor = Cursor::new(bytes); + let _magic = cursor.read_exact(4)?; + let version = cursor.read_u32_le()?; + if version != VERSION_0100 { + return Err(NresError::UnsupportedVersion { got: version }); + } + let entry_count_signed = cursor.read_i32_le()?; + if entry_count_signed < 0 { + return Err(NresError::InvalidEntryCount { + got: entry_count_signed, + }); + } + let entry_count = + u32::try_from(entry_count_signed).map_err(|_| DecodeError::IntegerOverflow)?; + let total_size = cursor.read_u32_le()?; + let actual = u64::try_from(bytes.len()).map_err(|_| DecodeError::IntegerOverflow)?; + if u64::from(total_size) != actual { + return Err(NresError::TotalSizeMismatch { + header: total_size, + actual, + }); + } + let directory_len = u64::from(entry_count) + .checked_mul(ENTRY_LEN as u64) + .ok_or(DecodeError::IntegerOverflow)?; + let directory_offset = u64::from(total_size).checked_sub(directory_len).ok_or( + NresError::DirectoryOutOfBounds { + offset: 0, + len: directory_len, + file_len: actual, + }, + )?; + if directory_offset < HEADER_LEN as u64 + || directory_offset + .checked_add(directory_len) + .ok_or(DecodeError::IntegerOverflow)? + != actual + { + return Err(NresError::DirectoryOutOfBounds { + offset: directory_offset, + len: directory_len, + file_len: actual, + }); + } + Ok(NresHeader { + version, + entry_count, + total_size, + directory_offset: u32::try_from(directory_offset) + .map_err(|_| DecodeError::IntegerOverflow)?, + }) +} + +fn parse_entries(bytes: &[u8], header: &NresHeader) -> Result, NresError> { + let mut entries = Vec::with_capacity(header.entry_count as usize); + let directory_offset = + usize::try_from(header.directory_offset).map_err(|_| DecodeError::IntegerOverflow)?; + for index in 0..header.entry_count { + let index_usize = usize::try_from(index).map_err(|_| DecodeError::IntegerOverflow)?; + let entry_offset = directory_offset + .checked_add( + index_usize + .checked_mul(ENTRY_LEN) + .ok_or(DecodeError::IntegerOverflow)?, + ) + .ok_or(DecodeError::IntegerOverflow)?; + entries.push(parse_entry( + bytes, + entry_offset, + index, + header.directory_offset, + )?); + } + Ok(entries) +} + +fn parse_entry( + bytes: &[u8], + offset: usize, + id: u32, + directory_offset: u32, +) -> Result { + let entry_bytes = bytes + .get(offset..offset + ENTRY_LEN) + .ok_or(DecodeError::IntegerOverflow)?; + let mut cursor = Cursor::new(entry_bytes); + let type_id = cursor.read_u32_le()?; + let attr1 = cursor.read_u32_le()?; + let attr2 = cursor.read_u32_le()?; + let data_size = cursor.read_u32_le()?; + let attr3 = cursor.read_u32_le()?; + let name_slice = cursor.read_exact(NAME_LEN)?; + let mut name_raw = [0; NAME_LEN]; + name_raw.copy_from_slice(name_slice); + let Some(name_len) = name_len(&name_raw) else { + return Err(NresError::MissingNameTerminator { id }); + }; + let name = name_raw[..name_len] + .iter() + .map(|byte| char::from(*byte)) + .collect(); + let data_offset = cursor.read_u32_le()?; + let sort_index = cursor.read_u32_le()?; + cursor.require_eof()?; + + let data_end = data_offset + .checked_add(data_size) + .ok_or(DecodeError::IntegerOverflow)?; + if data_offset < HEADER_LEN_U32 || data_end > directory_offset { + return Err(NresError::EntryDataOutOfBounds { + id, + offset: data_offset, + size: data_size, + directory_offset, + }); + } + + Ok(NresEntry { + id: EntryId(id), + meta: EntryMeta { + type_id, + attr1, + attr2, + attr3, + name, + data_offset, + data_size, + sort_index, + }, + name_raw, + data_range: usize::try_from(data_offset).map_err(|_| DecodeError::IntegerOverflow)? + ..usize::try_from(data_end).map_err(|_| DecodeError::IntegerOverflow)?, + }) +} + +fn validate_payload_ranges(entries: &[NresEntry]) -> Result<(), NresError> { + let mut ranges: Vec<(u32, Range)> = entries + .iter() + .map(|entry| (entry.id.0, entry.data_range.clone())) + .collect(); + ranges.sort_by(|left, right| { + left.1 + .start + .cmp(&right.1.start) + .then_with(|| left.1.end.cmp(&right.1.end)) + }); + for pair in ranges.windows(2) { + if pair[0].1.end > pair[1].1.start { + return Err(NresError::EntryDataOverlap { + first: pair[0].0, + second: pair[1].0, + }); + } + } + Ok(()) +} + +fn validate_names(entries: &[NresEntry]) -> Result<(), NresError> { + for entry in entries { + if entry.name_bytes().is_empty() { + return Err(NresError::EmptyName { id: entry.id.0 }); + } + } + Ok(()) +} + +fn validate_lookup_order(entries: &[NresEntry]) -> Result { + let entry_count = saturating_u32_len(entries.len()); + let mut seen = vec![false; entries.len()]; + for (position, entry) in entries.iter().enumerate() { + let index = entry.meta.sort_index; + if index >= entry_count { + return Err(NresError::SortIndexOutOfRange { + position: saturating_u32_len(position), + index, + entry_count, + }); + } + let index_usize = usize::try_from(index).map_err(|_| DecodeError::IntegerOverflow)?; + if seen[index_usize] { + return Err(NresError::SortIndexDuplicate { index }); + } + seen[index_usize] = true; + } + for pair in entries.windows(2) { + let left_index = + usize::try_from(pair[0].meta.sort_index).map_err(|_| DecodeError::IntegerOverflow)?; + let right_index = + usize::try_from(pair[1].meta.sort_index).map_err(|_| DecodeError::IntegerOverflow)?; + let left = entries[left_index].name_bytes(); + let right = entries[right_index].name_bytes(); + if cmp_ascii_casefold(left, right) == Ordering::Greater { + return Ok(false); + } + } + Ok(true) +} + +fn find_preserved_regions( + bytes: &[u8], + entries: &[NresEntry], + directory_offset: u32, +) -> Result, NresError> { + let mut ranges: Vec> = entries + .iter() + .map(|entry| entry.data_range.clone()) + .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(directory_offset).map_err(|_| DecodeError::IntegerOverflow)?; + let mut preserved = Vec::new(); + for range in ranges { + if cursor < range.start { + preserved.push(make_preserved_region(bytes, cursor..range.start)?); + } + cursor = cursor.max(range.end); + } + if cursor < directory_offset { + preserved.push(make_preserved_region(bytes, cursor..directory_offset)?); + } + Ok(preserved) +} + +fn make_preserved_region(bytes: &[u8], range: Range) -> Result { + let all_zero = bytes[range.clone()].iter().all(|byte| *byte == 0); + Ok(PreservedRegion { + range: u32::try_from(range.start).map_err(|_| DecodeError::IntegerOverflow)? + ..u32::try_from(range.end).map_err(|_| DecodeError::IntegerOverflow)?, + all_zero, + }) +} + +fn build_sort_order(entries: &[NresEntry]) -> Vec { + let mut order: Vec = (0..entries.len()).collect(); + order.sort_by(|left, right| { + cmp_ascii_casefold(entries[*left].name_bytes(), entries[*right].name_bytes()) + }); + order +} + +fn build_edit_sort_order(entries: &[EditableEntry]) -> Vec { + let mut order: Vec = (0..entries.len()).collect(); + order.sort_by(|left, right| { + cmp_ascii_casefold( + editable_name_bytes(&entries[*left].name_raw), + editable_name_bytes(&entries[*right].name_raw), + ) + }); + order +} + +fn editable_name_bytes(raw: &[u8; NAME_LEN]) -> &[u8] { + let len = name_len(raw).unwrap_or(NAME_LEN); + &raw[..len] +} + +fn cmp_ascii_casefold(left: &[u8], right: &[u8]) -> Ordering { + let left_key = lookup_key(left); + let right_key = lookup_key(right); + left_key.0.cmp(&right_key.0) +} + +fn lookup_key(bytes: &[u8]) -> LookupKey { + ascii_lookup_key(bytes) +} + +fn name_len(raw: &[u8; NAME_LEN]) -> Option { + raw.iter().position(|byte| *byte == 0) +} + +fn push_u32(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); +} + +fn checked_u32_len(len: usize) -> Result { + u32::try_from(len).map_err(|_| NresError::Binary(DecodeError::IntegerOverflow)) +} + +fn saturating_u32_len(len: usize) -> u32 { + u32::try_from(len).unwrap_or(u32::MAX) +} + +fn authoring_name_raw(name: &[u8]) -> Result<[u8; NAME_LEN], NresError> { + if let Some(offset) = name.iter().position(|byte| *byte == 0) { + return Err(NresError::AuthoringNameContainsNul { offset }); + } + let max = NAME_LEN - 1; + if name.len() > max { + return Err(NresError::AuthoringNameTooLong { + len: name.len(), + max, + }); + } + let mut raw = [0; NAME_LEN]; + raw[..name.len()].copy_from_slice(name); + Ok(raw) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::{Path, PathBuf}; + + #[derive(Clone, Copy)] + struct SyntheticEntry<'a> { + type_id: u32, + attr1: u32, + attr2: u32, + attr3: u32, + name: &'a str, + payload: &'a [u8], + } + + #[test] + fn parses_minimal_empty_archive() { + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"NRes"); + push_u32(&mut bytes, VERSION_0100); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, HEADER_LEN_U32); + + let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("empty nres"); + + assert_eq!(doc.header().entry_count, 0); + assert_eq!(doc.header().directory_offset, HEADER_LEN_U32); + assert!(doc.entries().is_empty()); + assert!(doc.preserved_regions().is_empty()); + assert_eq!(doc.encode(WriteProfile::Lossless), bytes); + } + + #[test] + fn one_entry_archive_uses_8_byte_alignment() { + let bytes = build_archive(&[SyntheticEntry { + type_id: 7, + attr1: 1, + attr2: 2, + attr3: 3, + name: "one", + payload: b"x", + }]); + let doc = decode(arc(bytes), ReadProfile::Strict).expect("one entry nres"); + let entry = doc.entry(EntryId(0)).expect("entry"); + + assert_eq!(doc.entry_count(), 1); + assert_eq!(entry.data_range().start, HEADER_LEN); + assert_eq!(entry.data_range().end, HEADER_LEN + 1); + assert_eq!(doc.header().directory_offset % 8, 0); + assert_eq!(doc.payload(EntryId(0)).expect("payload"), b"x"); + } + + #[test] + fn rejects_invalid_magic() { + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"BAD!"); + push_u32(&mut bytes, VERSION_0100); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, HEADER_LEN_U32); + + assert!(matches!( + decode(arc(bytes), ReadProfile::Strict), + Err(NresError::InvalidMagic { got }) if got == *b"BAD!" + )); + } + + #[test] + fn rejects_unsupported_version() { + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"NRes"); + push_u32(&mut bytes, VERSION_0100 + 1); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, HEADER_LEN_U32); + + assert!(matches!( + decode(arc(bytes), ReadProfile::Strict), + Err(NresError::UnsupportedVersion { got }) if got == VERSION_0100 + 1 + )); + } + + #[test] + fn rejects_negative_entry_count() { + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"NRes"); + push_u32(&mut bytes, VERSION_0100); + bytes.extend_from_slice(&(-1_i32).to_le_bytes()); + push_u32(&mut bytes, HEADER_LEN_U32); + + assert!(matches!( + decode(arc(bytes), ReadProfile::Strict), + Err(NresError::InvalidEntryCount { got }) if got == -1 + )); + } + + #[test] + fn rejects_directory_size_before_allocation() { + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"NRes"); + push_u32(&mut bytes, VERSION_0100); + push_u32(&mut bytes, i32::MAX.cast_unsigned()); + push_u32(&mut bytes, HEADER_LEN_U32); + + assert!(matches!( + decode(arc(bytes), ReadProfile::Strict), + Err(NresError::DirectoryOutOfBounds { .. }) + )); + } + + #[test] + fn rejects_total_size_mismatch() { + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"NRes"); + push_u32(&mut bytes, VERSION_0100); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, HEADER_LEN_U32 + 1); + + assert!(matches!( + decode(arc(bytes), ReadProfile::Strict), + Err(NresError::TotalSizeMismatch { header, actual }) + if header == HEADER_LEN_U32 + 1 && actual == HEADER_LEN as u64 + )); + } + + #[test] + fn rejects_directory_before_header() { + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"NRes"); + push_u32(&mut bytes, VERSION_0100); + push_u32(&mut bytes, 1); + push_u32(&mut bytes, ENTRY_LEN as u32); + bytes.resize(ENTRY_LEN, 0); + + assert!(matches!( + decode(arc(bytes), ReadProfile::Strict), + Err(NresError::DirectoryOutOfBounds { offset, .. }) if offset == 0 + )); + } + + #[test] + fn rejects_payload_before_data_region() { + let mut bytes = build_archive(&[SyntheticEntry { + type_id: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "one", + payload: b"x", + }]); + let directory_offset = bytes.len() - ENTRY_LEN; + bytes[directory_offset + 56..directory_offset + 60].copy_from_slice(&15_u32.to_le_bytes()); + + assert!(matches!( + decode(arc(bytes), ReadProfile::Strict), + Err(NresError::EntryDataOutOfBounds { offset, .. }) if offset == 15 + )); + } + + #[test] + fn rejects_payload_crossing_directory() { + let mut bytes = build_archive(&[SyntheticEntry { + type_id: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "one", + payload: b"x", + }]); + let directory_offset = bytes.len() - ENTRY_LEN; + let offset = u32::from_le_bytes( + bytes[directory_offset + 56..directory_offset + 60] + .try_into() + .expect("offset field"), + ); + let size = u32::try_from(directory_offset).expect("directory offset") - offset + 1; + bytes[directory_offset + 12..directory_offset + 16].copy_from_slice(&size.to_le_bytes()); + + assert!(matches!( + decode(arc(bytes), ReadProfile::Strict), + Err(NresError::EntryDataOutOfBounds { + directory_offset: got_directory, + .. + }) if got_directory == u32::try_from(directory_offset).expect("directory offset") + )); + } + + #[test] + fn rejects_name_without_nul_terminator() { + let mut bytes = build_archive(&[SyntheticEntry { + type_id: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "one", + payload: b"x", + }]); + let directory_offset = bytes.len() - ENTRY_LEN; + bytes[directory_offset + 20..directory_offset + 56].fill(b'A'); + + assert!(matches!( + decode(arc(bytes), ReadProfile::Strict), + Err(NresError::MissingNameTerminator { id }) if id == 0 + )); + } + + #[test] + fn preserves_name_bytes_after_nul() { + let mut bytes = build_archive(&[SyntheticEntry { + type_id: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "one", + payload: b"x", + }]); + let directory_offset = bytes.len() - ENTRY_LEN; + bytes[directory_offset + 20..directory_offset + 29].copy_from_slice(b"one\0TAIL!"); + + let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("nres"); + let entry = doc.entry(EntryId(0)).expect("entry"); + + assert_eq!(entry.name_bytes(), b"one"); + assert_eq!(doc.encode(WriteProfile::Lossless), bytes); + assert_eq!(doc.encode(WriteProfile::CanonicalCompact), bytes); + } + + #[test] + fn rejects_sort_index_out_of_range() { + let mut bytes = build_archive(&[SyntheticEntry { + type_id: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "one", + payload: b"x", + }]); + let directory_offset = bytes.len() - ENTRY_LEN; + bytes[directory_offset + 60..directory_offset + 64].copy_from_slice(&1_u32.to_le_bytes()); + + assert!(matches!( + decode(arc(bytes), ReadProfile::Strict), + Err(NresError::SortIndexOutOfRange { + position: 0, + index: 1, + entry_count: 1, + }) + )); + } + + #[test] + fn rejects_duplicate_sort_mapping() { + let mut bytes = build_archive(&[ + SyntheticEntry { + type_id: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "a", + payload: b"a", + }, + SyntheticEntry { + type_id: 2, + attr1: 0, + attr2: 0, + attr3: 0, + name: "b", + payload: b"b", + }, + ]); + let directory_offset = bytes.len() - ENTRY_LEN * 2; + bytes[directory_offset + 60..directory_offset + 64].copy_from_slice(&0_u32.to_le_bytes()); + bytes[directory_offset + ENTRY_LEN + 60..directory_offset + ENTRY_LEN + 64] + .copy_from_slice(&0_u32.to_le_bytes()); + + assert!(matches!( + decode(arc(bytes), ReadProfile::Strict), + Err(NresError::SortIndexDuplicate { index }) if index == 0 + )); + } + + #[test] + fn binary_lookup_returns_original_entry_index() { + let bytes = build_archive(&[ + SyntheticEntry { + type_id: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "Zulu", + payload: b"z", + }, + SyntheticEntry { + type_id: 2, + attr1: 0, + attr2: 0, + attr3: 0, + name: "alpha", + payload: b"a", + }, + SyntheticEntry { + type_id: 3, + attr1: 0, + attr2: 0, + attr3: 0, + name: "Mike", + payload: b"m", + }, + ]); + let doc = decode(arc(bytes), ReadProfile::Strict).expect("nres"); + + assert!(doc.lookup_order_valid()); + assert_eq!(doc.find("alpha"), Some(EntryId(1))); + assert_eq!(doc.find("Mike"), Some(EntryId(2))); + assert_eq!(doc.find("Zulu"), Some(EntryId(0))); + } + + #[test] + fn compatible_profile_uses_linear_fallback_for_broken_mapping() { + let mut bytes = build_archive(&[ + SyntheticEntry { + type_id: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "b", + payload: b"b", + }, + SyntheticEntry { + type_id: 2, + attr1: 0, + attr2: 0, + attr3: 0, + name: "a", + payload: b"a", + }, + ]); + let directory_offset = bytes.len() - ENTRY_LEN * 2; + bytes[directory_offset + 60..directory_offset + 64].copy_from_slice(&0_u32.to_le_bytes()); + bytes[directory_offset + ENTRY_LEN + 60..directory_offset + ENTRY_LEN + 64] + .copy_from_slice(&0_u32.to_le_bytes()); + + let doc = decode(arc(bytes), ReadProfile::Compatible).expect("compatible nres"); + + assert!(!doc.lookup_order_valid()); + assert_eq!(doc.find("A"), Some(EntryId(1))); + assert_eq!(doc.payload(EntryId(1)).expect("payload"), b"a"); + } + + #[test] + fn lookup_is_ascii_case_insensitive() { + let bytes = build_archive(&[SyntheticEntry { + type_id: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "MiXeD", + payload: b"x", + }]); + let doc = decode(arc(bytes), ReadProfile::Strict).expect("nres"); + + assert_eq!(doc.find("mixed"), Some(EntryId(0))); + assert_eq!(doc.find("MIXED"), Some(EntryId(0))); + } + + #[test] + fn parses_synthetic_archive_and_finds_names() { + let bytes = build_archive(&[ + SyntheticEntry { + type_id: 1, + attr1: 10, + attr2: 20, + attr3: 30, + name: "Zulu", + payload: b"z", + }, + SyntheticEntry { + type_id: 2, + attr1: 11, + attr2: 21, + attr3: 31, + name: "alpha", + payload: b"aaaa", + }, + ]); + let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("synthetic nres"); + + assert_eq!(doc.entry_count(), 2); + assert_eq!(doc.find("ALPHA"), Some(EntryId(1))); + assert_eq!(doc.find("zulu"), Some(EntryId(0))); + assert_eq!( + doc.payload(EntryId(1)).expect("payload"), + b"aaaa".as_slice() + ); + assert_eq!(doc.encode(WriteProfile::Lossless), bytes); + assert_eq!(doc.encode(WriteProfile::CanonicalCompact), bytes); + } + + #[test] + fn unsorted_lookup_table_falls_back_to_linear_lookup() { + let mut bytes = build_archive(&[ + SyntheticEntry { + type_id: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "b", + payload: b"b", + }, + SyntheticEntry { + type_id: 2, + attr1: 0, + attr2: 0, + attr3: 0, + name: "a", + payload: b"a", + }, + ]); + let directory_offset = usize::try_from(u32::from_le_bytes( + bytes[12..16].try_into().expect("total size field"), + )) + .expect("total size") + - ENTRY_LEN * 2; + bytes[directory_offset + 60..directory_offset + 64].copy_from_slice(&0_u32.to_le_bytes()); + bytes[directory_offset + ENTRY_LEN + 60..directory_offset + ENTRY_LEN + 64] + .copy_from_slice(&1_u32.to_le_bytes()); + + let doc = decode(arc(bytes), ReadProfile::Strict).expect("strict nres"); + assert!(!doc.lookup_order_valid()); + assert_eq!(doc.find("A"), Some(EntryId(1))); + } + + #[test] + fn rejects_overlapping_payloads() { + let mut bytes = build_archive(&[ + SyntheticEntry { + type_id: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "one", + payload: b"1111", + }, + SyntheticEntry { + type_id: 2, + attr1: 0, + attr2: 0, + attr3: 0, + name: "two", + payload: b"2222", + }, + ]); + let directory_offset = bytes.len() - ENTRY_LEN * 2; + let first_offset = u32::from_le_bytes( + bytes[directory_offset + 56..directory_offset + 60] + .try_into() + .expect("offset field"), + ); + bytes[directory_offset + ENTRY_LEN + 56..directory_offset + ENTRY_LEN + 60] + .copy_from_slice(&(first_offset + 1).to_le_bytes()); + + assert!(matches!( + decode(arc(bytes), ReadProfile::Strict), + Err(NresError::EntryDataOverlap { .. }) + )); + } + + #[test] + fn preserves_nonzero_unindexed_region() { + let mut bytes = build_archive(&[SyntheticEntry { + type_id: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "payload", + payload: b"data", + }]); + let directory_offset = bytes.len() - ENTRY_LEN; + bytes.splice(HEADER_LEN..HEADER_LEN, [0xAA, 0xBB, 0xCC, 0xDD]); + let total = u32::try_from(bytes.len()).expect("total size"); + bytes[12..16].copy_from_slice(&total.to_le_bytes()); + let offset = u32::from_le_bytes( + bytes[directory_offset + 4 + 56..directory_offset + 4 + 60] + .try_into() + .expect("shifted offset"), + ); + let shifted_directory_offset = directory_offset + 4; + bytes[shifted_directory_offset + 56..shifted_directory_offset + 60] + .copy_from_slice(&(offset + 4).to_le_bytes()); + + let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("nres"); + assert!(doc.has_nonzero_preserved_region()); + assert_eq!(doc.encode(WriteProfile::Lossless), bytes); + assert_ne!(doc.encode(WriteProfile::CanonicalCompact), bytes); + } + + #[test] + fn canonical_compact_roundtrip_preserves_entry_semantics() { + let mut bytes = build_archive(&[ + SyntheticEntry { + type_id: 7, + attr1: 10, + attr2: 20, + attr3: 30, + name: "zeta", + payload: b"zz", + }, + SyntheticEntry { + type_id: 9, + attr1: 11, + attr2: 21, + attr3: 31, + name: "alpha", + payload: b"aaaa", + }, + ]); + let directory_offset = bytes.len() - ENTRY_LEN * 2; + bytes.splice(HEADER_LEN..HEADER_LEN, [0xAA, 0xBB, 0xCC, 0xDD]); + let total = u32::try_from(bytes.len()).expect("total size"); + bytes[12..16].copy_from_slice(&total.to_le_bytes()); + for entry_index in 0..2 { + let field = directory_offset + 4 + entry_index * ENTRY_LEN + 56; + let offset = + u32::from_le_bytes(bytes[field..field + 4].try_into().expect("shifted offset")); + bytes[field..field + 4].copy_from_slice(&(offset + 4).to_le_bytes()); + } + + let original = decode(arc(bytes), ReadProfile::Strict).expect("original"); + let compact = decode( + arc(original.encode(WriteProfile::CanonicalCompact)), + ReadProfile::Strict, + ) + .expect("compact"); + + assert_eq!(compact.entry_count(), original.entry_count()); + assert!(!compact.has_nonzero_preserved_region()); + for original_entry in original.entries() { + let compact_id = compact + .find_bytes(original_entry.name_bytes()) + .expect("compact lookup"); + let compact_entry = compact.entry(compact_id).expect("compact entry"); + let original_meta = original_entry.meta(); + let compact_meta = compact_entry.meta(); + assert_eq!(compact_entry.name_bytes(), original_entry.name_bytes()); + assert_eq!(compact_meta.type_id, original_meta.type_id); + assert_eq!(compact_meta.attr1, original_meta.attr1); + assert_eq!(compact_meta.attr2, original_meta.attr2); + assert_eq!(compact_meta.attr3, original_meta.attr3); + assert_eq!(compact_meta.data_size, original_meta.data_size); + assert_eq!( + compact.payload(compact_id).expect("compact payload"), + original + .payload(original_entry.id()) + .expect("original payload") + ); + } + } + + #[test] + fn editor_payload_update_rewrites_offsets_and_size() { + let bytes = build_archive(&[ + SyntheticEntry { + type_id: 1, + attr1: 10, + attr2: 20, + attr3: 30, + name: "first", + payload: b"a", + }, + SyntheticEntry { + type_id: 2, + attr1: 11, + attr2: 21, + attr3: 31, + name: "second", + payload: b"bb", + }, + ]); + let original = decode(arc(bytes), ReadProfile::Strict).expect("original"); + let mut editor = original.editor().expect("editor"); + + editor + .set_payload(EntryId(0), b"replacement".to_vec()) + .expect("set payload"); + let edited = + decode(arc(editor.encode().expect("encode")), ReadProfile::Strict).expect("edited"); + let first = edited.entry(EntryId(0)).expect("first"); + let second = edited.entry(EntryId(1)).expect("second"); + + assert_eq!( + edited.payload(EntryId(0)).expect("first payload"), + b"replacement" + ); + assert_eq!(edited.payload(EntryId(1)).expect("second payload"), b"bb"); + assert_eq!(first.meta().data_size, 11); + assert_eq!(first.meta().data_offset, HEADER_LEN_U32); + assert_eq!(second.meta().data_offset % 8, 0); + assert!(second.meta().data_offset > first.meta().data_offset + first.meta().data_size); + } + + #[test] + fn editor_rename_rebuilds_search_mapping() { + let bytes = build_archive(&[ + SyntheticEntry { + type_id: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "zeta", + payload: b"z", + }, + SyntheticEntry { + type_id: 2, + attr1: 0, + attr2: 0, + attr3: 0, + name: "middle", + payload: b"m", + }, + ]); + let original = decode(arc(bytes), ReadProfile::Strict).expect("original"); + let mut editor = original.editor().expect("editor"); + + editor.rename(EntryId(0), b"alpha").expect("rename"); + let edited = + decode(arc(editor.encode().expect("encode")), ReadProfile::Strict).expect("edited"); + + assert!(edited.lookup_order_valid()); + assert_eq!(edited.find("alpha"), Some(EntryId(0))); + assert_eq!(edited.find("zeta"), None); + assert_eq!(edited.find("middle"), Some(EntryId(1))); + assert_eq!( + edited.entry(EntryId(0)).expect("entry").name_bytes(), + b"alpha" + ); + } + + #[test] + fn editor_rejects_invalid_authoring_names() { + let bytes = build_archive(&[SyntheticEntry { + type_id: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "one", + payload: b"x", + }]); + let original = decode(arc(bytes), ReadProfile::Strict).expect("original"); + let mut editor = original.editor().expect("editor"); + + assert!(matches!( + editor.rename(EntryId(0), [b'A'; NAME_LEN]), + Err(NresError::AuthoringNameTooLong { len, max }) + if len == NAME_LEN && max == NAME_LEN - 1 + )); + assert!(matches!( + editor.rename(EntryId(0), b"bad\0name"), + Err(NresError::AuthoringNameContainsNul { offset }) if offset == 3 + )); + + let encoded = editor.encode().expect("encode"); + let unchanged = decode(arc(encoded), ReadProfile::Strict).expect("unchanged"); + assert_eq!( + unchanged.entry(EntryId(0)).expect("entry").name_bytes(), + b"one" + ); + } + + #[test] + fn rejects_empty_names_and_resolves_duplicates_to_first_entry() { + let empty_name = build_archive(&[SyntheticEntry { + type_id: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "", + payload: b"x", + }]); + assert!(matches!( + decode(arc(empty_name), ReadProfile::Strict), + Err(NresError::EmptyName { id: 0 }) + )); + + let duplicate_names = build_archive(&[ + SyntheticEntry { + type_id: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "duplicate", + payload: b"a", + }, + SyntheticEntry { + type_id: 2, + attr1: 0, + attr2: 0, + attr3: 0, + name: "DUPLICATE", + payload: b"b", + }, + ]); + let doc = decode(arc(duplicate_names), ReadProfile::Strict).expect("duplicates"); + assert_eq!(doc.find("duplicate"), Some(EntryId(0))); + assert_eq!(doc.payload(EntryId(0)).expect("first duplicate"), b"a"); + assert_eq!(doc.payload(EntryId(1)).expect("second duplicate"), b"b"); + } + + #[test] + fn generated_archives_preserve_lossless_and_canonical_semantics() { + let cases = [ + vec![SyntheticEntry { + type_id: 1, + attr1: 10, + attr2: 20, + attr3: 30, + name: "single.bin", + payload: b"x", + }], + vec![ + SyntheticEntry { + type_id: 2, + attr1: 1, + attr2: 2, + attr3: 3, + name: "zeta.bin", + payload: b"zzzz", + }, + SyntheticEntry { + type_id: 3, + attr1: 4, + attr2: 5, + attr3: 6, + name: "Alpha.bin", + payload: b"a", + }, + SyntheticEntry { + type_id: 4, + attr1: 7, + attr2: 8, + attr3: 9, + name: "middle.bin", + payload: b"middle", + }, + ], + ]; + + for entries in cases { + let bytes = build_archive(&entries); + let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("generated nres"); + assert_eq!(doc.encode(WriteProfile::Lossless), bytes); + + let compact = doc.encode(WriteProfile::CanonicalCompact); + let compact_doc = decode(arc(compact), ReadProfile::Strict).expect("compact nres"); + assert_eq!(compact_doc.entry_count(), doc.entry_count()); + for original in doc.entries() { + let compact_id = compact_doc + .find_bytes(original.name_bytes()) + .expect("compact entry"); + let compact_entry = compact_doc.entry(compact_id).expect("compact meta"); + assert_eq!(compact_entry.meta().type_id, original.meta().type_id); + assert_eq!(compact_entry.meta().attr1, original.meta().attr1); + assert_eq!(compact_entry.meta().attr2, original.meta().attr2); + assert_eq!(compact_entry.meta().attr3, original.meta().attr3); + assert_eq!( + compact_doc.payload(compact_id).expect("compact payload"), + doc.payload(original.id()).expect("original payload") + ); + } + } + } + + #[test] + fn generated_editor_updates_roundtrip() { + for count in 1..5usize { + let entries = (0..count) + .map(|idx| SyntheticEntry { + type_id: u32::try_from(idx + 1).expect("type id"), + attr1: u32::try_from(idx).expect("attr1"), + attr2: u32::try_from(idx * 2).expect("attr2"), + attr3: u32::try_from(idx * 3).expect("attr3"), + name: ["a.bin", "b.bin", "c.bin", "d.bin"][idx], + payload: ["a", "bb", "ccc", "dddd"][idx].as_bytes(), + }) + .collect::>(); + let doc = decode(arc(build_archive(&entries)), ReadProfile::Strict).expect("nres"); + let mut editor = doc.editor().expect("editor"); + editor + .set_payload(EntryId(0), format!("replacement-{count}").into_bytes()) + .expect("set payload"); + editor + .rename(EntryId(0), format!("renamed-{count}.bin").as_bytes()) + .expect("rename"); + + let edited = + decode(arc(editor.encode().expect("encode")), ReadProfile::Strict).expect("edited"); + assert_eq!(edited.entry_count(), count); + let renamed = edited + .find(&format!("RENAMED-{count}.BIN")) + .expect("renamed"); + assert_eq!(renamed, EntryId(0)); + assert_eq!( + edited.payload(EntryId(0)).expect("payload"), + format!("replacement-{count}").as_bytes() + ); + } + } + + #[test] + fn arbitrary_small_inputs_do_not_panic_or_overallocate() { + for len in 0..160usize { + let mut bytes = vec![0u8; len]; + if len >= 4 { + bytes[0..4].copy_from_slice(b"NRes"); + } + if len >= 8 { + bytes[4..8].copy_from_slice(&VERSION_0100.to_le_bytes()); + } + if len >= 12 { + bytes[8..12].copy_from_slice(&u32::try_from(len % 4).expect("count").to_le_bytes()); + } + if len >= 16 { + bytes[12..16].copy_from_slice(&u32::try_from(len).expect("len").to_le_bytes()); + } + + let strict = + std::panic::catch_unwind(|| decode(arc(bytes.clone()), ReadProfile::Strict)); + let compatible = + std::panic::catch_unwind(|| decode(arc(bytes.clone()), ReadProfile::Compatible)); + assert!(strict.is_ok()); + assert!(compatible.is_ok()); + } + } + + #[test] + fn licensed_corpora_nres_roundtrip_gates() { + let part1 = corpus_gate("IS", 120, 6_804).expect("part 1 NRes gate"); + let part2 = corpus_gate("IS2", 134, 8_171).expect("part 2 NRes gate"); + + assert!(!part1.has_nonzero_preserved_region); + assert!( + part2.has_nonzero_preserved_region, + "part 2 must keep the known non-zero unindexed NRes regression case" + ); + } + + #[derive(Clone, Copy, Debug, Default)] + struct CorpusGateResult { + has_nonzero_preserved_region: bool, + } + + fn corpus_gate( + name: &str, + expected_files: usize, + expected_entries: usize, + ) -> Result { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("testdata") + .join(name); + if !root.is_dir() { + return Err(format!( + "licensed corpus root is missing: {}", + root.display() + )); + } + let mut files = Vec::new(); + collect_nres_files(&root, &mut files).map_err(|err| err.to_string())?; + files.sort(); + + let mut total_entries = 0usize; + let mut has_nonzero_preserved_region = false; + for path in &files { + let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?; + let doc = decode(arc(bytes.clone()), ReadProfile::Strict) + .map_err(|err| format!("{}: {err}", path.display()))?; + total_entries = total_entries + .checked_add(doc.entry_count()) + .ok_or_else(|| "entry count overflow".to_string())?; + if doc.has_nonzero_preserved_region() { + has_nonzero_preserved_region = true; + } + for entry in doc.entries() { + let id = doc + .find_bytes(entry.name_bytes()) + .ok_or_else(|| format!("lookup failed: {}", path.display()))?; + let found = doc + .entry(id) + .ok_or_else(|| format!("lookup returned invalid id: {}", path.display()))?; + if cmp_ascii_casefold(found.name_bytes(), entry.name_bytes()) != Ordering::Equal { + return Err(format!("lookup mismatch: {}", path.display())); + } + let _payload = doc + .payload(entry.id()) + .map_err(|err| format!("{}: {err}", path.display()))?; + } + if doc.encode(WriteProfile::Lossless) != bytes { + return Err(format!("lossless roundtrip mismatch: {}", path.display())); + } + } + + if files.len() != expected_files { + return Err(format!( + "{name}: expected {expected_files} NRes files, got {}", + files.len() + )); + } + if total_entries != expected_entries { + return Err(format!( + "{name}: expected {expected_entries} NRes entries, got {total_entries}" + )); + } + Ok(CorpusGateResult { + has_nonzero_preserved_region, + }) + } + + fn collect_nres_files(root: &Path, out: &mut Vec) -> std::io::Result<()> { + for entry in fs::read_dir(root)? { + let path = entry?.path(); + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with('.')) + { + continue; + } + if path.is_dir() { + collect_nres_files(&path, out)?; + continue; + } + if path.is_file() { + let bytes = fs::read(&path)?; + if bytes.starts_with(b"NRes") { + out.push(path); + } + } + } + Ok(()) + } + + fn build_archive(entries: &[SyntheticEntry<'_>]) -> Vec { + let mut out = vec![0; HEADER_LEN]; + 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 = (0..entries.len()).collect(); + order.sort_by(|left, right| { + cmp_ascii_casefold( + entries[*left].name.as_bytes(), + entries[*right].name.as_bytes(), + ) + }); + for (index, entry) in 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, + u32::try_from(entry.payload.len()).expect("payload size"), + ); + push_u32(&mut out, entry.attr3); + let mut name = [0; NAME_LEN]; + 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(&VERSION_0100.to_le_bytes()); + out[8..12].copy_from_slice( + &u32::try_from(entries.len()) + .expect("entry 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 arc(bytes: Vec) -> Arc<[u8]> { + Arc::from(bytes.into_boxed_slice()) + } +} diff --git a/crates/fparkan-path/Cargo.toml b/crates/fparkan-path/Cargo.toml new file mode 100644 index 0000000..57664b7 --- /dev/null +++ b/crates/fparkan-path/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "fparkan-path" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] + +[lints] +workspace = true diff --git a/crates/fparkan-path/src/lib.rs b/crates/fparkan-path/src/lib.rs new file mode 100644 index 0000000..d15aae8 --- /dev/null +++ b/crates/fparkan-path/src/lib.rs @@ -0,0 +1,259 @@ +#![forbid(unsafe_code)] +//! Legacy path normalization and ASCII lookup semantics. + +use std::fmt; +use std::path::{Path, PathBuf}; + +/// Original bytes. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OriginalPathBytes(pub Vec); + +impl OriginalPathBytes { + /// Returns the preserved byte image. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + + /// Returns the preserved byte image as an owned vector. + #[must_use] + pub fn into_vec(self) -> Vec { + self.0 + } +} + +/// Normalized relative path. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct NormalizedPath(String); + +impl NormalizedPath { + /// Returns string view. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Normalized path paired with its original byte image. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NormalizedPathWithOriginal { + normalized: NormalizedPath, + original: OriginalPathBytes, +} + +impl NormalizedPathWithOriginal { + /// Returns normalized path. + #[must_use] + pub fn normalized(&self) -> &NormalizedPath { + &self.normalized + } + + /// Returns original path bytes. + #[must_use] + pub fn original(&self) -> &OriginalPathBytes { + &self.original + } + + /// Splits into normalized and original path parts. + #[must_use] + pub fn into_parts(self) -> (NormalizedPath, OriginalPathBytes) { + (self.normalized, self.original) + } +} + +/// ASCII lookup key. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct LookupKey(pub Vec); + +/// Resource name bytes. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct ResourceName(pub Vec); + +/// Path policy. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PathPolicy { + /// Strict legacy relative resource path. + StrictLegacy, + /// Host compatible relative path. + HostCompatible, +} + +/// Path error. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PathError { + /// Empty path. + Empty, + /// Embedded NUL. + EmbeddedNul, + /// Absolute path. + Absolute, + /// Parent traversal. + ParentTraversal, + /// Host path escape. + EscapesRoot, + /// Invalid UTF-8 after normalization. + InvalidUtf8, +} + +impl fmt::Display for PathError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self:?}") + } +} + +impl std::error::Error for PathError {} + +/// Normalizes a relative path. +/// +/// # Errors +/// +/// Returns [`PathError`] when the input is empty, absolute, contains an +/// embedded NUL, attempts parent traversal, or is not valid UTF-8 after +/// legacy separator normalization. +pub fn normalize_relative(raw: &[u8], _policy: PathPolicy) -> Result { + if raw.is_empty() { + return Err(PathError::Empty); + } + if raw.contains(&0) { + return Err(PathError::EmbeddedNul); + } + let text = std::str::from_utf8(raw).map_err(|_| PathError::InvalidUtf8)?; + if text.starts_with('/') || text.starts_with('\\') || has_drive_prefix(text) { + return Err(PathError::Absolute); + } + let mut parts = Vec::new(); + for part in text.split(['/', '\\']) { + if part.is_empty() || part == "." { + continue; + } + if part == ".." { + return Err(PathError::ParentTraversal); + } + parts.push(part); + } + if parts.is_empty() { + return Err(PathError::Empty); + } + Ok(NormalizedPath(parts.join("/"))) +} + +/// Normalizes a relative path while preserving its original bytes. +/// +/// # Errors +/// +/// Returns [`PathError`] under the same conditions as [`normalize_relative`]. +pub fn normalize_relative_with_original( + raw: &[u8], + policy: PathPolicy, +) -> Result { + let normalized = normalize_relative(raw, policy)?; + Ok(NormalizedPathWithOriginal { + normalized, + original: OriginalPathBytes(raw.to_vec()), + }) +} + +fn has_drive_prefix(text: &str) -> bool { + let bytes = text.as_bytes(); + bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() +} + +/// Builds an ASCII-only casefold lookup key. +#[must_use] +pub fn ascii_lookup_key(raw: &[u8]) -> LookupKey { + LookupKey(raw.iter().map(u8::to_ascii_uppercase).collect()) +} + +/// Ensures relative path does not escape. +/// +/// # Errors +/// +/// Returns [`PathError::ParentTraversal`] when a normalized segment attempts +/// to address a parent directory. +pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> { + if rel.0.split('/').any(|part| part == "..") { + Err(PathError::ParentTraversal) + } else { + Ok(()) + } +} + +/// Joins normalized path under root. +/// +/// # Errors +/// +/// Returns [`PathError`] if the normalized path fails the escape check. +pub fn join_under(root: &Path, rel: &NormalizedPath) -> Result { + reject_escape(rel)?; + Ok(root.join(rel.as_str())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_separators() { + let p = normalize_relative(b"DATA\\MAPS/INTRO/Land.msh", PathPolicy::StrictLegacy) + .expect("path"); + assert_eq!(p.as_str(), "DATA/MAPS/INTRO/Land.msh"); + } + + #[test] + fn rejects_escape() { + assert_eq!( + normalize_relative(b"DATA/../secret", PathPolicy::StrictLegacy), + Err(PathError::ParentTraversal) + ); + } + + #[test] + fn rejects_absolute_drive_and_nul_paths() { + assert_eq!( + normalize_relative(b"/DATA/MAPS", PathPolicy::StrictLegacy), + Err(PathError::Absolute) + ); + assert_eq!( + normalize_relative(b"C:\\DATA\\MAPS", PathPolicy::StrictLegacy), + Err(PathError::Absolute) + ); + assert_eq!( + normalize_relative(b"DATA\0MAPS", PathPolicy::StrictLegacy), + Err(PathError::EmbeddedNul) + ); + } + + #[test] + fn join_under_keeps_normalized_path_below_root() { + let rel = normalize_relative(b"DATA/MAPS/Land.map", PathPolicy::StrictLegacy) + .expect("relative path"); + let joined = join_under(Path::new("/game"), &rel).expect("join"); + + assert_eq!(joined, PathBuf::from("/game/DATA/MAPS/Land.map")); + } + + #[test] + fn ascii_casefold_does_not_unicode_fold() { + assert_eq!(ascii_lookup_key(b"AbZ\xD0"), LookupKey(b"ABZ\xD0".to_vec())); + } + + #[test] + fn non_ascii_original_bytes_remain_stable() { + let raw = "DATA/Тест.bin".as_bytes(); + let path = normalize_relative_with_original(raw, PathPolicy::StrictLegacy) + .expect("path with non-ASCII UTF-8"); + + assert_eq!(path.normalized().as_str().as_bytes(), raw); + assert_eq!(path.original().as_bytes(), raw); + assert_eq!(&ascii_lookup_key(raw).0[5..13], &raw[5..13]); + } + + #[test] + fn original_separators_and_raw_bytes_are_preserved() { + let raw = b"DATA\\Maps/Intro\\Land.msh"; + let path = normalize_relative_with_original(raw, PathPolicy::StrictLegacy).expect("path"); + + assert_eq!(path.normalized().as_str(), "DATA/Maps/Intro/Land.msh"); + assert_eq!(path.original().as_bytes(), raw); + } +} diff --git a/crates/fparkan-platform/Cargo.toml b/crates/fparkan-platform/Cargo.toml new file mode 100644 index 0000000..dc103f2 --- /dev/null +++ b/crates/fparkan-platform/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "fparkan-platform" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] + +[lints] +workspace = true diff --git a/crates/fparkan-platform/src/lib.rs b/crates/fparkan-platform/src/lib.rs new file mode 100644 index 0000000..cfa021b --- /dev/null +++ b/crates/fparkan-platform/src/lib.rs @@ -0,0 +1,93 @@ +#![forbid(unsafe_code)] +//! Platform ports for clocks, input, events, windows, and graphics requests. + +/// Monotonic instant. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct MonotonicInstant(pub u64); + +/// Monotonic clock. +pub trait MonotonicClock { + /// Current instant. + fn now(&self) -> MonotonicInstant; +} + +/// Platform event. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PlatformEvent { + /// Quit requested. + Quit, +} + +/// Platform error. +#[derive(Debug)] +pub enum PlatformError { + /// Backend failed. + Backend, +} + +impl std::fmt::Display for PlatformError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +impl std::error::Error for PlatformError {} + +/// Event source. +pub trait EventSource { + /// Polls events. + /// + /// # Errors + /// + /// Returns [`PlatformError`] when the backend cannot collect events. + fn poll(&mut self, out: &mut Vec) -> Result<(), PlatformError>; +} + +/// Physical size. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PhysicalSize { + /// Width. + pub width: u32, + /// Height. + pub height: u32, +} + +/// Window port. +pub trait WindowPort { + /// Drawable size. + fn drawable_size(&self) -> PhysicalSize; + /// Presents. + /// + /// # Errors + /// + /// Returns [`PlatformError`] when the backend cannot present the current + /// frame. + fn present(&mut self) -> Result<(), PlatformError>; +} + +/// Graphics profile. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GraphicsProfile { + /// Desktop core. + DesktopCore, + /// Embedded profile. + Embedded, +} + +/// Version. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Version { + /// Major. + pub major: u8, + /// Minor. + pub minor: u8, +} + +/// Graphics context request. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GraphicsContextRequest { + /// Profile. + pub profile: GraphicsProfile, + /// Version. + pub version: Version, +} diff --git a/crates/fparkan-prototype/Cargo.toml b/crates/fparkan-prototype/Cargo.toml new file mode 100644 index 0000000..4825faf --- /dev/null +++ b/crates/fparkan-prototype/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "fparkan-prototype" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +encoding_rs = "0.8" +fparkan-binary = { path = "../fparkan-binary" } +fparkan-material = { path = "../fparkan-material" } +fparkan-msh = { path = "../fparkan-msh" } +fparkan-nres = { path = "../fparkan-nres" } +fparkan-path = { path = "../fparkan-path" } +fparkan-resource = { path = "../fparkan-resource" } +fparkan-texm = { path = "../fparkan-texm" } +fparkan-vfs = { path = "../fparkan-vfs" } + +[lints] +workspace = true diff --git a/crates/fparkan-prototype/src/lib.rs b/crates/fparkan-prototype/src/lib.rs new file mode 100644 index 0000000..4efafa1 --- /dev/null +++ b/crates/fparkan-prototype/src/lib.rs @@ -0,0 +1,2114 @@ +#![forbid(unsafe_code)] +//! Prototype registry and unit DAT primitives. + +use encoding_rs::WINDOWS_1251; +use fparkan_binary::{checked_count_bytes, Cursor, DecodeError}; +use fparkan_material::{decode_wear, resolve_material, WEAR_KIND}; +use fparkan_msh::{decode_msh, validate_msh, MshError}; +use fparkan_nres::ReadProfile; +use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName}; +use fparkan_resource::{ + archive_path, resource_name, ResourceError, ResourceKey, ResourceRepository, +}; +use fparkan_texm::decode_texm; +use fparkan_vfs::{Vfs, VfsError}; +use std::sync::Arc; + +const MESH_KIND: u32 = 0x4853_454D; +const UNIT_DAT_MIN_SIZE: usize = 0x48; +const UNIT_DAT_MAGIC: u32 = 0x0000_F0F1; +const PROTOTYPE_INHERITANCE_DEPTH_LIMIT: usize = 32; + +/// Prototype key. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct PrototypeKey(pub ResourceName); + +/// 64-byte object reference record. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ObjectRefRecord { + /// Archive raw bytes. + pub archive_raw: [u8; 32], + /// Resource raw bytes. + pub resource_raw: [u8; 32], +} + +/// Unit DAT document. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UnitDat { + /// Opaque eight-byte header before component records. + pub header_opaque: [u8; 8], + /// Component records. + pub records: Vec, +} + +/// Unit DAT binding used by mission object references. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UnitDatBinding { + /// Flags. + pub flags: u32, + /// Archive raw bytes. + pub archive_raw: [u8; 32], + /// Model key raw bytes. + pub model_raw: [u8; 32], +} + +/// Unit DAT component. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UnitComponentRecord { + /// Archive raw bytes. + pub archive_raw: [u8; 32], + /// Resource raw bytes. + pub resource_raw: [u8; 32], + /// Component kind. + pub kind: u32, + /// Parent or link. + pub parent_or_link: i32, + /// Description raw bytes. + pub description_raw: [u8; 32], + /// Opaque tail. + pub tail0: u32, + /// Opaque tail. + pub tail1: u32, +} + +/// Prototype geometry. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PrototypeGeometry { + /// Mesh resource. + Mesh(ResourceKey), + /// Valid non-geometric prototype. + NonGeometric, +} + +/// Effective prototype. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EffectivePrototype { + /// Key. + pub key: PrototypeKey, + /// Geometry. + pub geometry: PrototypeGeometry, + /// Resolution source. + pub source: PrototypeSource, + /// Resource dependencies discovered while resolving this prototype. + pub dependencies: Vec, +} + +/// Prototype resolution source. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PrototypeSource { + /// Direct archive/key lookup. + DirectArchive, + /// `objects.rlb` registry lookup. + ObjectsRegistry, + /// Unit DAT binding. + UnitDat, +} + +/// Prototype graph. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PrototypeGraph { + /// Requested keys. + pub roots: Vec, + /// Effective prototype requests after unit DAT expansion. + pub prototype_requests: Vec, +} + +/// Mission prototype dependency graph report. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PrototypeGraphReport { + /// Requested mission roots. + pub root_count: usize, + /// Roots that point at unit DAT files. + pub unit_reference_count: usize, + /// Roots that point directly at prototype keys. + pub direct_reference_count: usize, + /// Component records reached from unit DAT files. + pub unit_component_count: usize, + /// Prototype requests that resolved to an effective prototype. + pub resolved_count: usize, + /// Mesh dependencies reached by resolved prototypes. + pub mesh_dependency_count: usize, + /// WEAR requests derived from reached mesh dependencies. + pub wear_request_count: usize, + /// WEAR entries successfully decoded. + pub wear_resolved_count: usize, + /// Material slots requested by decoded WEAR tables. + pub material_slot_count: usize, + /// MAT0 material entries successfully decoded. + pub material_resolved_count: usize, + /// Texture requests derived from MAT0 texture phases. + pub texture_request_count: usize, + /// Texm texture entries successfully decoded. + pub texture_resolved_count: usize, + /// Lightmap requests declared by decoded WEAR tables. + pub lightmap_request_count: usize, + /// Lightmap Texm entries successfully decoded. + pub lightmap_resolved_count: usize, + /// Graph failures tied to mission root edges. + pub failures: Vec, +} + +impl PrototypeGraphReport { + /// Returns true when all reachable mission roots resolved. + #[must_use] + pub fn is_success(&self) -> bool { + self.failures.is_empty() + && self.resolved_count == self.direct_reference_count + self.unit_component_count + } +} + +/// Prototype graph failure tied to a root edge. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PrototypeGraphFailure { + /// Root index in the requested mission order. + pub root_index: usize, + /// Raw mission resource bytes. + pub resource_raw: Vec, + /// Edge that failed. + pub edge: PrototypeGraphEdge, + /// Failure detail. + pub message: String, +} + +/// Prototype graph edge. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PrototypeGraphEdge { + /// Mission object to unit DAT binding. + MissionToUnitDat, + /// Mission object to `objects.rlb` registry. + MissionToObjectsRegistry, + /// Unit DAT component to prototype key. + UnitDatToComponent, + /// Resolved prototype to mesh archive/resource. + PrototypeToMesh, + /// Mesh resource to matching WEAR table. + MeshToWear, + /// WEAR material slot to MAT0. + WearToMaterial, + /// MAT0 phase to Texm. + MaterialToTexture, + /// WEAR lightmap slot to lightmap Texm. + WearToLightmap, +} + +/// Prototype error. +#[derive(Debug)] +pub enum PrototypeError { + /// Decode error. + Decode(DecodeError), + /// Invalid size. + InvalidSize, + /// Invalid unit DAT magic. + InvalidUnitDatMagic(u32), + /// Invalid path. + InvalidPath(String), + /// VFS error. + Vfs(String), + /// Resource repository error. + Resource(String), + /// Referenced mesh is present but invalid. + InvalidMesh(String), +} + +impl From for PrototypeError { + fn from(value: DecodeError) -> Self { + Self::Decode(value) + } +} + +impl From for PrototypeError { + fn from(value: ResourceError) -> Self { + Self::Resource(value.to_string()) + } +} + +impl From for PrototypeError { + fn from(value: MshError) -> Self { + Self::InvalidMesh(value.to_string()) + } +} + +impl From for PrototypeError { + fn from(value: VfsError) -> Self { + Self::Vfs(value.to_string()) + } +} + +impl std::fmt::Display for PrototypeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +impl std::error::Error for PrototypeError {} + +/// Decodes an `objects.rlb` registry entry as 64-byte records. +/// +/// # Errors +/// +/// Returns [`PrototypeError::InvalidSize`] when the payload is not composed of +/// whole 64-byte records. +pub fn decode_registry_entry(payload: &[u8]) -> Result, PrototypeError> { + if !payload.len().is_multiple_of(64) { + return Err(PrototypeError::InvalidSize); + } + let mut out = Vec::with_capacity(payload.len() / 64); + for chunk in payload.chunks_exact(64) { + let mut archive_raw = [0; 32]; + let mut resource_raw = [0; 32]; + archive_raw.copy_from_slice(&chunk[..32]); + resource_raw.copy_from_slice(&chunk[32..64]); + out.push(ObjectRefRecord { + archive_raw, + resource_raw, + }); + } + Ok(out) +} + +/// Decodes unit DAT as an eight-byte header followed by `N * 112` bytes. +/// +/// # Errors +/// +/// Returns [`PrototypeError`] when the payload is too small or contains a +/// partial component record. +pub fn decode_unit_dat(payload: &[u8]) -> Result { + if payload.len() < 8 { + return Err(PrototypeError::InvalidSize); + } + let mut header_opaque = [0; 8]; + header_opaque.copy_from_slice(&payload[..8]); + let remaining = payload.len().saturating_sub(8) as u64; + if !remaining.is_multiple_of(112) { + return Err(PrototypeError::InvalidSize); + } + let record_count = remaining / 112; + let bytes = checked_count_bytes(record_count, 112, remaining)?; + if bytes as u64 != remaining { + return Err(PrototypeError::InvalidSize); + } + let mut cursor = Cursor::new(&payload[8..]); + let mut records = Vec::with_capacity( + usize::try_from(record_count).map_err(|_| DecodeError::IntegerOverflow)?, + ); + for _ in 0..record_count { + let mut archive_raw = [0; 32]; + let mut resource_raw = [0; 32]; + let mut description_raw = [0; 32]; + archive_raw.copy_from_slice(cursor.read_exact(32)?); + resource_raw.copy_from_slice(cursor.read_exact(32)?); + let kind = cursor.read_u32_le()?; + let parent_or_link = cursor.read_i32_le()?; + description_raw.copy_from_slice(cursor.read_exact(32)?); + let tail0 = cursor.read_u32_le()?; + let tail1 = cursor.read_u32_le()?; + records.push(UnitComponentRecord { + archive_raw, + resource_raw, + kind, + parent_or_link, + description_raw, + tail0, + tail1, + }); + } + cursor.require_eof()?; + Ok(UnitDat { + header_opaque, + records, + }) +} + +/// Decodes a mission unit DAT binding. +/// +/// # Errors +/// +/// Returns [`PrototypeError`] when the DAT file is too small, has the wrong +/// magic, or does not contain both archive and model keys. +pub fn decode_unit_dat_binding(payload: &[u8]) -> Result { + if payload.len() < UNIT_DAT_MIN_SIZE { + return Err(PrototypeError::InvalidSize); + } + let magic = u32::from_le_bytes( + payload[0..4] + .try_into() + .map_err(|_| PrototypeError::InvalidSize)?, + ); + if magic != UNIT_DAT_MAGIC { + return Err(PrototypeError::InvalidUnitDatMagic(magic)); + } + let flags = u32::from_le_bytes( + payload[4..8] + .try_into() + .map_err(|_| PrototypeError::InvalidSize)?, + ); + let mut archive_raw = [0; 32]; + let mut model_raw = [0; 32]; + archive_raw.copy_from_slice(&payload[0x08..0x28]); + model_raw.copy_from_slice(&payload[0x28..0x48]); + if cstr_bytes(&archive_raw).is_empty() || cstr_bytes(&model_raw).is_empty() { + return Err(PrototypeError::InvalidSize); + } + Ok(UnitDatBinding { + flags, + archive_raw, + model_raw, + }) +} + +/// Resolves one prototype request through unit DAT, `objects.rlb`, and direct mesh lookup. +/// +/// # Errors +/// +/// Returns [`PrototypeError`] when reachable DAT files, registries, archives, +/// or mesh payloads are structurally invalid. +pub fn resolve_prototype( + repository: &dyn ResourceRepository, + vfs: &dyn Vfs, + resource: &ResourceName, +) -> Result, PrototypeError> { + if has_extension_bytes(&resource.0, b"dat") { + return resolve_unit_dat_first_component(repository, vfs, resource); + } + + resolve_direct_prototype(repository, resource) +} + +fn resolve_direct_prototype( + repository: &dyn ResourceRepository, + resource: &ResourceName, +) -> Result, PrototypeError> { + let objects = + archive_path(b"objects.rlb").map_err(|err| PrototypeError::InvalidPath(err.to_string()))?; + resolve_archive_model( + repository, + &objects, + resource, + PrototypeSource::ObjectsRegistry, + ) +} + +struct ResolvedPrototypeRequests { + expected_count: usize, + prototypes: Vec, +} + +fn resolve_prototype_requests( + repository: &dyn ResourceRepository, + vfs: &dyn Vfs, + resource: &ResourceName, +) -> Result { + if has_extension_bytes(&resource.0, b"dat") { + return resolve_unit_dat_prototype_requests(repository, vfs, resource); + } + + let prototype = resolve_direct_prototype(repository, resource)?; + Ok(ResolvedPrototypeRequests { + expected_count: 1, + prototypes: prototype.into_iter().collect(), + }) +} + +fn resolve_unit_dat_first_component( + repository: &dyn ResourceRepository, + vfs: &dyn Vfs, + resource: &ResourceName, +) -> Result, PrototypeError> { + let expansion = resolve_unit_dat_prototype_requests(repository, vfs, resource)?; + Ok(expansion.prototypes.into_iter().next()) +} + +fn resolve_unit_dat_prototype_requests( + repository: &dyn ResourceRepository, + vfs: &dyn Vfs, + resource: &ResourceName, +) -> Result { + let dat_path = normalized_path_from_name(resource)?; + let bytes = match vfs.read(&dat_path) { + Ok(bytes) => bytes, + Err(VfsError::NotFound(_)) => { + return Ok(ResolvedPrototypeRequests { + expected_count: 0, + prototypes: Vec::new(), + }); + } + Err(err) => return Err(err.into()), + }; + + if let Ok(unit) = decode_unit_dat(&bytes) { + if !unit.records.is_empty() { + let mut prototypes = Vec::with_capacity(unit.records.len()); + for record in &unit.records { + let prototype = resolve_unit_component(repository, record)?.ok_or_else(|| { + PrototypeError::Resource(format!( + "unit component {} did not resolve", + String::from_utf8_lossy(cstr_bytes(&record.resource_raw)) + )) + })?; + prototypes.push(prototype); + } + return Ok(ResolvedPrototypeRequests { + expected_count: unit.records.len(), + prototypes, + }); + } + } + + let binding = decode_unit_dat_binding(&bytes)?; + let archive = + normalized_path_from_name(&ResourceName(cstr_bytes(&binding.archive_raw).to_vec()))?; + let model = ResourceName(cstr_bytes(&binding.model_raw).to_vec()); + let prototype = resolve_archive_model(repository, &archive, &model, PrototypeSource::UnitDat)?; + Ok(ResolvedPrototypeRequests { + expected_count: 1, + prototypes: prototype.into_iter().collect(), + }) +} + +fn resolve_unit_component( + repository: &dyn ResourceRepository, + record: &UnitComponentRecord, +) -> Result, PrototypeError> { + let archive = + normalized_path_from_name(&ResourceName(cstr_bytes(&record.archive_raw).to_vec()))?; + let resource = ResourceName(cstr_bytes(&record.resource_raw).to_vec()); + if resource.0.is_empty() { + return Ok(None); + } + resolve_archive_model(repository, &archive, &resource, PrototypeSource::UnitDat) +} + +/// Resolves many roots and records every resolved root in a graph. +/// +/// # Errors +/// +/// Returns [`PrototypeError`] when any reachable root fails with a structural +/// error. +pub fn build_prototype_graph( + repository: &dyn ResourceRepository, + vfs: &dyn Vfs, + roots: &[ResourceName], +) -> Result<(PrototypeGraph, Vec), PrototypeError> { + let mut graph = PrototypeGraph::default(); + let mut resolved = Vec::new(); + for root in roots { + let key = PrototypeKey(root.clone()); + graph.roots.push(key); + let expansion = resolve_prototype_requests(repository, vfs, root)?; + for prototype in expansion.prototypes { + graph.prototype_requests.push(prototype.key.clone()); + resolved.push(prototype); + } + } + Ok((graph, resolved)) +} + +/// Resolves many mission roots and records edge-specific graph failures. +/// +/// This function reports per-root failures in [`PrototypeGraphReport`] instead +/// of returning early. +pub fn build_prototype_graph_report( + repository: &dyn ResourceRepository, + vfs: &dyn Vfs, + roots: &[ResourceName], +) -> ( + PrototypeGraph, + Vec, + PrototypeGraphReport, +) { + let mut graph = PrototypeGraph::default(); + let mut resolved = Vec::new(); + let mut report = PrototypeGraphReport { + root_count: roots.len(), + ..PrototypeGraphReport::default() + }; + + for (root_index, root) in roots.iter().enumerate() { + graph.roots.push(PrototypeKey(root.clone())); + let edge = if has_extension_bytes(&root.0, b"dat") { + report.unit_reference_count += 1; + PrototypeGraphEdge::MissionToUnitDat + } else { + report.direct_reference_count += 1; + PrototypeGraphEdge::MissionToObjectsRegistry + }; + + match resolve_prototype_requests(repository, vfs, root) { + Ok(expansion) => { + let expected = expansion.expected_count; + if edge == PrototypeGraphEdge::MissionToUnitDat { + report.unit_component_count += expected; + } + let actual = expansion.prototypes.len(); + for prototype in expansion.prototypes { + graph.prototype_requests.push(prototype.key.clone()); + report.resolved_count += 1; + report.mesh_dependency_count += prototype.dependencies.len(); + resolved.push(prototype); + } + if actual < expected { + report.failures.push(PrototypeGraphFailure { + root_index, + resource_raw: root.0.clone(), + edge, + message: "resource did not resolve to an effective prototype".to_string(), + }); + } + } + Err(err) => report.failures.push(PrototypeGraphFailure { + root_index, + resource_raw: root.0.clone(), + edge: graph_error_edge(edge, &err), + message: err.to_string(), + }), + } + } + + (graph, resolved, report) +} + +/// Extends a graph report by validating visual dependencies for each resolved +/// prototype. +pub fn extend_graph_report_with_visual_dependencies( + repository: &dyn ResourceRepository, + report: &mut PrototypeGraphReport, + prototypes: &[EffectivePrototype], +) { + let texture_archive = archive_path(b"textures.lib").ok(); + let lightmap_archive = archive_path(b"lightmap.lib").ok(); + for (prototype_index, prototype) in prototypes.iter().enumerate() { + let PrototypeGeometry::Mesh(mesh) = &prototype.geometry else { + continue; + }; + report.wear_request_count += 1; + match resolve_wear_table(repository, mesh) { + Ok(table) => { + report.wear_resolved_count += 1; + report.material_slot_count += table.entries.len(); + for (material_index, _entry) in table.entries.iter().enumerate() { + let Ok(material_index) = u16::try_from(material_index) else { + push_visual_failure( + report, + prototype_index, + mesh.name.0.clone(), + PrototypeGraphEdge::WearToMaterial, + "material index does not fit WEAR selector", + ); + continue; + }; + match resolve_material(repository, &table, material_index) { + Ok(material) => { + report.material_resolved_count += 1; + for texture in material.document.texture_requests() { + report.texture_request_count += 1; + match resolve_texm_from_candidates( + repository, + &texture, + [texture_archive.as_ref(), lightmap_archive.as_ref()], + ) { + Ok(()) => report.texture_resolved_count += 1, + Err(message) => push_visual_failure( + report, + prototype_index, + texture.0, + PrototypeGraphEdge::MaterialToTexture, + &message, + ), + } + } + } + Err(err) => push_visual_failure( + report, + prototype_index, + mesh.name.0.clone(), + PrototypeGraphEdge::WearToMaterial, + &err.to_string(), + ), + } + } + for lightmap in &table.lightmaps { + report.lightmap_request_count += 1; + match resolve_texm_from_candidates( + repository, + &lightmap.lightmap, + [lightmap_archive.as_ref(), texture_archive.as_ref()], + ) { + Ok(()) => report.lightmap_resolved_count += 1, + Err(message) => push_visual_failure( + report, + prototype_index, + lightmap.lightmap.0.clone(), + PrototypeGraphEdge::WearToLightmap, + &message, + ), + } + } + } + Err(message) => push_visual_failure( + report, + prototype_index, + mesh.name.0.clone(), + PrototypeGraphEdge::MeshToWear, + &message, + ), + } + } +} + +fn resolve_wear_table( + repository: &dyn ResourceRepository, + mesh: &ResourceKey, +) -> Result { + let archive = repository + .open_archive(&mesh.archive) + .map_err(|err| err.to_string())?; + let wear_name = derive_wear_name(&mesh.name) + .ok_or_else(|| "cannot derive WEAR name from mesh resource".to_string())?; + let handle = repository + .find(archive, &wear_name) + .map_err(|err| err.to_string())? + .ok_or_else(|| { + format!( + "missing WEAR entry {}", + String::from_utf8_lossy(&wear_name.0) + ) + })?; + let info = repository + .entry_info(handle) + .map_err(|err| err.to_string())?; + if info.key.type_id != Some(WEAR_KIND) { + return Err(format!( + "entry {} is not WEAR", + String::from_utf8_lossy(&wear_name.0) + )); + } + let bytes = repository + .read(handle) + .map_err(|err| err.to_string())? + .into_owned(); + decode_wear(&bytes).map_err(|err| err.to_string()) +} + +fn resolve_texm_from_candidates<'a>( + repository: &dyn ResourceRepository, + texture: &ResourceName, + candidates: impl IntoIterator>, +) -> Result<(), String> { + let mut missing_archive = false; + for path in candidates.into_iter().flatten() { + let archive = match repository.open_archive(path) { + Ok(archive) => archive, + Err(ResourceError::MissingArchive) => { + missing_archive = true; + continue; + } + Err(err) => return Err(err.to_string()), + }; + let Some(handle) = repository + .find(archive, texture) + .map_err(|err| err.to_string())? + else { + continue; + }; + let bytes = repository + .read(handle) + .map_err(|err| err.to_string())? + .into_owned(); + decode_texm(Arc::from(bytes.into_boxed_slice())).map_err(|err| err.to_string())?; + return Ok(()); + } + if missing_archive { + Err(format!( + "texture archive missing for {}", + String::from_utf8_lossy(&texture.0) + )) + } else { + Err(format!( + "missing texture {}", + String::from_utf8_lossy(&texture.0) + )) + } +} + +fn push_visual_failure( + report: &mut PrototypeGraphReport, + prototype_index: usize, + resource_raw: Vec, + edge: PrototypeGraphEdge, + message: &str, +) { + report.failures.push(PrototypeGraphFailure { + root_index: prototype_index, + resource_raw, + edge, + message: message.to_string(), + }); +} + +fn derive_wear_name(model_name: &ResourceName) -> Option { + let stem = file_stem_bytes(&model_name.0); + if stem.is_empty() { + return None; + } + let mut out = stem.to_vec(); + out.extend_from_slice(b".wea"); + Some(ResourceName(out)) +} + +fn graph_error_edge(edge: PrototypeGraphEdge, err: &PrototypeError) -> PrototypeGraphEdge { + match err { + PrototypeError::InvalidMesh(_) => PrototypeGraphEdge::PrototypeToMesh, + PrototypeError::Decode(_) + | PrototypeError::InvalidSize + | PrototypeError::InvalidUnitDatMagic(_) + | PrototypeError::InvalidPath(_) + | PrototypeError::Vfs(_) + | PrototypeError::Resource(_) => edge, + } +} + +fn resolve_archive_model( + repository: &dyn ResourceRepository, + archive: &NormalizedPath, + model_key: &ResourceName, + source: PrototypeSource, +) -> Result, PrototypeError> { + if archive.as_str().eq_ignore_ascii_case("objects.rlb") { + if let Some(prototype) = resolve_objects_registry_model(repository, archive, model_key)? { + return Ok(Some(prototype)); + } + } + + let Some(mesh) = find_mesh_resource(repository, archive, model_key)? else { + return Ok(None); + }; + Ok(Some(effective(model_key.clone(), mesh, source))) +} + +fn resolve_objects_registry_model( + repository: &dyn ResourceRepository, + registry_archive: &NormalizedPath, + object_key: &ResourceName, +) -> Result, PrototypeError> { + let Some(refs) = + collect_registry_refs(repository, registry_archive, object_key, &mut Vec::new(), 0)? + else { + return Ok(None); + }; + + let mut missing_mesh_refs = Vec::new(); + for item in refs.iter().filter(|item| is_explicit_mesh_ref(item)) { + if let Some(prototype) = + resolve_object_ref_model(repository, object_key, item, cstr_bytes(&item.resource_raw))? + { + return Ok(Some(prototype)); + } + missing_mesh_refs.push(describe_object_ref(item)); + } + if !missing_mesh_refs.is_empty() { + return Err(PrototypeError::Resource(format!( + "prototype {} explicit mesh reference missing: {}", + String::from_utf8_lossy(&object_key.0), + missing_mesh_refs.join(" -> ") + ))); + } + + Ok(Some(EffectivePrototype { + key: PrototypeKey(object_key.clone()), + geometry: PrototypeGeometry::NonGeometric, + source: PrototypeSource::ObjectsRegistry, + dependencies: Vec::new(), + })) +} + +fn collect_registry_refs( + repository: &dyn ResourceRepository, + registry_archive: &NormalizedPath, + object_key: &ResourceName, + stack: &mut Vec, + depth: usize, +) -> Result>, PrototypeError> { + if depth > PROTOTYPE_INHERITANCE_DEPTH_LIMIT { + return Err(PrototypeError::Resource(format!( + "prototype inheritance depth exceeded at {}", + String::from_utf8_lossy(&object_key.0) + ))); + } + if stack + .iter() + .any(|item| eq_ignore_ascii_case(&item.0, &object_key.0)) + { + return Err(PrototypeError::Resource(format!( + "prototype inheritance cycle at {}", + String::from_utf8_lossy(&object_key.0) + ))); + } + let archive_id = match repository.open_archive(registry_archive) { + Ok(id) => id, + Err(ResourceError::MissingArchive) => return Ok(None), + Err(err) => return Err(err.into()), + }; + let Some((registry_entry, _matched_name)) = + find_any_candidate(repository, archive_id, &mesh_name_candidates(&object_key.0))? + else { + return Ok(None); + }; + let payload = repository.read(registry_entry)?.into_owned(); + let refs = decode_registry_entry(&payload)?; + let mut effective_refs = Vec::new(); + stack.push(object_key.clone()); + for item in refs { + if archive_name_is(&item.archive_raw, b"objects.rlb") { + let parent_key = ResourceName(cstr_bytes(&item.resource_raw).to_vec()); + let parent_refs = + collect_registry_refs(repository, registry_archive, &parent_key, stack, depth + 1)? + .ok_or_else(|| { + PrototypeError::Resource(format!( + "missing parent prototype {}", + String::from_utf8_lossy(&parent_key.0) + )) + })?; + effective_refs.extend(parent_refs); + } else { + effective_refs.push(item); + } + } + stack.pop(); + + Ok(Some(effective_refs)) +} + +fn resolve_object_ref_model( + repository: &dyn ResourceRepository, + requested: &ResourceName, + item: &ObjectRefRecord, + model_name: &[u8], +) -> Result, PrototypeError> { + let archive = normalized_path_from_name(&ResourceName(cstr_bytes(&item.archive_raw).to_vec()))?; + let Some(mesh) = find_mesh_resource(repository, &archive, &ResourceName(model_name.to_vec()))? + else { + return Ok(None); + }; + Ok(Some(effective( + requested.clone(), + mesh, + PrototypeSource::ObjectsRegistry, + ))) +} + +fn is_explicit_mesh_ref(item: &ObjectRefRecord) -> bool { + has_extension_bytes(cstr_bytes(&item.resource_raw), b"msh") +} + +fn describe_object_ref(item: &ObjectRefRecord) -> String { + format!( + "{}:{}", + String::from_utf8_lossy(cstr_bytes(&item.archive_raw)), + String::from_utf8_lossy(cstr_bytes(&item.resource_raw)) + ) +} + +fn find_mesh_resource( + repository: &dyn ResourceRepository, + archive: &NormalizedPath, + model_key: &ResourceName, +) -> Result, PrototypeError> { + let archive_id = match repository.open_archive(archive) { + Ok(id) => id, + Err(ResourceError::MissingArchive) => return Ok(None), + Err(err) => return Err(err.into()), + }; + let candidates = mesh_name_candidates(&model_key.0); + let Some((handle, matched_name)) = find_any_candidate(repository, archive_id, &candidates)? + else { + return Ok(None); + }; + validate_mesh_payload(repository.read(handle)?.into_owned())?; + Ok(Some(ResourceKey { + archive: archive.clone(), + name: resource_name(matched_name), + type_id: Some(MESH_KIND), + })) +} + +fn validate_mesh_payload(payload: Vec) -> Result<(), PrototypeError> { + let nested = fparkan_nres::decode( + Arc::from(payload.into_boxed_slice()), + ReadProfile::Compatible, + ) + .map_err(|err| PrototypeError::InvalidMesh(err.to_string()))?; + let document = decode_msh(&nested)?; + validate_msh(&document)?; + Ok(()) +} + +fn find_any_candidate( + repository: &dyn ResourceRepository, + archive_id: fparkan_resource::ArchiveId, + candidates: &[Vec], +) -> Result)>, PrototypeError> { + for candidate in candidates { + if let Some(handle) = repository.find(archive_id, &resource_name(candidate))? { + return Ok(Some((handle, candidate.clone()))); + } + } + Ok(None) +} + +fn effective( + requested: ResourceName, + mesh: ResourceKey, + source: PrototypeSource, +) -> EffectivePrototype { + EffectivePrototype { + key: PrototypeKey(requested), + geometry: PrototypeGeometry::Mesh(mesh.clone()), + source, + dependencies: vec![mesh], + } +} + +fn mesh_name_candidates(name: &[u8]) -> Vec> { + let trimmed = trim_ascii(name); + if trimmed.is_empty() { + return Vec::new(); + } + let mut out = Vec::new(); + push_unique_bytes(&mut out, trimmed.to_vec()); + if has_extension_bytes(trimmed, b"msh") { + let stem = file_stem_bytes(trimmed); + if !stem.is_empty() { + push_unique_bytes(&mut out, stem.to_vec()); + } + } else { + let mut with_suffix = trimmed.to_vec(); + with_suffix.extend_from_slice(b".msh"); + push_unique_bytes(&mut out, with_suffix); + } + out +} + +fn push_unique_bytes(items: &mut Vec>, value: Vec) { + if !items.iter().any(|item| eq_ignore_ascii_case(item, &value)) { + items.push(value); + } +} + +fn normalized_path_from_name(name: &ResourceName) -> Result { + let text = legacy_path_text(cstr_bytes(&name.0)); + normalize_relative(text.as_bytes(), PathPolicy::StrictLegacy) + .map_err(|err| PrototypeError::InvalidPath(err.to_string())) +} + +fn legacy_path_text(raw: &[u8]) -> String { + if let Ok(text) = std::str::from_utf8(raw) { + text.to_string() + } else { + let (decoded, _, _) = WINDOWS_1251.decode(raw); + decoded.into_owned() + } +} + +fn cstr_bytes(raw: &[u8]) -> &[u8] { + let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len()); + trim_ascii(&raw[..len]) +} + +fn archive_name_is(raw: &[u8], expected: &[u8]) -> bool { + cstr_bytes(raw).eq_ignore_ascii_case(expected) +} + +fn trim_ascii(bytes: &[u8]) -> &[u8] { + let mut start = 0usize; + let mut end = bytes.len(); + while start < end && bytes[start].is_ascii_whitespace() { + start += 1; + } + while end > start && bytes[end - 1].is_ascii_whitespace() { + end -= 1; + } + &bytes[start..end] +} + +fn has_extension_bytes(name: &[u8], ext: &[u8]) -> bool { + let Some(pos) = name.iter().rposition(|byte| *byte == b'.') else { + return false; + }; + eq_ignore_ascii_case(&name[pos + 1..], ext) +} + +fn file_stem_bytes(name: &[u8]) -> &[u8] { + let file_name = name + .iter() + .rposition(|byte| *byte == b'/' || *byte == b'\\') + .map_or(name, |pos| &name[pos + 1..]); + let Some(dot) = file_name.iter().rposition(|byte| *byte == b'.') else { + return file_name; + }; + &file_name[..dot] +} + +fn eq_ignore_ascii_case(left: &[u8], right: &[u8]) -> bool { + left.eq_ignore_ascii_case(right) +} + +/// Decodes FX/prototype bytes by preserving them for future typed support. +#[must_use] +pub fn preserve_payload(payload: &[u8]) -> Arc<[u8]> { + Arc::from(payload.to_vec().into_boxed_slice()) +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_resource::{archive_path as resource_archive_path, CachedResourceRepository}; + use fparkan_vfs::{DirectoryVfs, MemoryVfs}; + use std::path::Path; + + #[test] + fn registry_requires_record_multiple() { + assert!(decode_registry_entry(&[0; 63]).is_err()); + assert_eq!(decode_registry_entry(&[0; 64]).expect("record").len(), 1); + } + + #[test] + fn registry_zero_records_payload_is_empty() { + let records = decode_registry_entry(&[]).expect("empty registry"); + + assert!(records.is_empty()); + } + + #[test] + fn registry_preserves_bounded_name_tails_and_order() { + let mut bytes = Vec::new(); + let mut first = [0u8; 64]; + first[..9].copy_from_slice(b"arch\0tail"); + first[32..40].copy_from_slice(b"res\0tail"); + bytes.extend_from_slice(&first); + let mut second = [0u8; 64]; + second[..10].copy_from_slice(b"other.rlb\0"); + second[32..43].copy_from_slice(b"second.msh\0"); + bytes.extend_from_slice(&second); + + let records = decode_registry_entry(&bytes).expect("registry records"); + + assert_eq!(records.len(), 2); + assert_eq!(&records[0].archive_raw[..9], b"arch\0tail"); + assert_eq!(&records[0].resource_raw[..8], b"res\0tail"); + assert_eq!(cstr_bytes(&records[0].archive_raw), b"arch"); + assert_eq!(cstr_bytes(&records[1].resource_raw), b"second.msh"); + } + + #[test] + fn unit_zero_records_uses_exact_size() { + let bytes = [0_u8; 8]; + let unit = decode_unit_dat(&bytes).expect("unit"); + assert!(unit.records.is_empty()); + } + + #[test] + fn unit_dat_one_record_uses_exact_size_formula() { + let bytes = build_unit_dat(&[(b"objects.rlb".as_slice(), b"component".as_slice())]); + let unit = decode_unit_dat(&bytes).expect("unit"); + + assert_eq!(bytes.len(), 8 + 112); + assert_eq!(unit.records.len(), 1); + assert_eq!(cstr_bytes(&unit.records[0].archive_raw), b"objects.rlb"); + assert_eq!(cstr_bytes(&unit.records[0].resource_raw), b"component"); + } + + #[test] + fn unit_dat_rejects_truncated_record() { + let mut bytes = build_unit_dat(&[(b"objects.rlb".as_slice(), b"component".as_slice())]); + bytes.pop(); + + assert!(matches!( + decode_unit_dat(&bytes), + Err(PrototypeError::InvalidSize) + )); + } + + #[test] + fn unit_dat_preserves_header_description_tail_and_parent_link() { + let mut bytes = build_unit_dat(&[(b"objects.rlb".as_slice(), b"component".as_slice())]); + bytes[0..8].copy_from_slice(&[0xF1, 0xF0, 1, 2, 3, 4, 5, 6]); + bytes[8 + 68..8 + 72].copy_from_slice(&(-7_i32).to_le_bytes()); + let description = b"desc\0tail"; + bytes[8 + 72..8 + 72 + description.len()].copy_from_slice(description); + bytes[8 + 104..8 + 108].copy_from_slice(&0x1122_3344_u32.to_le_bytes()); + bytes[8 + 108..8 + 112].copy_from_slice(&0x5566_7788_u32.to_le_bytes()); + + let unit = decode_unit_dat(&bytes).expect("unit"); + let record = &unit.records[0]; + assert_eq!(unit.header_opaque, [0xF1, 0xF0, 1, 2, 3, 4, 5, 6]); + assert_eq!(record.parent_or_link, -7); + assert_eq!(&record.description_raw[..description.len()], description); + assert_eq!(record.tail0, 0x1122_3344); + assert_eq!(record.tail1, 0x5566_7788); + } + + #[test] + fn unit_dat_accepts_full_description_without_nul() { + let mut bytes = build_unit_dat(&[(b"objects.rlb".as_slice(), b"component".as_slice())]); + bytes[8 + 72..8 + 104].copy_from_slice(b"12345678901234567890123456789012"); + + let unit = decode_unit_dat(&bytes).expect("unit"); + + assert_eq!( + &unit.records[0].description_raw, + b"12345678901234567890123456789012" + ); + } + + #[test] + fn unit_dat_preserves_positive_parent_link() { + let mut bytes = build_unit_dat(&[(b"objects.rlb".as_slice(), b"component".as_slice())]); + bytes[8 + 68..8 + 72].copy_from_slice(&12_i32.to_le_bytes()); + + let unit = decode_unit_dat(&bytes).expect("unit"); + + assert_eq!(unit.records[0].parent_or_link, 12); + } + + #[test] + fn resolves_synthetic_objects_registry_model() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + let mesh = minimal_msh_payload(); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[( + b"s_tree_04".as_slice(), + build_object_refs(&[(b"static.rlb".as_slice(), b"s_tree_0_04.msh".as_slice())]) + .as_slice(), + )]) + .into_boxed_slice(), + ), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[(b"s_tree_0_04.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"s_tree_04")) + .expect("resolve") + .expect("prototype"); + + assert_eq!(resolved.source, PrototypeSource::ObjectsRegistry); + let PrototypeGeometry::Mesh(mesh) = resolved.geometry else { + panic!("expected mesh"); + }; + assert_eq!(mesh.archive.as_str(), "static.rlb"); + assert!(mesh.name.0.eq_ignore_ascii_case(b"s_tree_0_04.msh")); + } + + #[test] + fn graph_report_records_resolved_roots_and_failures() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + let mesh = minimal_msh_payload(); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[( + b"s_tree_04".as_slice(), + build_object_refs(&[(b"static.rlb".as_slice(), b"s_tree_0_04.msh".as_slice())]) + .as_slice(), + )]) + .into_boxed_slice(), + ), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[(b"s_tree_0_04.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let roots = [resource_name(b"s_tree_04"), resource_name(b"missing_key")]; + let (graph, resolved, report) = build_prototype_graph_report(&repo, vfs.as_ref(), &roots); + + assert_eq!(graph.roots.len(), 2); + assert_eq!(resolved.len(), 1); + assert_eq!(report.root_count, 2); + assert_eq!(report.direct_reference_count, 2); + assert_eq!(report.unit_reference_count, 0); + assert_eq!(report.resolved_count, 1); + assert_eq!(report.failures.len(), 1); + assert_eq!(report.failures[0].root_index, 1); + assert_eq!( + report.failures[0].edge, + PrototypeGraphEdge::MissionToObjectsRegistry + ); + assert!(!report.is_success()); + } + + #[test] + fn resolves_synthetic_unit_dat_binding() { + let mut vfs = MemoryVfs::default(); + let dat_path = resource_archive_path(b"UNITS/AUTO/unit.dat").expect("dat path"); + let archive_path = resource_archive_path(b"units.rlb").expect("archive path"); + let mesh = minimal_msh_payload(); + vfs.insert( + dat_path, + Arc::from(build_unit_dat_binding(b"units.rlb", b"unit_model").into_boxed_slice()), + ); + vfs.insert( + archive_path, + Arc::from( + build_nres(&[(b"unit_model.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let resolved = + resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"UNITS/AUTO/unit.dat")) + .expect("resolve") + .expect("prototype"); + + assert_eq!(resolved.source, PrototypeSource::UnitDat); + let PrototypeGeometry::Mesh(mesh) = resolved.geometry else { + panic!("expected mesh"); + }; + assert_eq!(mesh.archive.as_str(), "units.rlb"); + assert!(mesh.name.0.eq_ignore_ascii_case(b"unit_model.msh")); + } + + #[test] + fn unit_dat_expands_components_in_order() { + let mut vfs = MemoryVfs::default(); + let dat_path = resource_archive_path(b"UNITS/AUTO/compound.dat").expect("dat path"); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + let mesh = minimal_msh_payload(); + vfs.insert( + dat_path, + Arc::from( + build_unit_dat(&[ + (b"objects.rlb".as_slice(), b"component_a".as_slice()), + (b"objects.rlb".as_slice(), b"component_b".as_slice()), + ]) + .into_boxed_slice(), + ), + ); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[ + ( + b"component_a".as_slice(), + build_object_refs(&[( + b"static.rlb".as_slice(), + b"component_a.msh".as_slice(), + )]) + .as_slice(), + ), + ( + b"component_b".as_slice(), + build_object_refs(&[( + b"static.rlb".as_slice(), + b"component_b.msh".as_slice(), + )]) + .as_slice(), + ), + ]) + .into_boxed_slice(), + ), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[ + (b"component_a.msh".as_slice(), mesh.as_slice()), + (b"component_b.msh".as_slice(), mesh.as_slice()), + ]) + .into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let roots = [resource_name(b"UNITS/AUTO/compound.dat")]; + let (graph, resolved, report) = build_prototype_graph_report(&repo, vfs.as_ref(), &roots); + + assert_eq!(graph.roots.len(), 1); + assert_eq!(graph.prototype_requests.len(), 2); + assert_eq!(graph.prototype_requests[0].0 .0, b"component_a"); + assert_eq!(graph.prototype_requests[1].0 .0, b"component_b"); + assert_eq!(resolved.len(), 2); + assert_eq!(report.unit_reference_count, 1); + assert_eq!(report.unit_component_count, 2); + assert_eq!(report.resolved_count, 2); + assert!(report.is_success()); + } + + #[test] + fn objects_registry_inheritance_merges_parent_then_local_refs() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + let fortif_path = resource_archive_path(b"fortif.rlb").expect("fortif path"); + let mesh = minimal_msh_payload(); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[ + ( + b"parent_proto".as_slice(), + build_object_refs(&[( + b"static.rlb".as_slice(), + b"parent_proto.msh".as_slice(), + )]) + .as_slice(), + ), + ( + b"child_proto".as_slice(), + build_object_refs(&[ + (b"objects.rlb".as_slice(), b"parent_proto".as_slice()), + (b"fortif.rlb".as_slice(), b"child_proto.bas".as_slice()), + ]) + .as_slice(), + ), + ]) + .into_boxed_slice(), + ), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[(b"parent_proto.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(), + ), + ); + vfs.insert( + fortif_path, + Arc::from(build_nres(&[(b"child_proto.bas".as_slice(), b"base")]).into_boxed_slice()), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"child_proto")) + .expect("resolve") + .expect("prototype"); + + let PrototypeGeometry::Mesh(mesh) = resolved.geometry else { + panic!("expected inherited mesh"); + }; + assert_eq!(mesh.archive.as_str(), "static.rlb"); + assert!(mesh.name.0.eq_ignore_ascii_case(b"parent_proto.msh")); + } + + #[test] + fn objects_registry_inheritance_resolves_multiple_levels() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + let mesh = minimal_msh_payload(); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[ + ( + b"grandparent".as_slice(), + build_object_refs(&[( + b"static.rlb".as_slice(), + b"grandparent.msh".as_slice(), + )]) + .as_slice(), + ), + ( + b"parent".as_slice(), + build_object_refs(&[( + b"objects.rlb".as_slice(), + b"grandparent".as_slice(), + )]) + .as_slice(), + ), + ( + b"child".as_slice(), + build_object_refs(&[(b"objects.rlb".as_slice(), b"parent".as_slice())]) + .as_slice(), + ), + ]) + .into_boxed_slice(), + ), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[(b"grandparent.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"child")) + .expect("resolve") + .expect("prototype"); + + let PrototypeGeometry::Mesh(mesh) = resolved.geometry else { + panic!("expected inherited mesh"); + }; + assert!(mesh.name.0.eq_ignore_ascii_case(b"grandparent.msh")); + } + + #[test] + fn base_only_registry_entry_is_nongeometric() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let fortif_path = resource_archive_path(b"fortif.rlb").expect("fortif path"); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[( + b"base_only".as_slice(), + build_object_refs(&[(b"fortif.rlb".as_slice(), b"base_only.bas".as_slice())]) + .as_slice(), + )]) + .into_boxed_slice(), + ), + ); + vfs.insert( + fortif_path, + Arc::from(build_nres(&[(b"base_only.bas".as_slice(), b"base")]).into_boxed_slice()), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"base_only")) + .expect("resolve") + .expect("prototype"); + + assert_eq!(resolved.geometry, PrototypeGeometry::NonGeometric); + assert!(resolved.dependencies.is_empty()); + } + + #[test] + fn objects_registry_inheritance_rejects_direct_cycle() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[( + b"self_cycle".as_slice(), + build_object_refs(&[(b"objects.rlb".as_slice(), b"self_cycle".as_slice())]) + .as_slice(), + )]) + .into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let err = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"self_cycle")) + .expect_err("cycle"); + + assert!(err.to_string().contains("cycle")); + } + + #[test] + fn objects_registry_inheritance_rejects_indirect_cycle() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[ + ( + b"cycle_a".as_slice(), + build_object_refs(&[(b"objects.rlb".as_slice(), b"cycle_b".as_slice())]) + .as_slice(), + ), + ( + b"cycle_b".as_slice(), + build_object_refs(&[(b"objects.rlb".as_slice(), b"cycle_a".as_slice())]) + .as_slice(), + ), + ]) + .into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let err = + resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"cycle_a")).expect_err("cycle"); + + assert!(err.to_string().contains("cycle")); + } + + #[test] + fn invalid_referenced_msh_is_error() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[( + b"bad_tree".as_slice(), + build_object_refs(&[(b"static.rlb".as_slice(), b"bad_tree.msh".as_slice())]) + .as_slice(), + )]) + .into_boxed_slice(), + ), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[(b"bad_tree.msh".as_slice(), b"not an nres".as_slice())]) + .into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + + let err = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"bad_tree")) + .expect_err("invalid mesh"); + + assert!(matches!(err, PrototypeError::InvalidMesh(_))); + } + + #[test] + fn missing_referenced_archive_reports_root_chain() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[( + b"broken".as_slice(), + build_object_refs(&[(b"missing.rlb".as_slice(), b"broken.msh".as_slice())]) + .as_slice(), + )]) + .into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + + let (_graph, _resolved, report) = + build_prototype_graph_report(&repo, vfs.as_ref(), &[resource_name(b"broken")]); + + assert_eq!(report.failures.len(), 1); + assert_eq!(report.failures[0].resource_raw, b"broken"); + assert_eq!( + report.failures[0].edge, + PrototypeGraphEdge::MissionToObjectsRegistry + ); + assert!(report.failures[0].message.contains("broken")); + assert!(report.failures[0] + .message + .contains("missing.rlb:broken.msh")); + } + + #[test] + fn missing_referenced_resource_reports_root_chain() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[( + b"broken".as_slice(), + build_object_refs(&[(b"static.rlb".as_slice(), b"missing.msh".as_slice())]) + .as_slice(), + )]) + .into_boxed_slice(), + ), + ); + vfs.insert(static_path, Arc::from(build_nres(&[]).into_boxed_slice())); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + + let (_graph, _resolved, report) = + build_prototype_graph_report(&repo, vfs.as_ref(), &[resource_name(b"broken")]); + + assert_eq!(report.failures.len(), 1); + assert_eq!(report.failures[0].resource_raw, b"broken"); + assert!(report.failures[0] + .message + .contains("static.rlb:missing.msh")); + } + + #[test] + fn first_existing_explicit_msh_is_selected_in_order() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + let mesh = minimal_msh_payload(); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[( + b"ordered".as_slice(), + build_object_refs(&[ + (b"static.rlb".as_slice(), b"missing.msh".as_slice()), + (b"static.rlb".as_slice(), b"ordered.msh".as_slice()), + ]) + .as_slice(), + )]) + .into_boxed_slice(), + ), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[(b"ordered.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + + let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"ordered")) + .expect("ordered resolve") + .expect("prototype"); + + let PrototypeGeometry::Mesh(mesh) = resolved.geometry else { + panic!("expected mesh"); + }; + assert!(mesh.name.0.eq_ignore_ascii_case(b"ordered.msh")); + } + + #[test] + fn objects_registry_inheritance_rejects_depth_limit() { + let mut names = Vec::new(); + let mut payloads = Vec::new(); + for index in 0..34usize { + names.push(format!("proto_{index}").into_bytes()); + payloads.push(build_object_refs(&[( + b"objects.rlb".as_slice(), + format!("proto_{}", index + 1).as_bytes(), + )])); + } + let entries = names + .iter() + .zip(payloads.iter()) + .map(|(name, payload)| (name.as_slice(), payload.as_slice())) + .collect::>(); + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + vfs.insert( + objects_path, + Arc::from(build_nres(&entries).into_boxed_slice()), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + + let err = + resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"proto_0")).expect_err("depth"); + + assert!(err.to_string().contains("depth exceeded")); + } + + #[test] + fn generated_acyclic_prototype_graph_resolves_deterministically() { + let first = generated_acyclic_graph(&[0, 1, 2, 3, 4, 5]); + let second = generated_acyclic_graph(&[5, 4, 3, 2, 1, 0]); + + assert_eq!(first.0, second.0); + assert_eq!(first.1, second.1); + assert_eq!(first.2, second.2); + } + + #[test] + fn arbitrary_unit_and_registry_bytes_are_bounded_and_panic_free() { + for len in 0..256usize { + let bytes = vec![0xA5; len]; + let unit = std::panic::catch_unwind(|| decode_unit_dat(&bytes)); + let registry = std::panic::catch_unwind(|| decode_registry_entry(&bytes)); + + assert!(unit.is_ok()); + assert!(registry.is_ok()); + } + } + + #[test] + fn resolver_cache_invalidates_when_archive_fingerprint_changes() { + let root = temp_dir("resolver-cache"); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + std::fs::write( + root.join(objects_path.as_str()), + build_nres(&[( + b"dynamic".as_slice(), + build_object_refs(&[(b"static.rlb".as_slice(), b"dynamic.msh".as_slice())]) + .as_slice(), + )]), + ) + .expect("objects.rlb"); + std::fs::write( + root.join(static_path.as_str()), + build_nres(&[(b"dynamic.msh".as_slice(), b"not an nres".as_slice())]), + ) + .expect("initial static.rlb"); + let vfs = Arc::new(DirectoryVfs::new(&root)); + let repo = CachedResourceRepository::new(vfs.clone()); + + let err = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"dynamic")) + .expect_err("invalid initial mesh"); + assert!(matches!(err, PrototypeError::InvalidMesh(_))); + + std::fs::write( + root.join(static_path.as_str()), + build_nres(&[(b"dynamic.msh".as_slice(), minimal_msh_payload().as_slice())]), + ) + .expect("updated static.rlb"); + let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"dynamic")) + .expect("updated resolve") + .expect("prototype"); + + let PrototypeGeometry::Mesh(mesh) = resolved.geometry else { + panic!("expected mesh"); + }; + assert!(mesh.name.0.eq_ignore_ascii_case(b"dynamic.msh")); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn resolves_known_part1_registry_cases() { + let root = corpus_root("IS").expect("part 1 root"); + let vfs = Arc::new(DirectoryVfs::new(&root)); + let repo = CachedResourceRepository::new(vfs.clone()); + let cases = [ + (b"r_h_01".as_slice(), "bases.rlb", b"r_h_01.msh".as_slice()), + ( + b"s_tree_04".as_slice(), + "static.rlb", + b"s_tree_0_04.msh".as_slice(), + ), + ( + b"fr_m_brige".as_slice(), + "fortif.rlb", + b"fr_m_brige.msh".as_slice(), + ), + ]; + + for (key, archive, model) in cases { + let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(key)) + .unwrap_or_else(|err| panic!("failed to resolve {:?}: {err}", key)) + .unwrap_or_else(|| panic!("missing prototype for {:?}", key)); + let PrototypeGeometry::Mesh(mesh) = resolved.geometry else { + panic!("expected mesh"); + }; + assert_eq!(mesh.archive.as_str().to_ascii_lowercase(), archive); + assert!(mesh.name.0.eq_ignore_ascii_case(model)); + } + } + + #[test] + fn resolves_some_registry_entries_in_both_corpora() { + for corpus in ["IS", "IS2"] { + let root = corpus_root(corpus).expect("corpus root"); + let objects = std::fs::read(root.join("objects.rlb")).expect("objects.rlb"); + let document = fparkan_nres::decode( + Arc::from(objects.into_boxed_slice()), + fparkan_nres::ReadProfile::Compatible, + ) + .expect("objects.rlb document"); + let vfs = Arc::new(DirectoryVfs::new(&root)); + let repo = CachedResourceRepository::new(vfs.clone()); + let mut resolved = 0usize; + + for entry in document.entries().iter().take(64) { + if resolve_prototype(&repo, vfs.as_ref(), &resource_name(entry.name_bytes())) + .unwrap_or_else(|err| panic!("{corpus} {:?}: {err}", entry.name_bytes())) + .is_some() + { + resolved += 1; + } + } + + assert!(resolved > 0, "{corpus}: no registry entries resolved"); + } + } + + #[test] + fn licensed_corpora_unit_dat_parse_counts() { + let cases = [("IS", 425, 5_219), ("IS2", 676, 8_145)]; + for (corpus, expected_files, expected_records) in cases { + let root = corpus_root(corpus).expect("corpus root"); + let mut dat_paths = Vec::new(); + collect_unit_dat_files(&root, &mut dat_paths); + dat_paths.sort(); + let mut records = 0usize; + for path in &dat_paths { + let bytes = std::fs::read(path).expect("unit DAT"); + let unit = decode_unit_dat(&bytes).expect("unit DAT decode"); + for record in &unit.records { + assert!( + archive_name_is(&record.archive_raw, b"objects.rlb"), + "{}: unexpected component archive {:?}", + path.display(), + cstr_bytes(&record.archive_raw) + ); + assert_eq!( + record.kind, + 1, + "{}: unexpected component kind", + path.display() + ); + } + records += unit.records.len(); + } + assert_eq!(dat_paths.len(), expected_files, "{corpus} unit DAT files"); + assert_eq!(records, expected_records, "{corpus} unit DAT records"); + } + } + + #[test] + fn licensed_corpora_registry_payloads_are_record_aligned() { + for corpus in ["IS", "IS2"] { + let root = corpus_root(corpus).expect("corpus root"); + let objects = std::fs::read(root.join("objects.rlb")).expect("objects.rlb"); + let document = fparkan_nres::decode( + Arc::from(objects.into_boxed_slice()), + fparkan_nres::ReadProfile::Compatible, + ) + .expect("objects.rlb document"); + + assert!(document.entry_count() > 0, "{corpus}: empty objects.rlb"); + for entry in document.entries() { + let payload = document.payload(entry.id()).expect("registry payload"); + assert!( + payload.len().is_multiple_of(64), + "{corpus}: registry payload for {:?} is not 64-byte aligned", + entry.name_bytes() + ); + decode_registry_entry(payload).expect("registry payload decode"); + } + } + } + + fn collect_unit_dat_files(dir: &Path, out: &mut Vec) { + let mut children: Vec<_> = std::fs::read_dir(dir) + .expect("read dir") + .map(|entry| entry.expect("entry").path()) + .collect(); + children.sort(); + for child in children { + if child.is_dir() { + collect_unit_dat_files(&child, out); + } else if child + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("dat")) + && child.components().any(|component| { + component + .as_os_str() + .to_str() + .is_some_and(|text| text.eq_ignore_ascii_case("UNITS")) + }) + { + out.push(child); + } + } + } + + fn corpus_root(name: &str) -> Option { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("testdata") + .join(name); + root.is_dir().then_some(root) + } + + fn generated_acyclic_graph( + order: &[usize], + ) -> ( + PrototypeGraph, + Vec, + PrototypeGraphReport, + ) { + let names = (0..6usize) + .map(|index| format!("node_{index}").into_bytes()) + .collect::>(); + let payloads = (0..6usize) + .map(|index| { + if index == 0 { + build_object_refs(&[(b"static.rlb".as_slice(), b"node_0.msh".as_slice())]) + } else { + build_object_refs(&[( + b"objects.rlb".as_slice(), + format!("node_{}", index - 1).as_bytes(), + )]) + } + }) + .collect::>(); + let entries = order + .iter() + .map(|index| (names[*index].as_slice(), payloads[*index].as_slice())) + .collect::>(); + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + vfs.insert( + objects_path, + Arc::from(build_nres(&entries).into_boxed_slice()), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[(b"node_0.msh".as_slice(), minimal_msh_payload().as_slice())]) + .into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + build_prototype_graph_report( + &repo, + vfs.as_ref(), + &[resource_name(b"node_5"), resource_name(b"node_3")], + ) + } + + fn temp_dir(name: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!( + "fparkan-prototype-{name}-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::create_dir_all(&path).expect("temp dir"); + path + } + + fn build_unit_dat_binding(archive: &[u8], model: &[u8]) -> Vec { + let mut out = vec![0; UNIT_DAT_MIN_SIZE]; + out[0..4].copy_from_slice(&UNIT_DAT_MAGIC.to_le_bytes()); + copy_cstr(&mut out[0x08..0x28], archive); + copy_cstr(&mut out[0x28..0x48], model); + out + } + + fn build_unit_dat(components: &[(&[u8], &[u8])]) -> Vec { + let mut out = vec![0; 8]; + out[0..4].copy_from_slice(&UNIT_DAT_MAGIC.to_le_bytes()); + for (index, (archive, resource)) in components.iter().enumerate() { + let mut record = [0; 112]; + copy_cstr(&mut record[0..32], archive); + copy_cstr(&mut record[32..64], resource); + record[64..68].copy_from_slice(&1_u32.to_le_bytes()); + record[68..72].copy_from_slice( + &i32::try_from(index) + .map_or(-1, |value| value.saturating_sub(1)) + .to_le_bytes(), + ); + copy_cstr(&mut record[72..104], b"component"); + out.extend_from_slice(&record); + } + out + } + + fn build_object_refs(items: &[(&[u8], &[u8])]) -> Vec { + let mut out = Vec::with_capacity(items.len() * 64); + for (archive, resource) in items { + let mut chunk = [0; 64]; + copy_cstr(&mut chunk[..32], archive); + copy_cstr(&mut chunk[32..], resource); + out.extend_from_slice(&chunk); + } + out + } + + fn build_nres(entries: &[(&[u8], &[u8])]) -> Vec { + let entries = entries + .iter() + .map(|(name, payload)| TestEntry { + type_id: 0, + attr3: 0, + name, + payload, + }) + .collect::>(); + build_nres_typed(&entries) + } + + fn minimal_msh_payload() -> Vec { + build_nres_typed(&[ + TestEntry { + type_id: 1, + attr3: 38, + name: b"Res1", + payload: &[], + }, + TestEntry { + type_id: 2, + attr3: 0, + name: b"Res2", + payload: &[0; 0x8c], + }, + TestEntry { + type_id: 3, + attr3: 0, + name: b"Res3", + payload: &[], + }, + TestEntry { + type_id: 6, + attr3: 0, + name: b"Res6", + payload: &[], + }, + TestEntry { + type_id: 13, + attr3: 0, + name: b"Res13", + payload: &[], + }, + ]) + } + + struct TestEntry<'a> { + type_id: u32, + attr3: u32, + name: &'a [u8], + payload: &'a [u8], + } + + fn build_nres_typed(entries: &[TestEntry<'_>]) -> Vec { + 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 = (0..entries.len()).collect(); + order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name)); + for (idx, 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"), + ); + push_u32(&mut out, entry.attr3); + let mut name_raw = [0; 36]; + copy_cstr(&mut name_raw, entry.name); + 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 copy_cstr(dst: &mut [u8], src: &[u8]) { + let len = dst.len().saturating_sub(1).min(src.len()); + dst[..len].copy_from_slice(&src[..len]); + } + + fn push_u32(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); + } +} diff --git a/crates/fparkan-render/Cargo.toml b/crates/fparkan-render/Cargo.toml new file mode 100644 index 0000000..b045d68 --- /dev/null +++ b/crates/fparkan-render/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fparkan-render" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-world = { path = "../fparkan-world" } + +[lints] +workspace = true diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs new file mode 100644 index 0000000..a2f18d6 --- /dev/null +++ b/crates/fparkan-render/src/lib.rs @@ -0,0 +1,554 @@ +#![forbid(unsafe_code)] +//! Backend-neutral render commands and deterministic captures. + +use fparkan_world::OriginalObjectId; + +/// Immutable camera data visible to command generation. +#[derive(Clone, Debug, PartialEq)] +pub struct CameraSnapshot { + /// View matrix, row-major. + pub view: [f32; 16], + /// Projection matrix, row-major. + pub projection: [f32; 16], +} + +impl Default for CameraSnapshot { + fn default() -> Self { + Self { + view: identity_transform(), + projection: identity_transform(), + } + } +} + +/// Draw id. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct DrawId(pub u64); + +/// GPU mesh id. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GpuMeshId(pub u64); + +/// GPU material id. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GpuMaterialId(pub u64); + +/// Render phase. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum RenderPhase { + /// Terrain. + Terrain, + /// Opaque. + Opaque, + /// Alpha test. + AlphaTest, + /// Transparent. + Transparent, + /// Effects. + Effects, + /// Debug. + Debug, + /// UI. + Ui, +} + +/// Index range. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct IndexRange { + /// Start. + pub start: u32, + /// Count. + pub count: u32, +} + +/// A draw candidate in an immutable render snapshot. +#[derive(Clone, Debug, PartialEq)] +pub struct RenderSnapshotDraw { + /// Draw id. + pub id: DrawId, + /// Phase. + pub phase: RenderPhase, + /// Object id. + pub object_id: Option, + /// Mesh. + pub mesh: GpuMeshId, + /// Material table after WEAR/MAT0 fallback resolution. + pub material_slots: Vec, + /// Batch material index into [`Self::material_slots`]. + pub material_index: u16, + /// Node transform matrix, row-major. + pub transform: [f32; 16], + /// Index range. + pub range: IndexRange, + /// Stable sort order. + pub stable_order: u64, +} + +/// Immutable backend-neutral render snapshot. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RenderSnapshot { + /// Camera data for the frame. + pub camera: CameraSnapshot, + /// Draw candidates gathered from world/assets. + pub draws: Vec, +} + +/// Command generation profile. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct RenderProfile { + /// Include UI phase commands when present. + pub include_ui: bool, +} + +/// Draw command. +#[derive(Clone, Debug, PartialEq)] +pub struct DrawCommand { + /// Draw id. + pub id: DrawId, + /// Phase. + pub phase: RenderPhase, + /// Object id. + pub object_id: Option, + /// Mesh. + pub mesh: GpuMeshId, + /// Material. + pub material: GpuMaterialId, + /// Transform matrix, row-major. + pub transform: [f32; 16], + /// Index range. + pub range: IndexRange, + /// Stable sort order. + pub stable_order: u64, +} + +/// Render command. +#[derive(Clone, Debug, PartialEq)] +pub enum RenderCommand { + /// Begin frame. + BeginFrame, + /// Draw. + Draw(DrawCommand), + /// End frame. + EndFrame, +} + +/// Render command list. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RenderCommandList { + /// Commands. + pub commands: Vec, +} + +/// Frame output. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct FrameOutput; + +/// Render error. +#[derive(Debug)] +pub enum RenderError { + /// Invalid range. + InvalidRange, + /// Invalid draw range with command-generation context. + InvalidDrawRange { + /// Draw id. + draw_id: DrawId, + /// Stable sort order. + stable_order: u64, + /// Range start. + start: u32, + /// Range count. + count: u32, + }, + /// A batch material index did not resolve through the material table. + MaterialIndexOutOfBounds { + /// Draw id. + draw_id: DrawId, + /// Requested material index. + material_index: u16, + /// Available material slots. + material_count: usize, + }, +} + +impl std::fmt::Display for RenderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +impl std::error::Error for RenderError {} + +/// Builds a deterministic command list from an immutable render snapshot. +/// +/// # Errors +/// +/// Returns [`RenderError`] when a draw has an invalid index range or a material +/// index that cannot be resolved through its material slot table. +pub fn build_commands( + snapshot: &RenderSnapshot, + profile: RenderProfile, +) -> Result { + let mut draws = snapshot + .draws + .iter() + .filter(|draw| profile.include_ui || draw.phase != RenderPhase::Ui) + .collect::>(); + draws.sort_by_key(|draw| (draw.phase, draw.stable_order, draw.id)); + + let mut commands = Vec::with_capacity(draws.len() + 2); + commands.push(RenderCommand::BeginFrame); + for draw in draws { + if draw.range.count == 0 { + return Err(RenderError::InvalidDrawRange { + draw_id: draw.id, + stable_order: draw.stable_order, + start: draw.range.start, + count: draw.range.count, + }); + } + let material = draw + .material_slots + .get(usize::from(draw.material_index)) + .copied() + .ok_or(RenderError::MaterialIndexOutOfBounds { + draw_id: draw.id, + material_index: draw.material_index, + material_count: draw.material_slots.len(), + })?; + commands.push(RenderCommand::Draw(DrawCommand { + id: draw.id, + phase: draw.phase, + object_id: draw.object_id, + mesh: draw.mesh, + material, + transform: draw.transform, + range: draw.range, + stable_order: draw.stable_order, + })); + } + commands.push(RenderCommand::EndFrame); + Ok(RenderCommandList { commands }) +} + +/// Backend port. +pub trait RenderBackend { + /// Executes commands. + /// + /// # Errors + /// + /// Returns [`RenderError`] when the command stream is malformed for the + /// backend. + fn execute(&mut self, commands: &RenderCommandList) -> Result; +} + +/// Backend that validates commands and intentionally produces no pixels. +#[derive(Clone, Debug, Default)] +pub struct NullBackend; + +impl RenderBackend for NullBackend { + fn execute(&mut self, commands: &RenderCommandList) -> Result { + validate_commands(commands)?; + Ok(FrameOutput) + } +} + +/// Backend that stores deterministic command captures for verification. +#[derive(Clone, Debug, Default)] +pub struct RecordingBackend { + captures: Vec>, +} + +impl RecordingBackend { + /// Returns all captures in submission order. + #[must_use] + pub fn captures(&self) -> &[Vec] { + &self.captures + } + + /// Returns the most recent capture. + #[must_use] + pub fn last_capture(&self) -> Option<&[u8]> { + self.captures.last().map(Vec::as_slice) + } + + /// Clears stored captures without changing backend behavior. + pub fn clear(&mut self) { + self.captures.clear(); + } +} + +impl RenderBackend for RecordingBackend { + fn execute(&mut self, commands: &RenderCommandList) -> Result { + let capture = canonical_capture(commands)?; + self.captures.push(capture); + Ok(FrameOutput) + } +} + +/// Builds a canonical capture. +/// +/// # Errors +/// +/// Returns [`RenderError`] when a draw command contains an invalid index range. +pub fn canonical_capture(commands: &RenderCommandList) -> Result, RenderError> { + validate_commands(commands)?; + let mut out = Vec::new(); + for command in &commands.commands { + match command { + RenderCommand::BeginFrame => out.extend_from_slice(b"B\n"), + RenderCommand::EndFrame => out.extend_from_slice(b"E\n"), + RenderCommand::Draw(draw) => { + out.extend_from_slice( + format!( + "D,{:?},{},{},{},{}\n", + draw.phase, draw.id.0, draw.mesh.0, draw.material.0, draw.stable_order + ) + .as_bytes(), + ); + } + } + } + Ok(out) +} + +fn validate_commands(commands: &RenderCommandList) -> Result<(), RenderError> { + for command in &commands.commands { + if let RenderCommand::Draw(draw) = command { + if draw.range.count == 0 { + return Err(RenderError::InvalidRange); + } + } + } + Ok(()) +} + +fn identity_transform() -> [f32; 16] { + [ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snapshot_draw( + id: u64, + phase: RenderPhase, + material_index: u16, + stable_order: u64, + ) -> RenderSnapshotDraw { + RenderSnapshotDraw { + id: DrawId(id), + phase, + object_id: Some(OriginalObjectId(u32::try_from(id).expect("id fits"))), + mesh: GpuMeshId(10 + id), + material_slots: vec![GpuMaterialId(31), GpuMaterialId(37)], + material_index, + transform: identity_transform(), + range: IndexRange { start: 0, count: 3 }, + stable_order, + } + } + + #[test] + fn capture_is_stable() { + let list = RenderCommandList { + commands: vec![ + RenderCommand::BeginFrame, + RenderCommand::Draw(DrawCommand { + id: DrawId(1), + phase: RenderPhase::Opaque, + object_id: None, + mesh: GpuMeshId(2), + material: GpuMaterialId(3), + transform: [0.0; 16], + range: IndexRange { start: 0, count: 3 }, + stable_order: 4, + }), + RenderCommand::EndFrame, + ], + }; + assert_eq!( + canonical_capture(&list).expect("capture"), + b"B\nD,Opaque,1,2,3,4\nE\n" + ); + } + + #[test] + fn null_backend_validates_without_capture() { + let mut backend = NullBackend; + let invalid = RenderCommandList { + commands: vec![RenderCommand::Draw(DrawCommand { + id: DrawId(1), + phase: RenderPhase::Opaque, + object_id: None, + mesh: GpuMeshId(2), + material: GpuMaterialId(3), + transform: [0.0; 16], + range: IndexRange { start: 0, count: 0 }, + stable_order: 4, + })], + }; + + assert!(matches!( + backend.execute(&invalid), + Err(RenderError::InvalidRange) + )); + } + + #[test] + fn recording_backend_stores_captures() { + let mut backend = RecordingBackend::default(); + let list = RenderCommandList { + commands: vec![RenderCommand::BeginFrame, RenderCommand::EndFrame], + }; + + backend.execute(&list).expect("execute"); + backend.execute(&list).expect("execute"); + + assert_eq!(backend.captures().len(), 2); + assert_eq!(backend.last_capture(), Some(&b"B\nE\n"[..])); + backend.clear(); + assert!(backend.captures().is_empty()); + } + + #[test] + fn one_snapshot_draw_produces_one_draw_command() -> Result<(), RenderError> { + let snapshot = RenderSnapshot { + camera: CameraSnapshot::default(), + draws: vec![snapshot_draw(1, RenderPhase::Opaque, 0, 10)], + }; + + let commands = build_commands(&snapshot, RenderProfile::default())?; + + assert!(matches!(commands.commands[0], RenderCommand::BeginFrame)); + assert!(matches!(commands.commands[2], RenderCommand::EndFrame)); + let RenderCommand::Draw(draw) = &commands.commands[1] else { + panic!("expected draw"); + }; + assert_eq!(draw.id, DrawId(1)); + assert_eq!(draw.mesh, GpuMeshId(11)); + assert_eq!(draw.range, IndexRange { start: 0, count: 3 }); + Ok(()) + } + + #[test] + fn material_index_maps_through_resolved_material_slots() -> Result<(), RenderError> { + let snapshot = RenderSnapshot { + camera: CameraSnapshot::default(), + draws: vec![snapshot_draw(2, RenderPhase::Opaque, 1, 10)], + }; + + let commands = build_commands(&snapshot, RenderProfile::default())?; + + let RenderCommand::Draw(draw) = &commands.commands[1] else { + panic!("expected draw"); + }; + assert_eq!(draw.material, GpuMaterialId(37)); + Ok(()) + } + + #[test] + fn node_transform_is_retained() -> Result<(), RenderError> { + let mut draw = snapshot_draw(3, RenderPhase::Opaque, 0, 10); + draw.transform[3] = 12.5; + draw.transform[7] = -4.0; + let snapshot = RenderSnapshot { + camera: CameraSnapshot::default(), + draws: vec![draw], + }; + + let commands = build_commands(&snapshot, RenderProfile::default())?; + + let RenderCommand::Draw(draw) = &commands.commands[1] else { + panic!("expected draw"); + }; + assert_eq!(draw.transform[3], 12.5); + assert_eq!(draw.transform[7], -4.0); + Ok(()) + } + + #[test] + fn command_order_uses_phase_then_stable_key() -> Result<(), RenderError> { + let snapshot = RenderSnapshot { + camera: CameraSnapshot::default(), + draws: vec![ + snapshot_draw(3, RenderPhase::Transparent, 0, 0), + snapshot_draw(2, RenderPhase::Opaque, 0, 20), + snapshot_draw(1, RenderPhase::Opaque, 0, 10), + ], + }; + + let commands = build_commands(&snapshot, RenderProfile::default())?; + let capture = canonical_capture(&commands)?; + + assert_eq!( + capture, + b"B\nD,Opaque,1,11,31,10\nD,Opaque,2,12,31,20\nD,Transparent,3,13,31,0\nE\n" + ); + Ok(()) + } + + #[test] + fn command_capture_independent_of_snapshot_construction_order() -> Result<(), RenderError> { + let forward = RenderSnapshot { + camera: CameraSnapshot::default(), + draws: vec![ + snapshot_draw(1, RenderPhase::Opaque, 0, 10), + snapshot_draw(2, RenderPhase::Opaque, 1, 20), + ], + }; + let reverse = RenderSnapshot { + camera: CameraSnapshot::default(), + draws: vec![ + snapshot_draw(2, RenderPhase::Opaque, 1, 20), + snapshot_draw(1, RenderPhase::Opaque, 0, 10), + ], + }; + + assert_eq!( + canonical_capture(&build_commands(&forward, RenderProfile::default())?)?, + canonical_capture(&build_commands(&reverse, RenderProfile::default())?)? + ); + Ok(()) + } + + #[test] + fn invalid_range_returns_contextual_error() { + let mut draw = snapshot_draw(9, RenderPhase::Opaque, 0, 10); + draw.range = IndexRange { start: 4, count: 0 }; + let snapshot = RenderSnapshot { + camera: CameraSnapshot::default(), + draws: vec![draw], + }; + + assert!(matches!( + build_commands(&snapshot, RenderProfile::default()), + Err(RenderError::InvalidDrawRange { + draw_id: DrawId(9), + stable_order: 10, + start: 4, + count: 0 + }) + )); + } + + #[test] + fn ui_phase_is_excluded_until_requested() -> Result<(), RenderError> { + let snapshot = RenderSnapshot { + camera: CameraSnapshot::default(), + draws: vec![ + snapshot_draw(1, RenderPhase::Opaque, 0, 10), + snapshot_draw(2, RenderPhase::Ui, 0, 20), + ], + }; + + let default_commands = build_commands(&snapshot, RenderProfile::default())?; + let ui_commands = build_commands(&snapshot, RenderProfile { include_ui: true })?; + + assert_eq!(default_commands.commands.len(), 3); + assert_eq!(ui_commands.commands.len(), 4); + Ok(()) + } +} diff --git a/crates/fparkan-resource/Cargo.toml b/crates/fparkan-resource/Cargo.toml new file mode 100644 index 0000000..44e13c5 --- /dev/null +++ b/crates/fparkan-resource/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "fparkan-resource" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-nres = { path = "../fparkan-nres" } +fparkan-path = { path = "../fparkan-path" } +fparkan-rsli = { path = "../fparkan-rsli" } +fparkan-vfs = { path = "../fparkan-vfs" } + +[lints] +workspace = true diff --git a/crates/fparkan-resource/src/lib.rs b/crates/fparkan-resource/src/lib.rs new file mode 100644 index 0000000..aa6de70 --- /dev/null +++ b/crates/fparkan-resource/src/lib.rs @@ -0,0 +1,880 @@ +#![forbid(unsafe_code)] +//! Resource identity and repository ports. + +use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName}; +use fparkan_vfs::{Vfs, VfsError}; +use std::collections::BTreeMap; +use std::ops::Range; +use std::sync::{Arc, Mutex}; + +/// Resource key. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResourceKey { + /// Archive path. + pub archive: NormalizedPath, + /// Entry name. + pub name: ResourceName, + /// Optional type id. + pub type_id: Option, +} + +/// Resource entry metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResourceEntryInfo { + /// Stable resource key. + pub key: ResourceKey, + /// Archive entry attribute 1. + pub attr1: u32, + /// Archive entry attribute 2. + pub attr2: u32, + /// Archive entry attribute 3. + pub attr3: u32, +} + +/// Archive identity. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct ArchiveId(pub u64); + +/// Entry handle. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct EntryHandle { + /// Archive. + pub archive: ArchiveId, + /// Local entry index. + pub local: u32, +} + +/// Archive kind. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ArchiveKind { + /// `NRes` archive. + Nres, + /// `RsLi` archive. + Rsli, +} + +/// Resource bytes. +#[derive(Clone, Debug)] +pub enum ResourceBytes { + /// Shared byte owner. + Shared(Arc<[u8]>), + /// Slice in owner. + Slice { + /// Shared owner bytes. + owner: Arc<[u8]>, + /// Slice range. + range: Range, + }, +} + +impl ResourceBytes { + /// Returns a byte slice. + #[must_use] + pub fn as_slice(&self) -> &[u8] { + match self { + Self::Shared(bytes) => bytes, + Self::Slice { owner, range } => &owner[range.clone()], + } + } + + /// Returns byte length. + #[must_use] + pub fn len(&self) -> usize { + self.as_slice().len() + } + + /// Returns whether the resource is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Returns owned bytes. + #[must_use] + pub fn into_owned(self) -> Vec { + match self { + Self::Shared(bytes) => bytes.to_vec(), + Self::Slice { owner, range } => owner[range].to_vec(), + } + } +} + +/// Resource error. +#[derive(Debug)] +pub enum ResourceError { + /// Missing archive. + MissingArchive, + /// Missing entry. + MissingEntry, + /// Stale or invalid handle. + InvalidHandle, + /// Format error. + Format(String), + /// Entry-specific read error. + EntryRead { + /// Resource key. + key: ResourceKey, + /// Source error text. + source: String, + }, + /// Repository state lock was poisoned. + Poisoned, +} + +impl std::fmt::Display for ResourceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +impl std::error::Error for ResourceError {} + +/// Repository port. +pub trait ResourceRepository { + /// Opens archive. + /// + /// # Errors + /// + /// Returns [`ResourceError`] when the archive is missing, unsupported, or + /// malformed. + fn open_archive(&self, path: &NormalizedPath) -> Result; + /// Finds entry. + /// + /// # Errors + /// + /// Returns [`ResourceError`] when `archive` is not a valid opened archive. + fn find( + &self, + archive: ArchiveId, + name: &ResourceName, + ) -> Result, ResourceError>; + /// Reads bytes. + /// + /// # Errors + /// + /// Returns [`ResourceError`] when `entry` is stale, invalid, or cannot be + /// decoded. + fn read(&self, entry: EntryHandle) -> Result; + /// Reads entry metadata. + /// + /// # Errors + /// + /// Returns [`ResourceError`] when `entry` is stale or invalid. + fn entry_info(&self, entry: EntryHandle) -> Result; +} + +/// Cached archive repository over a [`Vfs`]. +pub struct CachedResourceRepository { + vfs: Arc, + state: Mutex, +} + +#[derive(Default)] +struct RepositoryState { + paths: BTreeMap, + archives: Vec, + payload_cache: DecodedPayloadCache, +} + +struct ArchiveSlot { + path: NormalizedPath, + fingerprint: u64, + kind: ArchiveKind, + document: ArchiveDocument, +} + +enum ArchiveDocument { + Nres(fparkan_nres::NresDocument), + Rsli(fparkan_rsli::RsliDocument), +} + +#[derive(Debug, Default)] +struct DecodedPayloadCache { + max_entries: usize, + generation: u64, + entries: BTreeMap, +} + +#[derive(Clone, Debug)] +struct PayloadCacheEntry { + bytes: Arc<[u8]>, + last_access: u64, +} + +impl CachedResourceRepository { + /// Creates a cached repository. + #[must_use] + pub fn new(vfs: Arc) -> Self { + Self::with_payload_cache_budget(vfs, 64) + } + + /// Creates a cached repository with a decoded payload entry budget. + #[must_use] + pub fn with_payload_cache_budget(vfs: Arc, max_payload_entries: usize) -> Self { + Self { + vfs, + state: Mutex::new(RepositoryState { + payload_cache: DecodedPayloadCache::new(max_payload_entries), + ..RepositoryState::default() + }), + } + } + + /// Returns the archive kind for an opened archive. + /// + /// # Errors + /// + /// Returns [`ResourceError::InvalidHandle`] when `archive` is not present. + pub fn archive_kind(&self, archive: ArchiveId) -> Result { + let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?; + Ok(state.archive(archive)?.kind) + } + + /// Returns the archive path for an opened archive. + /// + /// # Errors + /// + /// Returns [`ResourceError::InvalidHandle`] when `archive` is not present. + pub fn archive_path(&self, archive: ArchiveId) -> Result { + let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?; + Ok(state.archive(archive)?.path.clone()) + } +} + +impl ResourceRepository for CachedResourceRepository { + fn open_archive(&self, path: &NormalizedPath) -> Result { + let metadata = self.vfs.metadata(path).map_err(resource_error_from_vfs)?; + let fingerprint = metadata.fingerprint; + if let Some(id) = self.cached_id(path, fingerprint)? { + return Ok(id); + } + + let bytes = self.vfs.read(path).map_err(resource_error_from_vfs)?; + let slot = decode_archive(path.clone(), bytes, fingerprint)?; + let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?; + if let Some(id) = state.paths.get(path.as_str()).copied() { + if state.archive(id)?.fingerprint == fingerprint { + return Ok(id); + } + *state.archive_mut(id)? = slot; + state.payload_cache.remove_archive(id); + return Ok(id); + } + let id = ArchiveId(u64::try_from(state.archives.len()).map_err(|_| { + ResourceError::Format("too many open archives for handle space".to_string()) + })?); + state.paths.insert(path.as_str().to_string(), id); + state.archives.push(slot); + Ok(id) + } + + fn find( + &self, + archive: ArchiveId, + name: &ResourceName, + ) -> Result, ResourceError> { + let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?; + let slot = state.archive(archive)?; + let local = match &slot.document { + ArchiveDocument::Nres(document) => document.find_bytes(&name.0).map(|id| id.0), + ArchiveDocument::Rsli(document) => document.find_bytes(&name.0).map(|id| id.0), + }; + Ok(local.map(|local| EntryHandle { archive, local })) + } + + fn read(&self, entry: EntryHandle) -> Result { + let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?; + if let Some(bytes) = state.payload_cache.get(entry) { + return Ok(ResourceBytes::Shared(bytes)); + } + + let payload = { + let slot = state.archive(entry.archive)?; + let key = slot.entry_key(entry.local)?; + slot.read_payload(entry.local) + .map_err(|source| ResourceError::EntryRead { + key: key.clone(), + source, + })? + }; + let shared = Arc::from(payload.into_boxed_slice()); + state.payload_cache.insert(entry, Arc::clone(&shared)); + Ok(ResourceBytes::Shared(shared)) + } + + fn entry_info(&self, entry: EntryHandle) -> Result { + let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?; + let slot = state.archive(entry.archive)?; + match &slot.document { + ArchiveDocument::Nres(document) => { + let local = + usize::try_from(entry.local).map_err(|_| ResourceError::InvalidHandle)?; + let entry = document + .entries() + .get(local) + .ok_or(ResourceError::InvalidHandle)?; + let meta = entry.meta(); + Ok(ResourceEntryInfo { + key: ResourceKey { + archive: slot.path.clone(), + name: ResourceName(entry.name_bytes().to_vec()), + type_id: Some(meta.type_id), + }, + attr1: meta.attr1, + attr2: meta.attr2, + attr3: meta.attr3, + }) + } + ArchiveDocument::Rsli(document) => { + let meta = document + .entry(fparkan_rsli::EntryId(entry.local)) + .ok_or(ResourceError::InvalidHandle)?; + Ok(ResourceEntryInfo { + key: ResourceKey { + archive: slot.path.clone(), + name: ResourceName(meta.name_raw.to_vec()), + type_id: None, + }, + attr1: u32::try_from(meta.flags).unwrap_or_default(), + attr2: 0, + attr3: 0, + }) + } + } + } +} + +impl CachedResourceRepository { + fn cached_id( + &self, + path: &NormalizedPath, + fingerprint: u64, + ) -> Result, ResourceError> { + let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?; + let Some(id) = state.paths.get(path.as_str()).copied() else { + return Ok(None); + }; + if state.archive(id)?.fingerprint == fingerprint { + Ok(Some(id)) + } else { + Ok(None) + } + } +} + +impl DecodedPayloadCache { + fn new(max_entries: usize) -> Self { + Self { + max_entries, + generation: 0, + entries: BTreeMap::new(), + } + } + + fn get(&mut self, handle: EntryHandle) -> Option> { + let entry = self.entries.get_mut(&handle)?; + self.generation = self.generation.saturating_add(1); + entry.last_access = self.generation; + Some(Arc::clone(&entry.bytes)) + } + + fn insert(&mut self, handle: EntryHandle, bytes: Arc<[u8]>) { + if self.max_entries == 0 { + return; + } + self.generation = self.generation.saturating_add(1); + self.entries.insert( + handle, + PayloadCacheEntry { + bytes, + last_access: self.generation, + }, + ); + while self.entries.len() > self.max_entries { + let Some(victim) = self + .entries + .iter() + .min_by_key(|(_, entry)| entry.last_access) + .map(|(handle, _)| *handle) + else { + break; + }; + self.entries.remove(&victim); + } + } + + fn remove_archive(&mut self, archive: ArchiveId) { + self.entries.retain(|handle, _| handle.archive != archive); + } +} + +impl RepositoryState { + fn archive(&self, id: ArchiveId) -> Result<&ArchiveSlot, ResourceError> { + let index = usize::try_from(id.0).map_err(|_| ResourceError::InvalidHandle)?; + self.archives.get(index).ok_or(ResourceError::InvalidHandle) + } + + fn archive_mut(&mut self, id: ArchiveId) -> Result<&mut ArchiveSlot, ResourceError> { + let index = usize::try_from(id.0).map_err(|_| ResourceError::InvalidHandle)?; + self.archives + .get_mut(index) + .ok_or(ResourceError::InvalidHandle) + } +} + +impl ArchiveSlot { + fn entry_key(&self, local: u32) -> Result { + match &self.document { + ArchiveDocument::Nres(document) => { + let local = usize::try_from(local).map_err(|_| ResourceError::InvalidHandle)?; + let entry = document + .entries() + .get(local) + .ok_or(ResourceError::InvalidHandle)?; + Ok(ResourceKey { + archive: self.path.clone(), + name: ResourceName(entry.name_bytes().to_vec()), + type_id: Some(entry.meta().type_id), + }) + } + ArchiveDocument::Rsli(document) => { + let meta = document + .entry(fparkan_rsli::EntryId(local)) + .ok_or(ResourceError::InvalidHandle)?; + Ok(ResourceKey { + archive: self.path.clone(), + name: ResourceName(c_name_bytes(&meta.name_raw).to_vec()), + type_id: None, + }) + } + } + } + + fn read_payload(&self, local: u32) -> Result, String> { + match &self.document { + ArchiveDocument::Nres(document) => document + .payload(fparkan_nres::EntryId(local)) + .map(<[u8]>::to_vec) + .map_err(|err| err.to_string()), + ArchiveDocument::Rsli(document) => document + .load(fparkan_rsli::EntryId(local)) + .map_err(|err| err.to_string()), + } + } +} + +fn decode_archive( + path: NormalizedPath, + bytes: Arc<[u8]>, + fingerprint: u64, +) -> Result { + if bytes.starts_with(b"NRes") { + let document = fparkan_nres::decode(bytes, fparkan_nres::ReadProfile::Compatible) + .map_err(|err| ResourceError::Format(err.to_string()))?; + return Ok(ArchiveSlot { + path, + fingerprint, + kind: ArchiveKind::Nres, + document: ArchiveDocument::Nres(document), + }); + } + if bytes.get(0..4) == Some(b"NL\0\x01") { + let document = fparkan_rsli::decode(bytes, fparkan_rsli::ReadProfile::Compatible) + .map_err(|err| ResourceError::Format(err.to_string()))?; + return Ok(ArchiveSlot { + path, + fingerprint, + kind: ArchiveKind::Rsli, + document: ArchiveDocument::Rsli(document), + }); + } + Err(ResourceError::Format( + "unsupported archive magic for resource repository".to_string(), + )) +} + +fn resource_error_from_vfs(err: VfsError) -> ResourceError { + match err { + VfsError::NotFound(_) => ResourceError::MissingArchive, + VfsError::Ambiguous(path) => ResourceError::Format(format!("ambiguous VFS path: {path}")), + VfsError::Io(source) => ResourceError::Format(source.to_string()), + VfsError::Path => ResourceError::Format("invalid VFS path".to_string()), + } +} + +/// Builds a resource name from raw bytes. +#[must_use] +pub fn resource_name(raw: impl AsRef<[u8]>) -> ResourceName { + ResourceName(raw.as_ref().to_vec()) +} + +/// Normalizes an archive path for resource lookup. +/// +/// # Errors +/// +/// Returns [`ResourceError::Format`] when the path is not a valid relative +/// resource path. +pub fn archive_path(raw: impl AsRef<[u8]>) -> Result { + normalize_relative(raw.as_ref(), PathPolicy::StrictLegacy) + .map_err(|err| ResourceError::Format(err.to_string())) +} + +fn c_name_bytes(raw: &[u8; 12]) -> &[u8] { + let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len()); + &raw[..len] +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_vfs::{DirectoryVfs, MemoryVfs}; + use std::path::Path; + + #[test] + fn cached_repository_reads_synthetic_nres() { + let path = archive_path(b"archives/test.lib").expect("path"); + let bytes = build_nres(&[("Alpha.TXT", b"alpha".as_slice()), ("beta.bin", b"beta")]); + let mut vfs = MemoryVfs::default(); + vfs.insert(path.clone(), Arc::from(bytes.into_boxed_slice())); + let repo = CachedResourceRepository::new(Arc::new(vfs)); + + let first = repo.open_archive(&path).expect("open archive"); + let second = repo.open_archive(&path).expect("open archive again"); + assert_eq!(first, second); + assert_eq!(repo.archive_kind(first).expect("kind"), ArchiveKind::Nres); + + let handle = repo + .find(first, &resource_name(b"alpha.txt")) + .expect("find") + .expect("entry"); + assert_eq!(repo.read(handle).expect("read").as_slice(), b"alpha"); + let info = repo.entry_info(handle).expect("entry info"); + assert_eq!(info.key.archive, path); + assert!(info.key.name.0.eq_ignore_ascii_case(b"Alpha.TXT")); + assert!(matches!( + repo.read(EntryHandle { + archive: ArchiveId(99), + local: 0 + }), + Err(ResourceError::InvalidHandle) + )); + } + + #[test] + fn entry_handles_are_archive_qualified() { + let first_path = archive_path(b"first.lib").expect("first path"); + let second_path = archive_path(b"second.lib").expect("second path"); + let mut vfs = MemoryVfs::default(); + vfs.insert( + first_path.clone(), + Arc::from(build_nres(&[("same.bin", b"first".as_slice())]).into_boxed_slice()), + ); + vfs.insert( + second_path.clone(), + Arc::from(build_nres(&[("same.bin", b"second".as_slice())]).into_boxed_slice()), + ); + let repo = CachedResourceRepository::new(Arc::new(vfs)); + + let first_archive = repo.open_archive(&first_path).expect("first archive"); + let second_archive = repo.open_archive(&second_path).expect("second archive"); + let first_handle = repo + .find(first_archive, &resource_name(b"same.bin")) + .expect("first find") + .expect("first handle"); + let second_handle = repo + .find(second_archive, &resource_name(b"same.bin")) + .expect("second find") + .expect("second handle"); + + assert_ne!(first_handle, second_handle); + assert_eq!(first_handle.archive, first_archive); + assert_eq!(second_handle.archive, second_archive); + assert_eq!( + repo.read(first_handle).expect("first read").as_slice(), + b"first" + ); + assert_eq!( + repo.read(second_handle).expect("second read").as_slice(), + b"second" + ); + } + + #[test] + fn archive_cache_and_decoded_payload_cache_evict_independently() { + let path = archive_path(b"cache/test.lib").expect("path"); + let bytes = build_nres(&[("a.bin", b"a".as_slice()), ("b.bin", b"b".as_slice())]); + let mut vfs = MemoryVfs::default(); + vfs.insert(path.clone(), Arc::from(bytes.into_boxed_slice())); + let repo = CachedResourceRepository::with_payload_cache_budget(Arc::new(vfs), 1); + + 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"); + assert_eq!(repo.read(first).expect("read a").as_slice(), b"a"); + assert_eq!(repo.read(second).expect("read b").as_slice(), b"b"); + + let state = repo.state.lock().expect("state"); + assert_eq!(state.archives.len(), 1); + assert_eq!(state.payload_cache.entries.len(), 1); + assert_eq!(state.paths.get(path.as_str()).copied(), Some(archive)); + drop(state); + + assert_eq!(repo.open_archive(&path).expect("cached archive"), archive); + assert_eq!( + repo.read(first).expect("reread evicted payload").as_slice(), + b"a" + ); + } + + #[test] + fn archive_cache_invalidates_when_vfs_bytes_change() { + let root = temp_dir("archive-invalidate"); + let path = archive_path(b"cache/test.lib").expect("path"); + let host_path = root.join(path.as_str()); + std::fs::create_dir_all(host_path.parent().expect("parent")).expect("cache dir"); + std::fs::write(&host_path, build_nres(&[("a.bin", b"before".as_slice())])) + .expect("initial archive"); + let repo = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(&root))); + + let archive = repo.open_archive(&path).expect("open initial archive"); + let first = repo + .find(archive, &resource_name(b"a.bin")) + .expect("find initial") + .expect("initial handle"); + assert_eq!( + repo.read(first).expect("read initial").as_slice(), + b"before" + ); + + std::fs::write(&host_path, build_nres(&[("a.bin", b"after".as_slice())])) + .expect("updated archive"); + let reopened = repo.open_archive(&path).expect("open updated archive"); + let second = repo + .find(reopened, &resource_name(b"a.bin")) + .expect("find updated") + .expect("updated handle"); + + assert_eq!(reopened, archive); + assert_eq!( + repo.read(second).expect("read updated").as_slice(), + b"after" + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn entry_read_error_carries_archive_path_and_entry_name() { + let path = archive_path(b"bad/rsli.lib").expect("path"); + let mut vfs = MemoryVfs::default(); + vfs.insert( + path.clone(), + Arc::from(build_rsli_unknown_method(b"BROKEN.TEX", b"x").into_boxed_slice()), + ); + let repo = CachedResourceRepository::new(Arc::new(vfs)); + let archive = repo.open_archive(&path).expect("open bad archive"); + let handle = repo + .find(archive, &resource_name(b"BROKEN.TEX")) + .expect("find bad entry") + .expect("bad handle"); + + let err = repo.read(handle).expect_err("read should fail"); + + match err { + ResourceError::EntryRead { key, source } => { + assert_eq!(key.archive, path); + assert_eq!(key.name.0, b"BROKEN.TEX"); + assert!(source.contains("unsupported packing method")); + } + other => panic!("unexpected error: {other:?}"), + } + } + + #[test] + fn licensed_corpora_repository_reads_nres_and_rsli() { + licensed_repository_gate("IS").expect("part 1 repository gate"); + licensed_repository_gate("IS2").expect("part 2 repository gate"); + } + + fn licensed_repository_gate(corpus: &str) -> Result<(), String> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("testdata") + .join(corpus); + if !root.is_dir() { + return Err(format!( + "licensed corpus root is missing: {}", + root.display() + )); + } + let repo = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(&root))); + + let material_path = archive_path(b"Material.lib").map_err(|err| err.to_string())?; + let material_bytes = + std::fs::read(root.join(material_path.as_str())).map_err(|err| err.to_string())?; + let material_doc = fparkan_nres::decode( + Arc::from(material_bytes.clone().into_boxed_slice()), + fparkan_nres::ReadProfile::Compatible, + ) + .map_err(|err| err.to_string())?; + let material_entry = material_doc + .entries() + .first() + .ok_or_else(|| "Material.lib has no entries".to_string())?; + + let material_archive = repo + .open_archive(&material_path) + .map_err(|err| err.to_string())?; + let material_handle = repo + .find( + material_archive, + &resource_name(material_entry.name_bytes()), + ) + .map_err(|err| err.to_string())? + .ok_or_else(|| "Material.lib first entry not found".to_string())?; + let material_payload = repo + .read(material_handle) + .map_err(|err| err.to_string())? + .into_owned(); + let expected_material = material_doc + .payload(material_entry.id()) + .map_err(|err| err.to_string())?; + if material_payload != expected_material { + return Err("Material.lib payload mismatch".to_string()); + } + + let font_path = archive_path(b"gamefont.rlb").map_err(|err| err.to_string())?; + let font_bytes = + std::fs::read(root.join(font_path.as_str())).map_err(|err| err.to_string())?; + let font_doc = fparkan_rsli::decode( + Arc::from(font_bytes.into_boxed_slice()), + fparkan_rsli::ReadProfile::Compatible, + ) + .map_err(|err| err.to_string())?; + let font_entry = font_doc + .entries() + .first() + .ok_or_else(|| "gamefont.rlb has no entries".to_string())?; + let font_archive = repo + .open_archive(&font_path) + .map_err(|err| err.to_string())?; + let font_handle = repo + .find(font_archive, &resource_name(font_entry.name_raw)) + .map_err(|err| err.to_string())? + .ok_or_else(|| "gamefont.rlb first entry not found".to_string())?; + let font_payload = repo + .read(font_handle) + .map_err(|err| err.to_string())? + .into_owned(); + let expected_font = font_doc + .load(fparkan_rsli::EntryId(0)) + .map_err(|err| err.to_string())?; + if font_payload != expected_font { + return Err("gamefont.rlb payload mismatch".to_string()); + } + Ok(()) + } + + fn build_nres(entries: &[(&str, &[u8])]) -> Vec { + 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 = (0..entries.len()).collect(); + order.sort_by(|left, right| { + entries[*left] + .0 + .as_bytes() + .cmp(entries[*right].0.as_bytes()) + }); + 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 size"), + ); + push_u32(&mut out, 0); + let mut name_raw = [0; 36]; + name_raw[..name.len()].copy_from_slice(name.as_bytes()); + 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, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); + } + + fn temp_dir(name: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!( + "fparkan-resource-{name}-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::create_dir_all(&path).expect("temp dir"); + path + } + + fn build_rsli_unknown_method(name: &[u8], payload: &[u8]) -> Vec { + let mut header = [0u8; 32]; + header[0..4].copy_from_slice(b"NL\0\x01"); + header[4..6].copy_from_slice(&1i16.to_le_bytes()); + header[14..16].copy_from_slice(&0xABBAu16.to_le_bytes()); + header[20..24].copy_from_slice(&0x1234u32.to_le_bytes()); + + let mut row = [0u8; 32]; + let name_len = name.len().min(12); + row[0..name_len].copy_from_slice(&name[..name_len]); + row[16..18].copy_from_slice(&0x1E0i16.to_le_bytes()); + row[20..24].copy_from_slice( + &u32::try_from(payload.len()) + .expect("rsli unpacked size") + .to_le_bytes(), + ); + row[24..28].copy_from_slice(&64u32.to_le_bytes()); + row[28..32].copy_from_slice( + &u32::try_from(payload.len()) + .expect("rsli packed size") + .to_le_bytes(), + ); + + let mut out = Vec::new(); + out.extend_from_slice(&header); + out.extend_from_slice(&test_xor_stream(&row, 0x1234)); + out.extend_from_slice(payload); + out + } + + fn test_xor_stream(data: &[u8], key16: u16) -> Vec { + let mut lo = u8::try_from(key16 & 0xFF).expect("lo"); + let mut hi = u8::try_from((key16 >> 8) & 0xFF).expect("hi"); + data.iter() + .map(|byte| { + lo = hi ^ lo.wrapping_shl(1); + let transformed = byte ^ lo; + hi = lo ^ (hi >> 1); + transformed + }) + .collect() + } +} diff --git a/crates/fparkan-rsli/Cargo.toml b/crates/fparkan-rsli/Cargo.toml new file mode 100644 index 0000000..481788d --- /dev/null +++ b/crates/fparkan-rsli/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fparkan-rsli" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +flate2 = { version = "1", default-features = false, features = ["rust_backend"] } + +[lints] +workspace = true diff --git a/crates/fparkan-rsli/src/lib.rs b/crates/fparkan-rsli/src/lib.rs new file mode 100644 index 0000000..59b4c67 --- /dev/null +++ b/crates/fparkan-rsli/src/lib.rs @@ -0,0 +1,2113 @@ +#![forbid(unsafe_code)] +//! Stage-1 `RsLi` archive contract. + +use std::fmt; +use std::io::Read; +use std::sync::Arc; + +/// Read profile. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReadProfile { + /// Reject compatibility quirks. + Strict, + /// Accept registered retail compatibility quirks. + Compatible, +} + +/// Write profile. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WriteProfile { + /// Return the original byte image. + Lossless, +} + +/// `RsLi` compatibility switches. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RsliCompatibilityProfile { + /// Allow the registered `AO` trailer overlay. + pub allow_ao_trailer: bool, + /// Allow retail Deflate entries whose declared size is one byte past EOF. + pub allow_deflate_eof_plus_one: bool, + /// Rebuild lookup order when a retail presorted table is corrupt. + pub allow_invalid_presorted_fallback: bool, +} + +impl Default for RsliCompatibilityProfile { + fn default() -> Self { + Self { + allow_ao_trailer: true, + allow_deflate_eof_plus_one: true, + allow_invalid_presorted_fallback: true, + } + } +} + +/// `RsLi` packing method. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RsliMethod { + /// Stored without packing. + Stored, + /// XOR only. + XorOnly, + /// Simple LZSS. + Lzss, + /// XOR plus simple LZSS. + XorLzss, + /// Adaptive LZSS/Huffman method `0x080`. + AdaptiveLzss, + /// XOR plus adaptive LZSS/Huffman method `0x0A0`. + XorAdaptiveLzss, + /// Raw Deflate. + RawDeflate, + /// Unsupported method bits. + Unknown(u32), +} + +/// Entry identifier in original table order. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct EntryId(pub u32); + +/// Archive header summary. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RsliHeader { + /// Raw 32-byte header. + pub raw: [u8; 32], + /// Format version. + pub version: u8, + /// Entry count. + pub entry_count: u16, + /// Presorted flag from the header. + pub presorted_flag: u16, + /// XOR seed used for the entry table. + pub xor_seed: u32, +} + +/// `AO` trailer summary. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AoTrailer { + /// Raw six-byte trailer. + pub raw: [u8; 6], + /// Media overlay byte offset. + pub overlay: u32, +} + +/// Entry metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EntryMeta { + /// Decoded byte-for-byte name adapter. + pub name: String, + /// Raw fixed-size name field. + pub name_raw: [u8; 12], + /// Original flags. + pub flags: i32, + /// Packing method. + pub method: RsliMethod, + /// Effective payload offset after overlay. + pub data_offset: u64, + /// Declared packed size. + pub packed_size: u32, + /// Declared unpacked size. + pub unpacked_size: u32, + /// Sort table value. + pub sort_to_original: i16, + /// Raw data offset stored in the table. + pub data_offset_raw: u32, +} + +/// Parsed `RsLi` document. +#[derive(Debug)] +pub struct RsliDocument { + bytes: Arc<[u8]>, + header: RsliHeader, + ao_trailer: Option, + entries: Vec, + records: Vec, +} + +/// Packed resource bytes and metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PackedResource { + /// Entry metadata. + pub meta: EntryMeta, + /// Packed bytes as stored in the archive. + pub packed: Vec, +} + +/// `RsLi` parse or decode error. +#[derive(Debug)] +pub enum RsliError { + /// Invalid magic. + InvalidMagic { + /// Observed magic. + got: [u8; 2], + }, + /// Reserved header byte has an unexpected value. + InvalidReserved { + /// Observed reserved byte. + got: u8, + }, + /// Unsupported version. + UnsupportedVersion { + /// Observed version. + got: u8, + }, + /// Invalid entry count. + InvalidEntryCount { + /// Observed signed count. + got: i16, + }, + /// Too many entries for stable ids. + TooManyEntries { + /// Observed count. + got: usize, + }, + /// Entry table is outside the archive. + EntryTableOutOfBounds { + /// Table byte offset. + table_offset: u64, + /// Table byte length. + table_len: u64, + /// Archive byte length. + file_len: u64, + }, + /// Entry table is structurally corrupt. + CorruptEntryTable(&'static str), + /// Entry id is outside this archive. + EntryIdOutOfRange { + /// Entry id. + id: u32, + /// Entry count. + entry_count: u32, + }, + /// Entry payload is outside the archive. + EntryDataOutOfBounds { + /// Entry id. + id: u32, + /// Payload offset. + offset: u64, + /// Payload declared size. + size: u32, + /// Archive byte length. + file_len: u64, + }, + /// `AO` media overlay points outside the archive. + MediaOverlayOutOfBounds { + /// Overlay byte offset. + overlay: u32, + /// Archive byte length. + file_len: u64, + }, + /// Unsupported packing method. + UnsupportedMethod { + /// Raw method bits. + raw: u32, + }, + /// Packed range ends past EOF. + PackedSizePastEof { + /// Entry id. + id: u32, + /// Payload offset. + offset: u64, + /// Declared packed size. + packed_size: u32, + /// Archive byte length. + file_len: u64, + }, + /// Registered retail quirk is rejected by the selected profile. + DeflateEofPlusOneQuirkRejected { + /// Entry id. + id: u32, + }, + /// Payload decompression failed. + DecompressionFailed(&'static str), + /// Decoded payload size does not match the declared size. + OutputSizeMismatch { + /// Expected decoded size. + expected: u32, + /// Observed decoded size. + got: u32, + }, + /// Integer conversion or arithmetic overflow. + IntegerOverflow, +} + +impl fmt::Display for RsliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidMagic { got } => write!(f, "invalid RsLi magic: {got:02X?}"), + Self::InvalidReserved { got } => write!(f, "invalid RsLi reserved byte: {got:#x}"), + Self::UnsupportedVersion { got } => write!(f, "unsupported RsLi version: {got:#x}"), + Self::InvalidEntryCount { got } => write!(f, "invalid entry_count: {got}"), + Self::TooManyEntries { got } => write!(f, "too many entries: {got} exceeds u32::MAX"), + Self::EntryTableOutOfBounds { + table_offset, + table_len, + file_len, + } => write!( + f, + "entry table out of bounds: off={table_offset}, len={table_len}, file={file_len}" + ), + Self::CorruptEntryTable(message) => write!(f, "corrupt entry table: {message}"), + Self::EntryIdOutOfRange { id, entry_count } => { + write!(f, "RsLi entry id out of range: {id} >= {entry_count}") + } + Self::EntryDataOutOfBounds { + id, + offset, + size, + file_len, + } => write!( + f, + "entry data out of bounds: id={id}, off={offset}, size={size}, file={file_len}" + ), + Self::MediaOverlayOutOfBounds { overlay, file_len } => { + write!( + f, + "media overlay out of bounds: overlay={overlay}, file={file_len}" + ) + } + Self::UnsupportedMethod { raw } => write!(f, "unsupported packing method: {raw:#x}"), + Self::PackedSizePastEof { + id, + offset, + packed_size, + file_len, + } => write!( + f, + "packed range past EOF: id={id}, off={offset}, size={packed_size}, file={file_len}" + ), + Self::DeflateEofPlusOneQuirkRejected { id } => { + write!(f, "deflate EOF+1 quirk rejected for entry {id}") + } + Self::DecompressionFailed(message) => write!(f, "decompression failed: {message}"), + Self::OutputSizeMismatch { expected, got } => { + write!(f, "output size mismatch: expected={expected}, got={got}") + } + Self::IntegerOverflow => write!(f, "integer overflow"), + } + } +} + +impl std::error::Error for RsliError {} + +/// Decodes an `RsLi` document. +/// +/// # Errors +/// +/// Returns [`RsliError`] when the header, table, payload ranges, registered +/// compatibility quirks, or packed payloads are invalid for the selected +/// profile. +pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result { + let options = match profile { + ReadProfile::Strict => ParseOptions { + allow_ao_trailer: false, + allow_deflate_eof_plus_one: false, + allow_invalid_presorted_fallback: false, + }, + ReadProfile::Compatible => { + let profile = RsliCompatibilityProfile::default(); + ParseOptions { + allow_ao_trailer: profile.allow_ao_trailer, + allow_deflate_eof_plus_one: profile.allow_deflate_eof_plus_one, + allow_invalid_presorted_fallback: profile.allow_invalid_presorted_fallback, + } + } + }; + let ParsedRsli { + header, + ao_trailer, + records, + } = parse_rsli(&bytes, options)?; + let entries = records.iter().map(|record| record.meta.clone()).collect(); + Ok(RsliDocument { + bytes, + header, + ao_trailer, + entries, + records, + }) +} + +impl RsliDocument { + /// Header summary. + #[must_use] + pub fn header(&self) -> &RsliHeader { + &self.header + } + + /// Optional `AO` trailer. + #[must_use] + pub fn ao_trailer(&self) -> Option<&AoTrailer> { + self.ao_trailer.as_ref() + } + + /// Entry count. + #[must_use] + pub fn entry_count(&self) -> usize { + self.entries.len() + } + + /// Entries in original table order. + #[must_use] + pub fn entries(&self) -> &[EntryMeta] { + &self.entries + } + + /// Finds an entry by name. + #[must_use] + pub fn find(&self, name: &str) -> Option { + self.find_bytes(name.as_bytes()) + } + + /// Finds an entry by raw ASCII-case-insensitive name bytes. + #[must_use] + pub fn find_bytes(&self, name: &[u8]) -> Option { + let len = name + .iter() + .position(|byte| *byte == 0) + .unwrap_or(name.len()); + let query = name[..len] + .iter() + .map(u8::to_ascii_uppercase) + .collect::>(); + self.find_impl(&query) + } + + /// Returns an entry by id. + #[must_use] + pub fn entry(&self, id: EntryId) -> Option<&EntryMeta> { + self.entries.get(usize::try_from(id.0).ok()?) + } + + /// Loads and unpacks an entry. + /// + /// # Errors + /// + /// Returns [`RsliError`] when `id` is invalid or the packed payload cannot + /// be decoded to the declared size. + pub fn load(&self, id: EntryId) -> Result, RsliError> { + let record = self.record_by_id(id)?; + let packed = self.packed_slice(id, record)?; + decode_payload( + packed, + record.meta.method, + record.key16, + record.meta.unpacked_size, + ) + } + + /// Returns packed bytes and public metadata. + /// + /// # Errors + /// + /// Returns [`RsliError`] when `id` is invalid or the packed range is outside + /// the archive. + pub fn load_packed(&self, id: EntryId) -> Result { + let record = self.record_by_id(id)?; + let packed = self.packed_slice(id, record)?.to_vec(); + Ok(PackedResource { + meta: record.meta.clone(), + packed, + }) + } + + /// Encodes the document according to the selected profile. + #[must_use] + pub fn encode(&self, profile: WriteProfile) -> Vec { + match profile { + WriteProfile::Lossless => self.bytes.to_vec(), + } + } +} + +impl RsliDocument { + fn find_impl(&self, query_bytes: &[u8]) -> Option { + let mut low = 0usize; + let mut high = self.records.len(); + while low < high { + let mid = low + (high - low) / 2; + let original = self.records.get(mid)?.meta.sort_to_original; + if original < 0 { + break; + } + let original = usize::try_from(original).ok()?; + let record = self.records.get(original)?; + match cmp_c_string(query_bytes, c_name_bytes(&record.meta.name_raw)) { + std::cmp::Ordering::Less => high = mid, + std::cmp::Ordering::Greater => low = mid + 1, + std::cmp::Ordering::Equal => return Some(EntryId(u32::try_from(original).ok()?)), + } + } + + self.records.iter().enumerate().find_map(|(idx, record)| { + if cmp_c_string(query_bytes, c_name_bytes(&record.meta.name_raw)) + == std::cmp::Ordering::Equal + { + Some(EntryId(u32::try_from(idx).ok()?)) + } else { + None + } + }) + } + + fn record_by_id(&self, id: EntryId) -> Result<&EntryRecord, RsliError> { + let idx = usize::try_from(id.0).map_err(|_| RsliError::IntegerOverflow)?; + self.records + .get(idx) + .ok_or_else(|| RsliError::EntryIdOutOfRange { + id: id.0, + entry_count: saturating_u32_len(self.records.len()), + }) + } + + fn packed_slice<'a>( + &'a self, + id: EntryId, + record: &EntryRecord, + ) -> Result<&'a [u8], RsliError> { + let end = record + .effective_offset + .checked_add(record.packed_size_available) + .ok_or(RsliError::IntegerOverflow)?; + self.bytes + .get(record.effective_offset..end) + .ok_or(RsliError::EntryDataOutOfBounds { + id: id.0, + offset: u64::try_from(record.effective_offset).unwrap_or(u64::MAX), + size: record.packed_size_declared, + file_len: u64::try_from(self.bytes.len()).unwrap_or(u64::MAX), + }) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ParseOptions { + allow_ao_trailer: bool, + allow_deflate_eof_plus_one: bool, + allow_invalid_presorted_fallback: bool, +} + +#[derive(Clone, Debug)] +struct ParsedRsli { + header: RsliHeader, + ao_trailer: Option, + records: Vec, +} + +#[derive(Clone, Debug)] +struct EntryRecord { + meta: EntryMeta, + key16: u16, + packed_size_declared: u32, + packed_size_available: usize, + effective_offset: usize, +} + +#[allow(clippy::too_many_lines)] +fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result { + if bytes.len() < 32 { + return Err(RsliError::EntryTableOutOfBounds { + table_offset: 32, + table_len: 0, + file_len: u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?, + }); + } + + let mut header_raw = [0u8; 32]; + header_raw.copy_from_slice(&bytes[0..32]); + + let mut magic = [0u8; 2]; + magic.copy_from_slice(&bytes[0..2]); + if &magic != b"NL" { + return Err(RsliError::InvalidMagic { got: magic }); + } + let reserved = bytes[2]; + if reserved != 0 { + return Err(RsliError::InvalidReserved { got: reserved }); + } + let version = bytes[3]; + if version != 0x01 { + return Err(RsliError::UnsupportedVersion { got: version }); + } + + let entry_count_signed = i16::from_le_bytes([bytes[4], bytes[5]]); + if entry_count_signed < 0 { + return Err(RsliError::InvalidEntryCount { + got: entry_count_signed, + }); + } + let count = usize::try_from(entry_count_signed).map_err(|_| RsliError::IntegerOverflow)?; + if count > usize::try_from(u32::MAX).map_err(|_| RsliError::IntegerOverflow)? { + return Err(RsliError::TooManyEntries { got: count }); + } + + let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]); + let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); + let header = RsliHeader { + raw: header_raw, + version, + entry_count: u16::try_from(entry_count_signed).map_err(|_| RsliError::IntegerOverflow)?, + presorted_flag, + xor_seed, + }; + + let table_len = count.checked_mul(32).ok_or(RsliError::IntegerOverflow)?; + let table_end = 32usize + .checked_add(table_len) + .ok_or(RsliError::IntegerOverflow)?; + if table_end > bytes.len() { + return Err(RsliError::EntryTableOutOfBounds { + table_offset: 32, + table_len: u64::try_from(table_len).map_err(|_| RsliError::IntegerOverflow)?, + file_len: u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?, + }); + } + + let table_plain = xor_stream(&bytes[32..table_end], (xor_seed & 0xFFFF) as u16); + if table_plain.len() != table_len { + return Err(RsliError::CorruptEntryTable( + "entry table decrypt length mismatch", + )); + } + + let (overlay, trailer_raw) = parse_ao_trailer(bytes, options.allow_ao_trailer)?; + + let mut records = Vec::with_capacity(count); + for idx in 0..count { + let row = &table_plain[idx * 32..(idx + 1) * 32]; + let mut name_raw = [0u8; 12]; + name_raw.copy_from_slice(&row[0..12]); + + let flags_signed = i16::from_le_bytes([row[16], row[17]]); + let mut sort_to_original = i16::from_le_bytes([row[18], row[19]]); + let unpacked_size = u32::from_le_bytes([row[20], row[21], row[22], row[23]]); + let data_offset_raw = u32::from_le_bytes([row[24], row[25], row[26], row[27]]); + let packed_size_declared = u32::from_le_bytes([row[28], row[29], row[30], row[31]]); + let method_raw = u32::from(flags_signed.cast_unsigned()) & 0x1E0; + let method = parse_method(method_raw); + + let effective_offset_u64 = u64::from(data_offset_raw) + .checked_add(u64::from(overlay)) + .ok_or(RsliError::IntegerOverflow)?; + let effective_offset = + usize::try_from(effective_offset_u64).map_err(|_| RsliError::IntegerOverflow)?; + let mut packed_size_available = + usize::try_from(packed_size_declared).map_err(|_| RsliError::IntegerOverflow)?; + let end = effective_offset_u64 + .checked_add(u64::from(packed_size_declared)) + .ok_or(RsliError::IntegerOverflow)?; + let file_len = u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?; + + if end > file_len { + if method_raw == 0x100 && end == file_len + 1 { + if options.allow_deflate_eof_plus_one + && is_registered_deflate_eof_plus_one_quirk(&name_raw) + { + packed_size_available = packed_size_available + .checked_sub(1) + .ok_or(RsliError::IntegerOverflow)?; + } else { + return Err(RsliError::DeflateEofPlusOneQuirkRejected { + id: u32::try_from(idx).map_err(|_| RsliError::IntegerOverflow)?, + }); + } + } else { + return Err(RsliError::PackedSizePastEof { + id: u32::try_from(idx).map_err(|_| RsliError::IntegerOverflow)?, + offset: effective_offset_u64, + packed_size: packed_size_declared, + file_len, + }); + } + } + + let available_end = effective_offset + .checked_add(packed_size_available) + .ok_or(RsliError::IntegerOverflow)?; + if available_end > bytes.len() { + return Err(RsliError::EntryDataOutOfBounds { + id: u32::try_from(idx).map_err(|_| RsliError::IntegerOverflow)?, + offset: effective_offset_u64, + size: packed_size_declared, + file_len, + }); + } + + if presorted_flag != 0xABBA { + sort_to_original = 0; + } + + records.push(EntryRecord { + meta: EntryMeta { + name: decode_name(c_name_bytes(&name_raw)), + name_raw, + flags: i32::from(flags_signed), + method, + data_offset: effective_offset_u64, + packed_size: packed_size_declared, + unpacked_size, + sort_to_original, + data_offset_raw, + }, + key16: sort_to_original.cast_unsigned(), + packed_size_declared, + packed_size_available, + effective_offset, + }); + } + + if presorted_flag == 0xABBA { + if validate_permutation(&records).is_err() { + if !options.allow_invalid_presorted_fallback { + validate_permutation(&records)?; + } + rebuild_sorted_mapping(&mut records)?; + } + } else { + rebuild_sorted_mapping(&mut records)?; + } + + Ok(ParsedRsli { + header, + ao_trailer: trailer_raw.map(|raw| AoTrailer { raw, overlay }), + records, + }) +} + +fn rebuild_sorted_mapping(records: &mut [EntryRecord]) -> Result<(), RsliError> { + let mut sorted: Vec = (0..records.len()).collect(); + sorted.sort_by(|a, b| { + cmp_c_string( + c_name_bytes(&records[*a].meta.name_raw), + c_name_bytes(&records[*b].meta.name_raw), + ) + }); + for (idx, record) in records.iter_mut().enumerate() { + record.meta.sort_to_original = + i16::try_from(sorted[idx]).map_err(|_| RsliError::IntegerOverflow)?; + record.key16 = record.meta.sort_to_original.cast_unsigned(); + } + Ok(()) +} + +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" { + return Ok((0, None)); + } + let mut raw = [0u8; 6]; + raw.copy_from_slice(&bytes[bytes.len() - 6..]); + let overlay = u32::from_le_bytes([raw[2], raw[3], raw[4], raw[5]]); + if u64::from(overlay) > u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)? { + return Err(RsliError::MediaOverlayOutOfBounds { + overlay, + file_len: u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?, + }); + } + Ok((overlay, Some(raw))) +} + +fn validate_permutation(records: &[EntryRecord]) -> Result<(), RsliError> { + let mut seen = vec![false; records.len()]; + for record in records { + let idx = i32::from(record.meta.sort_to_original); + if idx < 0 { + return Err(RsliError::CorruptEntryTable( + "sort_to_original is not a valid permutation index", + )); + } + let idx = usize::try_from(idx).map_err(|_| RsliError::IntegerOverflow)?; + if idx >= records.len() || seen[idx] { + return Err(RsliError::CorruptEntryTable( + "sort_to_original is not a permutation", + )); + } + seen[idx] = true; + } + if seen.iter().any(|value| !*value) { + return Err(RsliError::CorruptEntryTable( + "sort_to_original is not a permutation", + )); + } + Ok(()) +} + +fn parse_method(raw: u32) -> RsliMethod { + match raw { + 0x000 => RsliMethod::Stored, + 0x020 => RsliMethod::XorOnly, + 0x040 => RsliMethod::Lzss, + 0x060 => RsliMethod::XorLzss, + 0x080 => RsliMethod::AdaptiveLzss, + 0x0A0 => RsliMethod::XorAdaptiveLzss, + 0x100 => RsliMethod::RawDeflate, + other => RsliMethod::Unknown(other), + } +} + +fn is_registered_deflate_eof_plus_one_quirk(name_raw: &[u8; 12]) -> bool { + c_name_bytes(name_raw) + .iter() + .map(u8::to_ascii_uppercase) + .eq(b"INTERF8.TEX".iter().copied()) +} + +fn decode_name(name: &[u8]) -> String { + name.iter().map(|byte| char::from(*byte)).collect() +} + +fn c_name_bytes(raw: &[u8; 12]) -> &[u8] { + let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len()); + &raw[..len] +} + +fn cmp_c_string(a: &[u8], b: &[u8]) -> std::cmp::Ordering { + let min_len = a.len().min(b.len()); + for idx in 0..min_len { + if a[idx] != b[idx] { + return a[idx].cmp(&b[idx]); + } + } + a.len().cmp(&b.len()) +} + +fn decode_payload( + packed: &[u8], + method: RsliMethod, + key16: u16, + unpacked_size: u32, +) -> Result, RsliError> { + let expected = usize::try_from(unpacked_size).map_err(|_| RsliError::IntegerOverflow)?; + let out = match method { + RsliMethod::Stored => { + if packed.len() < expected { + return Err(RsliError::OutputSizeMismatch { + expected: unpacked_size, + got: u32::try_from(packed.len()).unwrap_or(u32::MAX), + }); + } + packed[..expected].to_vec() + } + RsliMethod::XorOnly => { + if packed.len() < expected { + return Err(RsliError::OutputSizeMismatch { + expected: unpacked_size, + got: u32::try_from(packed.len()).unwrap_or(u32::MAX), + }); + } + xor_stream(&packed[..expected], key16) + } + RsliMethod::Lzss => lzss_decompress_simple(packed, expected, None)?, + RsliMethod::XorLzss => lzss_decompress_simple(packed, expected, Some(key16))?, + RsliMethod::AdaptiveLzss => lzss_huffman_decompress(packed, expected, None)?, + RsliMethod::XorAdaptiveLzss => lzss_huffman_decompress(packed, expected, Some(key16))?, + RsliMethod::RawDeflate => decode_deflate(packed)?, + RsliMethod::Unknown(raw) => return Err(RsliError::UnsupportedMethod { raw }), + }; + if out.len() != expected { + return Err(RsliError::OutputSizeMismatch { + expected: unpacked_size, + got: u32::try_from(out.len()).unwrap_or(u32::MAX), + }); + } + Ok(out) +} + +#[derive(Clone, Copy, Debug)] +struct XorState { + lo: u8, + hi: u8, +} + +impl XorState { + fn new(key16: u16) -> Self { + Self { + lo: u8::try_from(key16 & 0xFF).unwrap_or(u8::MAX), + hi: u8::try_from((key16 >> 8) & 0xFF).unwrap_or(u8::MAX), + } + } + + fn decrypt_byte(&mut self, encrypted: u8) -> u8 { + self.lo = self.hi ^ self.lo.wrapping_shl(1); + let decrypted = encrypted ^ self.lo; + self.hi = self.lo ^ (self.hi >> 1); + decrypted + } +} + +fn xor_stream(data: &[u8], key16: u16) -> Vec { + let mut state = XorState::new(key16); + data.iter().map(|byte| state.decrypt_byte(*byte)).collect() +} + +fn lzss_decompress_simple( + data: &[u8], + expected_size: usize, + xor_key: Option, +) -> Result, RsliError> { + let mut ring = [0x20u8; 0x1000]; + let mut ring_pos = 0xFEEusize; + let mut out = Vec::with_capacity(expected_size); + let mut in_pos = 0usize; + let mut control = 0u8; + let mut bits_left = 0u8; + let mut xor_state = xor_key.map(XorState::new); + + while out.len() < expected_size { + if bits_left == 0 { + control = read_packed_byte(data, in_pos, &mut xor_state).ok_or( + RsliError::DecompressionFailed("lzss-simple: unexpected EOF"), + )?; + in_pos = in_pos.saturating_add(1); + bits_left = 8; + } + + if (control & 1) != 0 { + let byte = read_packed_byte(data, in_pos, &mut xor_state).ok_or( + RsliError::DecompressionFailed("lzss-simple: unexpected EOF"), + )?; + in_pos = in_pos.saturating_add(1); + out.push(byte); + ring[ring_pos] = byte; + ring_pos = (ring_pos + 1) & 0x0FFF; + } else { + let low = read_packed_byte(data, in_pos, &mut xor_state).ok_or( + RsliError::DecompressionFailed("lzss-simple: unexpected EOF"), + )?; + let high = read_packed_byte(data, in_pos.saturating_add(1), &mut xor_state).ok_or( + RsliError::DecompressionFailed("lzss-simple: unexpected EOF"), + )?; + in_pos = in_pos.saturating_add(2); + let offset = usize::from(low) | (usize::from(high & 0xF0) << 4); + let length = usize::from((high & 0x0F) + 3); + for step in 0..length { + let byte = ring[(offset + step) & 0x0FFF]; + out.push(byte); + ring[ring_pos] = byte; + ring_pos = (ring_pos + 1) & 0x0FFF; + if out.len() >= expected_size { + break; + } + } + } + control >>= 1; + bits_left -= 1; + } + Ok(out) +} + +fn read_packed_byte(data: &[u8], pos: usize, state: &mut Option) -> Option { + let encrypted = data.get(pos).copied()?; + Some(if let Some(state) = state { + state.decrypt_byte(encrypted) + } else { + encrypted + }) +} + +fn decode_deflate(packed: &[u8]) -> Result, RsliError> { + let mut out = Vec::new(); + let mut decoder = flate2::read::DeflateDecoder::new(packed); + decoder + .read_to_end(&mut out) + .map_err(|_| RsliError::DecompressionFailed("deflate"))?; + Ok(out) +} + +const LZH_N: usize = 4096; +const LZH_F: usize = 60; +const LZH_THRESHOLD: usize = 2; +const LZH_N_CHAR: usize = 256 - LZH_THRESHOLD + LZH_F; +const LZH_T: usize = LZH_N_CHAR * 2 - 1; +const LZH_R: usize = LZH_T - 1; +const LZH_MAX_FREQ: u16 = 0x8000; + +fn lzss_huffman_decompress( + data: &[u8], + expected_size: usize, + xor_key: Option, +) -> Result, RsliError> { + let mut decoder = LzhDecoder::new(data, xor_key); + decoder.decode(expected_size) +} + +struct LzhDecoder<'a> { + bit_reader: BitReader<'a>, + text: [u8; LZH_N], + freq: [u16; LZH_T + 1], + parent: [usize; LZH_T + LZH_N_CHAR], + son: [usize; LZH_T], + d_code: [u8; 256], + d_len: [u8; 256], + ring_pos: usize, +} + +impl<'a> LzhDecoder<'a> { + fn new(data: &'a [u8], xor_key: Option) -> Self { + let mut decoder = Self { + bit_reader: BitReader::new(data, xor_key), + text: [0x20u8; LZH_N], + freq: [0u16; LZH_T + 1], + parent: [0usize; LZH_T + LZH_N_CHAR], + son: [0usize; LZH_T], + d_code: [0u8; 256], + d_len: [0u8; 256], + ring_pos: LZH_N - LZH_F, + }; + decoder.init_tables(); + decoder.start_huff(); + decoder + } + + fn decode(&mut self, expected_size: usize) -> Result, RsliError> { + let mut out = Vec::with_capacity(expected_size); + while out.len() < expected_size { + let c = self.decode_char()?; + if c < 256 { + let byte = u8::try_from(c).map_err(|_| RsliError::IntegerOverflow)?; + out.push(byte); + self.text[self.ring_pos] = byte; + self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1); + } else { + let mut offset = self.decode_position()?; + offset = (self.ring_pos.wrapping_sub(offset).wrapping_sub(1)) & (LZH_N - 1); + let mut length = c.saturating_sub(253); + while length > 0 && out.len() < expected_size { + let byte = self.text[offset]; + out.push(byte); + self.text[self.ring_pos] = byte; + self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1); + offset = (offset + 1) & (LZH_N - 1); + length -= 1; + } + } + } + Ok(out) + } + + fn init_tables(&mut self) { + let d_code_group_counts = [1usize, 3, 8, 12, 24, 16]; + let d_len_group_counts = [32usize, 48, 64, 48, 48, 16]; + let mut group_index = 0u8; + let mut idx = 0usize; + let mut run = 32usize; + for count in d_code_group_counts { + for _ in 0..count { + for _ in 0..run { + self.d_code[idx] = group_index; + idx += 1; + } + group_index = group_index.wrapping_add(1); + } + run >>= 1; + } + + let mut len = 3u8; + idx = 0; + for count in d_len_group_counts { + for _ in 0..count { + self.d_len[idx] = len; + idx += 1; + } + len = len.saturating_add(1); + } + } + + fn start_huff(&mut self) { + for i in 0..LZH_N_CHAR { + self.freq[i] = 1; + self.son[i] = i + LZH_T; + self.parent[i + LZH_T] = i; + } + let mut i = 0usize; + let mut j = LZH_N_CHAR; + while j <= LZH_R { + self.freq[j] = self.freq[i].saturating_add(self.freq[i + 1]); + self.son[j] = i; + self.parent[i] = j; + self.parent[i + 1] = j; + i += 2; + j += 1; + } + self.freq[LZH_T] = u16::MAX; + self.parent[LZH_R] = 0; + } + + fn decode_char(&mut self) -> Result { + let mut node = self.son[LZH_R]; + while node < LZH_T { + let bit = usize::from(self.bit_reader.read_bit()?); + let branch = node + .checked_add(bit) + .ok_or(RsliError::DecompressionFailed("lzss-huffman tree overflow"))?; + node = *self.son.get(branch).ok_or(RsliError::DecompressionFailed( + "lzss-huffman tree out of bounds", + ))?; + } + let c = node - LZH_T; + self.update(c); + Ok(c) + } + + fn decode_position(&mut self) -> Result { + let i = usize::try_from(self.bit_reader.read_bits(8)?) + .map_err(|_| RsliError::IntegerOverflow)?; + let mut c = usize::from(self.d_code[i]) << 6; + let mut j = usize::from(self.d_len[i]).saturating_sub(2); + while j > 0 { + j -= 1; + c |= usize::from(self.bit_reader.read_bit()?) << j; + } + Ok(c | (i & 0x3F)) + } + + fn update(&mut self, c: usize) { + if self.freq[LZH_R] == LZH_MAX_FREQ { + self.reconstruct(); + } + let mut current = self.parent[c + LZH_T]; + loop { + self.freq[current] = self.freq[current].saturating_add(1); + let freq = self.freq[current]; + if current + 1 < self.freq.len() && freq > self.freq[current + 1] { + let mut swap_idx = current + 1; + while swap_idx + 1 < self.freq.len() && freq > self.freq[swap_idx + 1] { + swap_idx += 1; + } + self.freq.swap(current, swap_idx); + let left = self.son[current]; + let right = self.son[swap_idx]; + self.son[current] = right; + self.son[swap_idx] = left; + self.parent[left] = swap_idx; + if left < LZH_T { + self.parent[left + 1] = swap_idx; + } + self.parent[right] = current; + if right < LZH_T { + self.parent[right + 1] = current; + } + current = swap_idx; + } + current = self.parent[current]; + if current == 0 { + break; + } + } + } + + fn reconstruct(&mut self) { + let mut j = 0usize; + for i in 0..LZH_T { + if self.son[i] >= LZH_T { + self.freq[j] = (self.freq[i].saturating_add(1)) / 2; + self.son[j] = self.son[i]; + j += 1; + } + } + let mut i = 0usize; + let mut current = LZH_N_CHAR; + while current < LZH_T { + let sum = self.freq[i].saturating_add(self.freq[i + 1]); + self.freq[current] = sum; + let mut insert_at = current; + while insert_at > 0 && sum < self.freq[insert_at - 1] { + insert_at -= 1; + } + for move_idx in (insert_at..current).rev() { + self.freq[move_idx + 1] = self.freq[move_idx]; + self.son[move_idx + 1] = self.son[move_idx]; + } + self.freq[insert_at] = sum; + self.son[insert_at] = i; + i += 2; + current += 1; + } + for idx in 0..LZH_T { + let node = self.son[idx]; + self.parent[node] = idx; + if node < LZH_T { + self.parent[node + 1] = idx; + } + } + self.freq[LZH_T] = u16::MAX; + self.parent[LZH_R] = 0; + } +} + +struct BitReader<'a> { + data: &'a [u8], + byte_pos: usize, + bit_mask: u8, + current_byte: u8, + xor_state: Option, +} + +impl<'a> BitReader<'a> { + fn new(data: &'a [u8], xor_key: Option) -> Self { + Self { + data, + byte_pos: 0, + bit_mask: 0x80, + current_byte: 0, + xor_state: xor_key.map(XorState::new), + } + } + + fn read_bit(&mut self) -> Result { + if self.bit_mask == 0x80 { + let Some(mut byte) = self.data.get(self.byte_pos).copied() else { + return Err(RsliError::DecompressionFailed( + "lzss-huffman: unexpected EOF", + )); + }; + if let Some(state) = &mut self.xor_state { + byte = state.decrypt_byte(byte); + } + self.current_byte = byte; + } + let bit = u8::from((self.current_byte & self.bit_mask) != 0); + self.bit_mask >>= 1; + if self.bit_mask == 0 { + self.bit_mask = 0x80; + self.byte_pos = self.byte_pos.saturating_add(1); + } + Ok(bit) + } + + fn read_bits(&mut self, bits: usize) -> Result { + let mut value = 0u32; + for _ in 0..bits { + value = (value << 1) | u32::from(self.read_bit()?); + } + Ok(value) + } +} + +fn saturating_u32_len(len: usize) -> u32 { + u32::try_from(len).unwrap_or(u32::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::{Path, PathBuf}; + + #[test] + fn parses_minimal_empty_library() { + let bytes = synthetic_rsli(&[], false, 0x1234, None); + + let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("minimal RsLi"); + + assert_eq!(doc.entry_count(), 0); + assert_eq!(doc.header().raw[0..4], *b"NL\0\x01"); + assert_eq!(doc.encode(WriteProfile::Lossless), bytes); + } + + #[test] + fn rejects_invalid_header_fields() { + let valid = synthetic_rsli(&[], false, 0, None); + + let mut invalid_magic = valid.clone(); + invalid_magic[0] = b'X'; + assert!(matches!( + decode(arc(invalid_magic), ReadProfile::Strict), + Err(RsliError::InvalidMagic { .. }) + )); + + let mut invalid_reserved = valid.clone(); + invalid_reserved[2] = 1; + assert!(matches!( + decode(arc(invalid_reserved), ReadProfile::Strict), + Err(RsliError::InvalidReserved { got: 1 }) + )); + + let mut invalid_version = valid.clone(); + invalid_version[3] = 2; + assert!(matches!( + decode(arc(invalid_version), ReadProfile::Strict), + Err(RsliError::UnsupportedVersion { got: 2 }) + )); + + let mut invalid_count = valid; + invalid_count[4..6].copy_from_slice(&(-1i16).to_le_bytes()); + assert!(matches!( + decode(arc(invalid_count), ReadProfile::Strict), + Err(RsliError::InvalidEntryCount { got: -1 }) + )); + } + + #[test] + fn rejects_entry_table_bounds() { + let mut bytes = synthetic_rsli(&[], false, 0, None); + bytes[4..6].copy_from_slice(&1i16.to_le_bytes()); + + assert!(matches!( + decode(arc(bytes), ReadProfile::Strict), + Err(RsliError::EntryTableOutOfBounds { .. }) + )); + } + + #[test] + fn table_xor_transform_uses_known_vector() { + assert_eq!( + xor_stream(&[0x00, 0x01, 0x02, 0x03], 0x1234), + [0x7A, 0x86, 0xB2, 0x8C] + ); + } + + #[test] + fn table_xor_transform_is_symmetric() { + let plain = b"entry table bytes".to_vec(); + let encrypted = xor_stream(&plain, 0x3456); + + assert_ne!(encrypted, plain); + assert_eq!(xor_stream(&encrypted, 0x3456), plain); + } + + #[test] + fn table_xor_state_spans_entries() { + let rows = two_plain_rows_for_transform_test(); + let whole_stream = xor_stream(&rows.concat(), 0x2468); + let row_reset = rows + .iter() + .flat_map(|row| xor_stream(row, 0x2468)) + .collect::>(); + + assert_ne!(whole_stream, row_reset); + + let bytes = synthetic_rsli( + &[ + SyntheticEntry::stored(b"A", 0, b"a"), + SyntheticEntry::stored(b"B", 1, b"b"), + ], + true, + 0x2468, + None, + ); + let doc = decode(arc(bytes), ReadProfile::Strict).expect("continuous table stream"); + assert_eq!(doc.entry_count(), 2); + } + + #[test] + fn presorted_mapping_uses_valid_permutation() { + let bytes = synthetic_rsli( + &[ + SyntheticEntry::stored(b"B", 1, b"bee"), + SyntheticEntry::stored(b"A", 0, b"aye"), + ], + true, + 0x4321, + None, + ); + + let doc = decode(arc(bytes), ReadProfile::Strict).expect("valid presorted map"); + + assert_eq!(doc.find("A"), Some(EntryId(1))); + assert_eq!(doc.find("B"), Some(EntryId(0))); + assert_eq!(doc.load(EntryId(1)).expect("A payload"), b"aye"); + } + + #[test] + fn compatible_profile_rebuilds_invalid_presorted_mapping() { + let bytes = synthetic_rsli( + &[ + SyntheticEntry::stored(b"B", 0, b"bee"), + SyntheticEntry::stored(b"A", 0, b"aye"), + ], + true, + 0x0102, + None, + ); + + assert!(matches!( + decode(arc(bytes.clone()), ReadProfile::Strict), + Err(RsliError::CorruptEntryTable(_)) + )); + + let doc = decode(arc(bytes), ReadProfile::Compatible).expect("compatible fallback"); + assert_eq!(doc.find("A"), Some(EntryId(1))); + assert_eq!(doc.find("B"), Some(EntryId(0))); + } + + #[test] + fn stored_method_uses_exact_size() { + let bytes = synthetic_rsli( + &[SyntheticEntry::stored(b"A", 0, b"abc")], + true, + 0x1111, + None, + ); + + let doc = decode(arc(bytes), ReadProfile::Strict).expect("stored entry"); + + assert_eq!(doc.load(EntryId(0)).expect("stored payload"), b"abc"); + assert_eq!(doc.entry(EntryId(0)).expect("stored meta").packed_size, 3); + } + + #[test] + fn xor_only_method_uses_entry_key() { + let plain = b"secret".to_vec(); + let packed = xor_stream(&plain, 1); + let bytes = synthetic_rsli( + &[ + SyntheticEntry::with_payload(b"A", 0x020, 1, &plain, packed), + SyntheticEntry::stored(b"B", 0, b"plain"), + ], + true, + 0x2222, + None, + ); + + let doc = decode(arc(bytes), ReadProfile::Strict).expect("xor entry"); + + assert_eq!(doc.load(EntryId(0)).expect("xor payload"), plain); + } + + #[test] + fn lzss_method_decodes_literals_references_and_wrap() { + let bytes = synthetic_rsli( + &[ + SyntheticEntry::with_payload( + b"LIT", + 0x040, + 0, + b"ABC", + vec![0b0000_0111, b'A', b'B', b'C'], + ), + SyntheticEntry::with_payload( + b"WRAP", + 0x040, + 1, + b" ", + vec![0b0000_0000, 0xFF, 0xF1], + ), + ], + true, + 0x1212, + None, + ); + + let doc = decode(arc(bytes), ReadProfile::Strict).expect("lzss archive"); + + assert_eq!(doc.load(EntryId(0)).expect("literal lzss"), b"ABC"); + assert_eq!(doc.load(EntryId(1)).expect("wrapped reference"), b" "); + } + + #[test] + fn xor_lzss_method_uses_entry_key() { + let plain_lzss = vec![0b0000_0111, b'X', b'Y', b'Z']; + let bytes = synthetic_rsli( + &[ + SyntheticEntry::with_payload(b"X", 0x060, 1, b"XYZ", xor_stream(&plain_lzss, 1)), + SyntheticEntry::stored(b"A", 0, b"filler"), + ], + true, + 0x3434, + None, + ); + + let doc = decode(arc(bytes), ReadProfile::Strict).expect("xor lzss archive"); + + assert_eq!(doc.load(EntryId(0)).expect("xor lzss"), b"XYZ"); + } + + #[test] + fn adaptive_lzss_method_decodes_synthetic_vector() { + let bytes = synthetic_rsli( + &[SyntheticEntry::with_payload( + b"A", + 0x080, + 0, + b"t", + vec![0x00], + )], + true, + 0, + None, + ); + + let doc = decode(arc(bytes), ReadProfile::Strict).expect("adaptive lzss archive"); + + assert_eq!(doc.load(EntryId(0)).expect("adaptive lzss"), b"t"); + } + + #[test] + fn xor_adaptive_lzss_method_decodes_synthetic_vector() { + let bytes = synthetic_rsli( + &[ + SyntheticEntry::with_payload(b"X", 0x0A0, 1, b"t", vec![0x02]), + SyntheticEntry::stored(b"A", 0, b"filler"), + ], + true, + 0x5656, + None, + ); + + let doc = decode(arc(bytes), ReadProfile::Strict).expect("xor adaptive lzss archive"); + + assert_eq!(doc.load(EntryId(0)).expect("xor adaptive lzss"), b"t"); + } + + #[test] + fn raw_deflate_method_expects_raw_stream_not_zlib_wrapper() { + let raw_deflate = vec![0x01, 0x03, 0x00, 0xFC, 0xFF, b'r', b'a', b'w']; + let bytes = synthetic_rsli( + &[SyntheticEntry::with_payload( + b"RAW", + 0x100, + 0, + b"raw", + raw_deflate, + )], + true, + 0, + None, + ); + let doc = decode(arc(bytes), ReadProfile::Strict).expect("raw deflate archive"); + assert_eq!(doc.load(EntryId(0)).expect("raw deflate"), b"raw"); + + let zlib_wrapped = vec![ + 0x78, 0x01, 0x01, 0x03, 0x00, 0xFC, 0xFF, b'r', b'a', b'w', 0x02, 0x92, 0x01, 0x4B, + ]; + let wrapped = synthetic_rsli( + &[SyntheticEntry::with_payload( + b"ZLIB", + 0x100, + 0, + b"raw", + zlib_wrapped, + )], + true, + 0, + None, + ); + let doc = decode(arc(wrapped), ReadProfile::Strict).expect("zlib wrapped archive"); + assert!(matches!( + doc.load(EntryId(0)), + Err(RsliError::DecompressionFailed("deflate")) + )); + } + + #[test] + fn named_deflate_eof_plus_one_quirk_accepts_only_approved_entry() { + let raw_deflate = vec![0x01, 0x03, 0x00, 0xFC, 0xFF, b'r', b'a', b'w']; + let approved = synthetic_rsli( + &[SyntheticEntry::with_declared_packed_size( + b"INTERF8.TEX", + 0x100, + 0, + b"raw", + raw_deflate.clone(), + u32::try_from(raw_deflate.len() + 1).expect("declared size"), + )], + true, + 0, + None, + ); + + assert!(matches!( + decode(arc(approved.clone()), ReadProfile::Strict), + Err(RsliError::DeflateEofPlusOneQuirkRejected { id: 0 }) + )); + let doc = decode(arc(approved), ReadProfile::Compatible).expect("approved EOF+1 quirk"); + assert_eq!(doc.load(EntryId(0)).expect("approved payload"), b"raw"); + + let unknown = synthetic_rsli( + &[SyntheticEntry::with_declared_packed_size( + b"OTHER.TEX", + 0x100, + 0, + b"raw", + raw_deflate.clone(), + u32::try_from(raw_deflate.len() + 1).expect("declared size"), + )], + true, + 0, + None, + ); + assert!(matches!( + decode(arc(unknown), ReadProfile::Compatible), + Err(RsliError::DeflateEofPlusOneQuirkRejected { id: 0 }) + )); + + let plus_two = synthetic_rsli( + &[SyntheticEntry::with_declared_packed_size( + b"INTERF8.TEX", + 0x100, + 0, + b"raw", + raw_deflate.clone(), + u32::try_from(raw_deflate.len() + 2).expect("declared size"), + )], + true, + 0, + None, + ); + assert!(matches!( + decode(arc(plus_two), ReadProfile::Compatible), + Err(RsliError::PackedSizePastEof { id: 0, .. }) + )); + } + + #[test] + fn unknown_method_is_rejected_on_load() { + let bytes = synthetic_rsli( + &[SyntheticEntry::with_payload( + b"A", + 0x1E0, + 0, + b"abc", + b"abc".to_vec(), + )], + true, + 0, + None, + ); + let doc = decode(arc(bytes), ReadProfile::Strict).expect("unknown method archive"); + + assert!(matches!( + doc.load(EntryId(0)), + Err(RsliError::UnsupportedMethod { raw: 0x1E0 }) + )); + } + + #[test] + fn decoded_size_mismatch_is_rejected() { + let bytes = synthetic_rsli( + &[SyntheticEntry::with_payload( + b"A", + 0x000, + 0, + b"abc", + b"ab".to_vec(), + )], + true, + 0, + None, + ); + let doc = decode(arc(bytes), ReadProfile::Strict).expect("mismatched entry archive"); + + assert!(matches!( + doc.load(EntryId(0)), + Err(RsliError::OutputSizeMismatch { + expected: 3, + got: 2 + }) + )); + } + + #[test] + fn ao_overlay_adjusts_effective_offsets() { + let bytes = synthetic_rsli( + &[SyntheticEntry::stored(b"A", 0, b"media")], + true, + 0x3333, + Some(4), + ); + + let doc = decode(arc(bytes), ReadProfile::Compatible).expect("AO overlay"); + let meta = doc.entry(EntryId(0)).expect("AO meta"); + assert_eq!(meta.data_offset, 64); + assert_eq!(meta.data_offset_raw, 60); + assert_eq!(doc.load(EntryId(0)).expect("AO payload"), b"media"); + } + + #[test] + fn invalid_ao_overlay_is_rejected() { + let mut bytes = synthetic_rsli(&[], false, 0, None); + bytes.extend_from_slice(b"AO"); + bytes.extend_from_slice(&1000u32.to_le_bytes()); + + assert!(matches!( + decode(arc(bytes), ReadProfile::Compatible), + Err(RsliError::MediaOverlayOutOfBounds { overlay: 1000, .. }) + )); + } + + #[test] + fn unknown_header_bytes_are_lossless() { + let mut bytes = synthetic_rsli( + &[SyntheticEntry::stored(b"A", 0, b"abc")], + true, + 0x4444, + None, + ); + bytes[6] = 0xA5; + bytes[24] = 0x5A; + + let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("unknown header bytes"); + + assert_eq!(doc.header().raw[6], 0xA5); + assert_eq!(doc.header().raw[24], 0x5A); + assert_eq!(doc.encode(WriteProfile::Lossless), bytes); + } + + #[test] + fn no_op_lossless_roundtrip_preserves_bytes() { + let bytes = synthetic_rsli( + &[ + SyntheticEntry::stored(b"A", 0, b"alpha"), + SyntheticEntry::stored(b"B", 1, b"beta"), + ], + true, + 0x5555, + None, + ); + + let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("roundtrip archive"); + + assert_eq!(doc.encode(WriteProfile::Lossless), bytes); + } + + #[test] + fn generated_supported_methods_decode_expected_bytes() { + let cases = [ + (0x000, b"STO".as_slice(), b"ok".as_slice(), b"ok".to_vec()), + ( + 0x020, + b"XOR".as_slice(), + b"ok".as_slice(), + xor_stream(b"ok", 0), + ), + ( + 0x040, + b"LZS".as_slice(), + b"ok".as_slice(), + vec![0b0000_0011, b'o', b'k'], + ), + ( + 0x060, + b"XLZ".as_slice(), + b"ok".as_slice(), + xor_stream(&[0b0000_0011, b'o', b'k'], 0), + ), + (0x080, b"ADP".as_slice(), b"t".as_slice(), vec![0x00]), + ( + 0x0A0, + b"XAD".as_slice(), + b"t".as_slice(), + xor_stream(&[0x00], 0), + ), + ( + 0x100, + b"DEF".as_slice(), + b"ok".as_slice(), + vec![0x01, 0x02, 0x00, 0xFD, 0xFF, b'o', b'k'], + ), + ]; + + for (idx, (method, name, expected, packed)) in cases.iter().enumerate() { + let bytes = synthetic_rsli( + &[SyntheticEntry::with_payload( + name, + *method, + 0, + expected, + packed.clone(), + )], + true, + u16::try_from(idx).expect("case index"), + None, + ); + let doc = decode(arc(bytes), ReadProfile::Strict).expect("generated method archive"); + assert_eq!( + doc.load(EntryId(0)).expect("generated method payload"), + *expected + ); + } + } + + #[test] + fn arbitrary_small_inputs_do_not_panic() { + for len in 0..128usize { + let mut bytes = vec![0u8; len]; + if len >= 4 { + bytes[0..4].copy_from_slice(b"NL\0\x01"); + } + if len >= 6 { + bytes[4..6].copy_from_slice(&((len % 8) as i16).to_le_bytes()); + } + if len >= 24 { + bytes[20..24].copy_from_slice(&0x1357u32.to_le_bytes()); + } + + let strict = + std::panic::catch_unwind(|| decode(arc(bytes.clone()), ReadProfile::Strict)); + let compatible = + std::panic::catch_unwind(|| decode(arc(bytes.clone()), ReadProfile::Compatible)); + assert!(strict.is_ok()); + assert!(compatible.is_ok()); + } + } + + #[test] + fn licensed_corpora_rsli_roundtrip_gates() { + let part1 = corpus_gate("IS", 2).expect("part 1 RsLi gate"); + let part2 = corpus_gate("IS2", 2).expect("part 2 RsLi gate"); + + assert!(part1.entries > 0); + assert!(part2.entries > 0); + } + + #[test] + fn licensed_part1_rsli_method_distribution_baseline() { + let stats = corpus_gate("IS", 2).expect("part 1 RsLi gate"); + + assert_eq!( + stats.methods, + RsliMethodCounts { + stored: 0, + xor_only: 0, + lzss: 2, + xor_lzss: 0, + adaptive_lzss: 0, + xor_adaptive_lzss: 0, + raw_deflate: 24, + unknown: 0, + } + ); + } + + #[test] + fn licensed_part2_rsli_method_distribution_baseline() { + let stats = corpus_gate("IS2", 2).expect("part 2 RsLi gate"); + + assert_eq!( + stats.methods, + RsliMethodCounts { + stored: 0, + xor_only: 0, + lzss: 2, + xor_lzss: 0, + adaptive_lzss: 0, + xor_adaptive_lzss: 0, + raw_deflate: 24, + unknown: 0, + } + ); + } + + #[test] + fn licensed_corpora_rsli_quirk_is_only_approved_interf8_tex() { + let part1 = corpus_gate("IS", 2).expect("part 1 RsLi gate"); + let part2 = corpus_gate("IS2", 2).expect("part 2 RsLi gate"); + + assert_eq!( + part1.eof_plus_one_entries, + vec!["sprites.lib:INTERF8.TEX".to_string()] + ); + assert_eq!( + part2.eof_plus_one_entries, + vec!["sprites.lib:INTERF8.TEX".to_string()] + ); + assert_strict_profile_only_rejects_approved_quirk("IS"); + assert_strict_profile_only_rejects_approved_quirk("IS2"); + } + + #[derive(Clone, Debug, Default, Eq, PartialEq)] + struct RsliMethodCounts { + stored: usize, + xor_only: usize, + lzss: usize, + xor_lzss: usize, + adaptive_lzss: usize, + xor_adaptive_lzss: usize, + raw_deflate: usize, + unknown: usize, + } + + impl RsliMethodCounts { + fn add(&mut self, method: RsliMethod) { + match method { + RsliMethod::Stored => self.stored += 1, + RsliMethod::XorOnly => self.xor_only += 1, + RsliMethod::Lzss => self.lzss += 1, + RsliMethod::XorLzss => self.xor_lzss += 1, + RsliMethod::AdaptiveLzss => self.adaptive_lzss += 1, + RsliMethod::XorAdaptiveLzss => self.xor_adaptive_lzss += 1, + RsliMethod::RawDeflate => self.raw_deflate += 1, + RsliMethod::Unknown(_) => self.unknown += 1, + } + } + } + + #[derive(Clone, Debug, Default, Eq, PartialEq)] + struct CorpusGateResult { + entries: usize, + methods: RsliMethodCounts, + eof_plus_one_entries: Vec, + } + + fn corpus_gate(name: &str, expected_files: usize) -> Result { + let files = corpus_files(name)?; + if files.len() != expected_files { + return Err(format!( + "{name}: expected {expected_files} RsLi files, got {}", + files.len() + )); + } + + let mut entries = 0usize; + let mut methods = RsliMethodCounts::default(); + let mut eof_plus_one_entries = Vec::new(); + for path in &files { + let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?; + let doc = decode(arc(bytes.clone()), ReadProfile::Compatible) + .map_err(|err| format!("{}: {err}", path.display()))?; + entries = entries + .checked_add(doc.entry_count()) + .ok_or_else(|| "entry count overflow".to_string())?; + for (idx, entry) in doc.entries().iter().enumerate() { + methods.add(entry.method); + if entry.method == RsliMethod::RawDeflate + && entry.data_offset + u64::from(entry.packed_size) == bytes.len() as u64 + 1 + { + eof_plus_one_entries.push(format!( + "{}:{}", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""), + entry.name + )); + } + let id = EntryId(u32::try_from(idx).map_err(|_| "entry id overflow")?); + let found = doc + .find(&entry.name) + .ok_or_else(|| format!("lookup failed: {}", path.display()))?; + if found != id { + return Err(format!("lookup mismatch: {}", path.display())); + } + let unpacked = doc + .load(id) + .map_err(|err| format!("{} entry #{idx}: {err}", path.display()))?; + if unpacked.len() + != usize::try_from(entry.unpacked_size).map_err(|_| "size overflow")? + { + return Err(format!("unpacked size mismatch: {}", path.display())); + } + let packed = doc + .load_packed(id) + .map_err(|err| format!("{} entry #{idx}: {err}", path.display()))?; + if packed.packed.is_empty() && entry.packed_size != 0 { + return Err(format!( + "packed payload unexpectedly empty: {}", + path.display() + )); + } + } + if doc.encode(WriteProfile::Lossless) != bytes { + return Err(format!("lossless roundtrip mismatch: {}", path.display())); + } + } + Ok(CorpusGateResult { + entries, + methods, + eof_plus_one_entries, + }) + } + + fn corpus_files(name: &str) -> Result, String> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("testdata") + .join(name); + if !root.is_dir() { + return Err(format!( + "licensed corpus root is missing: {}", + root.display() + )); + } + let mut files = Vec::new(); + collect_rsli_files(&root, &mut files).map_err(|err| err.to_string())?; + files.sort(); + Ok(files) + } + + fn assert_strict_profile_only_rejects_approved_quirk(name: &str) { + for path in corpus_files(name).expect("licensed RsLi files") { + let bytes = fs::read(&path).expect("licensed RsLi bytes"); + let doc = decode(arc(bytes.clone()), ReadProfile::Compatible) + .expect("compatible licensed RsLi"); + let mut eof_plus_one_names = Vec::new(); + for entry in doc.entries() { + if entry.method == RsliMethod::RawDeflate + && entry.data_offset + u64::from(entry.packed_size) == bytes.len() as u64 + 1 + { + eof_plus_one_names.push(entry.name.clone()); + } + } + + let strict = decode(arc(bytes), ReadProfile::Strict); + if eof_plus_one_names.is_empty() { + assert!( + strict.is_ok(), + "strict profile should accept {}", + path.display() + ); + } else { + assert_eq!(eof_plus_one_names, vec!["INTERF8.TEX".to_string()]); + assert!( + matches!( + strict, + Err(RsliError::DeflateEofPlusOneQuirkRejected { .. }) + ), + "strict profile should only reject the approved EOF+1 quirk in {}", + path.display() + ); + } + } + } + + fn collect_rsli_files(root: &Path, out: &mut Vec) -> std::io::Result<()> { + for entry in fs::read_dir(root)? { + let path = entry?.path(); + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with('.')) + { + continue; + } + if path.is_dir() { + collect_rsli_files(&path, out)?; + continue; + } + if path.is_file() { + let bytes = fs::read(&path)?; + if bytes.get(0..4) == Some(b"NL\0\x01") { + out.push(path); + } + } + } + Ok(()) + } + + fn arc(bytes: Vec) -> Arc<[u8]> { + Arc::from(bytes.into_boxed_slice()) + } + + #[derive(Clone, Debug)] + struct SyntheticEntry { + name: Vec, + method_raw: u32, + sort_to_original: i16, + unpacked_size: u32, + declared_packed_size: u32, + packed: Vec, + } + + impl SyntheticEntry { + fn stored(name: &[u8], sort_to_original: i16, payload: &[u8]) -> Self { + Self::with_payload(name, 0x000, sort_to_original, payload, payload.to_vec()) + } + + fn with_payload( + name: &[u8], + method_raw: u32, + sort_to_original: i16, + unpacked: &[u8], + packed: Vec, + ) -> Self { + let declared_packed_size = u32::try_from(packed.len()).expect("synthetic packed size"); + Self::with_declared_packed_size( + name, + method_raw, + sort_to_original, + unpacked, + packed, + declared_packed_size, + ) + } + + fn with_declared_packed_size( + name: &[u8], + method_raw: u32, + sort_to_original: i16, + unpacked: &[u8], + packed: Vec, + declared_packed_size: u32, + ) -> Self { + Self { + name: name.to_vec(), + method_raw, + sort_to_original, + unpacked_size: u32::try_from(unpacked.len()).expect("synthetic unpacked size"), + declared_packed_size, + packed, + } + } + } + + fn synthetic_rsli( + entries: &[SyntheticEntry], + presorted: bool, + xor_seed: u16, + overlay: Option, + ) -> Vec { + let count = i16::try_from(entries.len()).expect("synthetic entry count"); + let table_len = entries + .len() + .checked_mul(32) + .expect("synthetic table length"); + let payload_offset = 32usize + .checked_add(table_len) + .expect("synthetic payload offset"); + let overlay = overlay.unwrap_or(0); + + let mut header = [0u8; 32]; + header[0..4].copy_from_slice(b"NL\0\x01"); + header[4..6].copy_from_slice(&count.to_le_bytes()); + if presorted { + header[14..16].copy_from_slice(&0xABBAu16.to_le_bytes()); + } + header[20..24].copy_from_slice(&u32::from(xor_seed).to_le_bytes()); + + let mut table_plain = Vec::with_capacity(table_len); + let mut cursor = payload_offset; + for entry in entries { + let mut row = [0u8; 32]; + let name_len = entry.name.len().min(12); + row[0..name_len].copy_from_slice(&entry.name[..name_len]); + row[16..18].copy_from_slice( + &i16::try_from(entry.method_raw) + .expect("synthetic method fits") + .to_le_bytes(), + ); + row[18..20].copy_from_slice(&entry.sort_to_original.to_le_bytes()); + row[20..24].copy_from_slice(&entry.unpacked_size.to_le_bytes()); + let raw_offset = u32::try_from(cursor) + .expect("synthetic offset") + .checked_sub(overlay) + .expect("synthetic overlay precedes payload"); + row[24..28].copy_from_slice(&raw_offset.to_le_bytes()); + row[28..32].copy_from_slice(&entry.declared_packed_size.to_le_bytes()); + table_plain.extend_from_slice(&row); + cursor = cursor + .checked_add(entry.packed.len()) + .expect("synthetic payload cursor"); + } + + let mut bytes = Vec::with_capacity(cursor + 6); + bytes.extend_from_slice(&header); + bytes.extend_from_slice(&xor_stream(&table_plain, xor_seed)); + for entry in entries { + bytes.extend_from_slice(&entry.packed); + } + if overlay != 0 { + bytes.extend_from_slice(b"AO"); + bytes.extend_from_slice(&overlay.to_le_bytes()); + } + bytes + } + + fn two_plain_rows_for_transform_test() -> Vec<[u8; 32]> { + let mut a = [0u8; 32]; + let mut b = [0u8; 32]; + a[0] = b'A'; + b[0] = b'B'; + a[18..20].copy_from_slice(&0i16.to_le_bytes()); + b[18..20].copy_from_slice(&1i16.to_le_bytes()); + vec![a, b] + } +} diff --git a/crates/fparkan-runtime/Cargo.toml b/crates/fparkan-runtime/Cargo.toml new file mode 100644 index 0000000..17d95c1 --- /dev/null +++ b/crates/fparkan-runtime/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "fparkan-runtime" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-mission-format = { path = "../fparkan-mission-format" } +fparkan-nres = { path = "../fparkan-nres" } +fparkan-path = { path = "../fparkan-path" } +fparkan-platform = { path = "../fparkan-platform" } +fparkan-prototype = { path = "../fparkan-prototype" } +fparkan-render = { path = "../fparkan-render" } +fparkan-resource = { path = "../fparkan-resource" } +fparkan-terrain = { path = "../fparkan-terrain" } +fparkan-terrain-format = { path = "../fparkan-terrain-format" } +fparkan-vfs = { path = "../fparkan-vfs" } +fparkan-world = { path = "../fparkan-world" } + +[lints] +workspace = true diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs new file mode 100644 index 0000000..2a05c4a --- /dev/null +++ b/crates/fparkan-runtime/src/lib.rs @@ -0,0 +1,1099 @@ +#![forbid(unsafe_code)] +//! Runtime orchestration for headless and rendered modes. + +use fparkan_mission_format::{ + decode_tma, decode_tma_land_path, LpString, MissionDocument, MissionError, TmaProfile, +}; +use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy}; +use fparkan_prototype::{ + build_prototype_graph_report, extend_graph_report_with_visual_dependencies, EffectivePrototype, + PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport, +}; +use fparkan_resource::{resource_name, CachedResourceRepository}; +use fparkan_terrain::TerrainWorld; +use fparkan_terrain_format::{ + decode_build_dat, decode_land_map, decode_land_msh, BuildCategory, TerrainFormatError, +}; +use fparkan_vfs::{Vfs, VfsError}; +use fparkan_world::{ + construct_object, new as new_world, register_object, step, InputSnapshot, ObjectDraft, + OriginalObjectId, World, WorldConfig, WorldSnapshot, +}; +use std::sync::Arc; + +/// Engine mode. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EngineMode { + /// Headless. + Headless, + /// Rendered. + Rendered, +} + +/// Scheduler phase. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SchedulerPhase { + /// Collect platform events. + CollectPlatformEvents, + /// Build input snapshot. + BuildInputSnapshot, + /// Advance clock. + AdvanceGameClock, + /// Calculate world queue. + CalculateWorldQueue, + /// Apply deferred operations. + ApplyDeferredOperations, + /// Update animation/effects. + UpdateAnimationAndEffects, + /// Publish render snapshot. + PublishRenderSnapshot, + /// Render world. + RenderWorld, + /// End frame callbacks. + EndFrameCallbacks, + /// Maintenance. + Maintenance, +} + +/// Engine config. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EngineConfig { + /// Mode. + pub mode: EngineMode, +} + +/// Injectable engine services used by composition roots. +#[derive(Clone, Default)] +pub struct EngineServices { + /// Resource filesystem. + pub vfs: Option>, +} + +impl EngineServices { + /// Creates services with a VFS. + #[must_use] + pub fn new(vfs: Arc) -> Self { + Self { vfs: Some(vfs) } + } +} + +/// Mission request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionRequest { + /// Mission key/path. + pub key: String, +} + +/// Mission loading phase captured for diagnostics and acceptance tests. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MissionLoadPhase { + /// Resolve services and mission request context. + Context, + /// Decode and validate TMA. + Tma, + /// Decode and validate terrain map assets. + Map, + /// Expand object roots into a prototype graph. + Graph, + /// Prepare all reachable visual/resource dependencies. + Assets, + /// Construct all object drafts before registration. + Construct, + /// Register constructed objects. + Register, +} + +/// Raw placed transform preserved by the mission loader. +#[derive(Clone, Debug, PartialEq)] +pub struct PlacedTransformProfile { + /// Object index in TMA order. + pub object_index: usize, + /// Raw position vector. + pub position: [f32; 3], + /// Raw orientation vector. No Euler order is inferred here. + pub orientation_raw: [f32; 3], + /// Raw scale vector. + pub scale: [f32; 3], +} + +/// Mission loading trace. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MissionLoadTrace { + /// Observed phases in execution order. + pub phases: Vec, + /// Number of object drafts constructed before the first registration. + pub drafts_before_registration: usize, + /// Number of objects registered. + pub registered_objects: usize, + /// Raw transform profile for placed objects. + pub transforms: Vec, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct MissionLoadOptions { + fail_after_registered_objects: Option, +} + +/// Loaded mission. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LoadedMission { + /// Mission key. + pub key: String, + /// Decoded mission path count. + pub path_count: usize, + /// Decoded clan count. + pub clan_count: usize, + /// Decoded placed object count. + pub object_count: usize, + /// Decoded extra record count. + pub extra_count: usize, + /// `Land.msh` path. + pub land_msh_path: String, + /// `Land.map` path. + pub land_map_path: String, + /// Build category count. + pub build_category_count: usize, + /// Runtime navigation area count. + pub areal_count: usize, + /// Runtime surface triangle count. + pub surface_count: usize, + /// Registered world object count. + pub registered_objects: usize, + /// Mission resource roots that point to unit DAT files. + pub graph_unit_reference_count: usize, + /// Mission resource roots that point directly to prototype keys. + pub graph_direct_reference_count: usize, + /// Component records reached from unit DAT roots. + pub graph_unit_component_count: usize, + /// Mission prototype graph root count. + pub graph_root_count: usize, + /// Expanded prototype requests resolved to effective prototypes. + pub graph_resolved_count: usize, + /// Reached mesh dependency count. + pub graph_mesh_dependency_count: usize, + /// Graph failure count. + pub graph_failure_count: usize, + /// WEAR requests derived from graph meshes. + pub graph_wear_request_count: usize, + /// WEAR entries decoded. + pub graph_wear_resolved_count: usize, + /// WEAR material slots requested. + pub graph_material_slot_count: usize, + /// MAT0 entries decoded. + pub graph_material_resolved_count: usize, + /// Texture requests derived from MAT0 phases. + pub graph_texture_request_count: usize, + /// Texm texture entries decoded. + pub graph_texture_resolved_count: usize, + /// Lightmap requests declared by WEAR tables. + pub graph_lightmap_request_count: usize, + /// Lightmap Texm entries decoded. + pub graph_lightmap_resolved_count: usize, +} + +/// Frame result. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FrameResult { + /// Snapshot. + pub snapshot: WorldSnapshot, +} + +/// Engine. +pub struct Engine { + config: EngineConfig, + services: EngineServices, + world: World, + loaded: Option, +} + +struct LoadedMissionState { + summary: LoadedMission, + mission: MissionDocument, + terrain: TerrainWorld, + build_categories: Vec, + prototype_graph: PrototypeGraph, + prototype_report: PrototypeGraphReport, + resolved_prototypes: Vec, +} + +/// Engine error. +#[derive(Debug)] +pub enum EngineError { + /// Engine was created without a resource VFS. + MissingVfs, + /// Invalid resource path. + Path { + /// Path role. + role: &'static str, + /// Raw value. + value: String, + /// Source error. + source: PathError, + }, + /// VFS error. + Vfs { + /// Resource path. + path: String, + /// Source error. + source: VfsError, + }, + /// `NRes` decode error. + Nres { + /// Resource path. + path: String, + /// Source error. + source: fparkan_nres::NresError, + }, + /// Mission decode error. + Mission { + /// Resource path. + path: String, + /// Source error. + source: MissionError, + }, + /// Terrain disk format error. + TerrainFormat { + /// Resource path. + path: String, + /// Source error. + source: TerrainFormatError, + }, + /// Terrain runtime build error. + Terrain(fparkan_terrain::TerrainError), + /// Prototype graph errors. + PrototypeGraph { + /// Root failures. + failures: Vec, + }, + /// World error. + World(fparkan_world::WorldError), + /// Staged mission world was torn down after a registration-phase failure. + RegistrationTeardown { + /// Registered objects before the forced failure. + registered_objects: usize, + /// Objects released by normal world shutdown. + released_objects: usize, + /// Managers were released after objects. + managers_released: bool, + }, +} + +impl From for EngineError { + fn from(value: fparkan_world::WorldError) -> Self { + Self::World(value) + } +} + +impl std::fmt::Display for EngineError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingVfs => write!(f, "mission loading requires a VFS service"), + Self::Path { + role, + value, + source, + } => { + write!(f, "invalid {role} path '{value}': {source}") + } + Self::Vfs { path, source } => write!(f, "{path}: {source}"), + Self::Nres { path, source } => write!(f, "{path}: {source}"), + Self::Mission { path, source } => write!(f, "{path}: {source}"), + Self::TerrainFormat { path, source } => write!(f, "{path}: {source}"), + Self::Terrain(source) => write!(f, "{source}"), + Self::PrototypeGraph { failures } => { + write!(f, "mission prototype graph has {} failures", failures.len()) + } + Self::World(source) => write!(f, "{source}"), + Self::RegistrationTeardown { + registered_objects, + released_objects, + managers_released, + } => write!( + f, + "mission registration failed after {registered_objects} objects; teardown released {released_objects}, managers_released={managers_released}" + ), + } + } +} + +impl std::error::Error for EngineError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Path { source, .. } => Some(source), + Self::Vfs { source, .. } => Some(source), + Self::Nres { source, .. } => Some(source), + Self::Mission { source, .. } => Some(source), + Self::TerrainFormat { source, .. } => Some(source), + Self::Terrain(source) => Some(source), + Self::World(source) => Some(source), + Self::MissingVfs | Self::PrototypeGraph { .. } | Self::RegistrationTeardown { .. } => { + None + } + } + } +} + +/// Creates engine. +/// +/// # Errors +/// +/// Currently this constructor is infallible, but it returns +/// [`EngineError`] to keep the composition-root API stable as services become +/// mandatory. +pub fn create(config: EngineConfig, services: EngineServices) -> Result { + Ok(Engine { + config, + services, + world: new_world(WorldConfig), + loaded: None, + }) +} + +/// Loads mission transactionally. +/// +/// # Errors +/// +/// Returns [`EngineError`] when VFS services are missing, mission paths are +/// invalid, required files cannot be read, disk formats fail validation, terrain +/// runtime data cannot be built, prototype graph roots do not resolve, or +/// object registration fails. +pub fn load_mission( + engine: &mut Engine, + request: MissionRequest, +) -> Result { + load_mission_with_trace(engine, request).map(|(loaded, _trace)| loaded) +} + +/// Loads mission transactionally and returns a diagnostic trace. +/// +/// # Errors +/// +/// Returns [`EngineError`] under the same conditions as [`load_mission`]. +pub fn load_mission_with_trace( + engine: &mut Engine, + request: MissionRequest, +) -> Result<(LoadedMission, MissionLoadTrace), EngineError> { + load_mission_with_options(engine, request, MissionLoadOptions::default()) +} + +#[allow(clippy::too_many_lines)] +fn load_mission_with_options( + engine: &mut Engine, + request: MissionRequest, + options: MissionLoadOptions, +) -> Result<(LoadedMission, MissionLoadTrace), EngineError> { + let mut trace = MissionLoadTrace::default(); + trace.phases.push(MissionLoadPhase::Context); + let vfs = engine.services.vfs.clone().ok_or(EngineError::MissingVfs)?; + let mission_path = normalize_engine_path("mission", &request.key)?; + let mission_bytes = read_vfs(&vfs, &mission_path)?; + + trace.phases.push(MissionLoadPhase::Map); + let land_path = decode_tma_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| { + EngineError::Mission { + path: mission_path.as_str().to_string(), + source, + } + })?; + let (land_msh_path, land_map_path) = terrain_paths_from_land_path(&land_path)?; + let land_msh_nres = decode_nres(&vfs, &land_msh_path)?; + let land_map_nres = decode_nres(&vfs, &land_map_path)?; + let land_msh = + decode_land_msh(&land_msh_nres).map_err(|source| EngineError::TerrainFormat { + path: land_msh_path.as_str().to_string(), + source, + })?; + let land_map = + decode_land_map(&land_map_nres).map_err(|source| EngineError::TerrainFormat { + path: land_map_path.as_str().to_string(), + source, + })?; + let terrain = + TerrainWorld::from_land_assets(&land_msh, &land_map).map_err(EngineError::Terrain)?; + + let build_dat_path = normalize_engine_path("BuildDat", "BuildDat.lst")?; + let build_dat = read_vfs(&vfs, &build_dat_path)?; + let build_categories = + decode_build_dat(&build_dat).map_err(|source| EngineError::TerrainFormat { + path: build_dat_path.as_str().to_string(), + source, + })?; + trace.phases.push(MissionLoadPhase::Tma); + let mission = + decode_tma(mission_bytes, TmaProfile::Strict).map_err(|source| EngineError::Mission { + path: mission_path.as_str().to_string(), + source, + })?; + let verified_terrain_paths = terrain_paths(&mission)?; + debug_assert_eq!(verified_terrain_paths.0.as_str(), land_msh_path.as_str()); + debug_assert_eq!(verified_terrain_paths.1.as_str(), land_map_path.as_str()); + trace.transforms = mission + .objects + .iter() + .enumerate() + .map(|(object_index, object)| PlacedTransformProfile { + object_index, + position: object.position, + orientation_raw: object.orientation, + scale: object.scale, + }) + .collect(); + trace.phases.push(MissionLoadPhase::Graph); + let repository = CachedResourceRepository::new(vfs.clone()); + let graph_roots: Vec<_> = mission + .objects + .iter() + .map(|object| resource_name(&object.resource_name.raw)) + .collect(); + let (prototype_graph, resolved_prototypes, mut prototype_report) = + build_prototype_graph_report(&repository, vfs.as_ref(), &graph_roots); + extend_graph_report_with_visual_dependencies( + &repository, + &mut prototype_report, + &resolved_prototypes, + ); + if !prototype_report.is_success() { + return Err(EngineError::PrototypeGraph { + failures: prototype_report.failures.clone(), + }); + } + trace.phases.push(MissionLoadPhase::Assets); + + let mut new_runtime_world = new_world(WorldConfig); + let mut handles = Vec::with_capacity(mission.objects.len()); + trace.phases.push(MissionLoadPhase::Construct); + for (index, _object) in mission.objects.iter().enumerate() { + let original_id = u32::try_from(index).ok().map(OriginalObjectId); + let handle = construct_object(&mut new_runtime_world, ObjectDraft { original_id })?; + handles.push(handle); + } + trace.drafts_before_registration = handles.len(); + trace.phases.push(MissionLoadPhase::Register); + for handle in &handles { + if options.fail_after_registered_objects == Some(trace.registered_objects) { + let report = fparkan_world::shutdown(new_runtime_world); + return Err(EngineError::RegistrationTeardown { + registered_objects: trace.registered_objects, + released_objects: report.released_objects.len(), + managers_released: report.managers_released, + }); + } + register_object(&mut new_runtime_world, *handle)?; + trace.registered_objects += 1; + } + + let summary = LoadedMission { + key: request.key, + path_count: mission.paths.len(), + clan_count: mission.clans.len(), + object_count: mission.objects.len(), + extra_count: mission.extras.len(), + land_msh_path: land_msh_path.as_str().to_string(), + land_map_path: land_map_path.as_str().to_string(), + build_category_count: build_categories.len(), + areal_count: terrain.areal_count(), + surface_count: terrain.surface_count(), + registered_objects: handles.len(), + graph_unit_reference_count: prototype_report.unit_reference_count, + graph_direct_reference_count: prototype_report.direct_reference_count, + graph_unit_component_count: prototype_report.unit_component_count, + graph_root_count: prototype_report.root_count, + graph_resolved_count: prototype_report.resolved_count, + graph_mesh_dependency_count: prototype_report.mesh_dependency_count, + graph_failure_count: prototype_report.failures.len(), + graph_wear_request_count: prototype_report.wear_request_count, + graph_wear_resolved_count: prototype_report.wear_resolved_count, + graph_material_slot_count: prototype_report.material_slot_count, + graph_material_resolved_count: prototype_report.material_resolved_count, + graph_texture_request_count: prototype_report.texture_request_count, + graph_texture_resolved_count: prototype_report.texture_resolved_count, + graph_lightmap_request_count: prototype_report.lightmap_request_count, + graph_lightmap_resolved_count: prototype_report.lightmap_resolved_count, + }; + + engine.world = new_runtime_world; + engine.loaded = Some(LoadedMissionState { + summary: summary.clone(), + mission, + terrain, + build_categories, + prototype_graph, + prototype_report, + resolved_prototypes, + }); + Ok((summary, trace)) +} + +/// Steps headless mode. +/// +/// # Errors +/// +/// Returns [`EngineError`] when the world step fails. +pub fn step_headless( + engine: &mut Engine, + input: InputSnapshot, +) -> Result { + let snapshot = step(&mut engine.world, &input)?; + Ok(FrameResult { snapshot }) +} + +/// Steps rendered mode. +/// +/// # Errors +/// +/// Returns [`EngineError`] when the world step fails. +pub fn frame(engine: &mut Engine) -> Result { + match engine.config.mode { + EngineMode::Headless | EngineMode::Rendered => step_headless(engine, InputSnapshot), + } +} + +/// Shuts down engine. +/// +/// # Errors +/// +/// Currently shutdown is infallible, but the `Result` preserves the lifecycle +/// API for future service teardown failures. +pub fn shutdown(_engine: Engine) -> Result<(), EngineError> { + Ok(()) +} + +/// Returns the loaded mission summary. +#[must_use] +pub fn loaded_mission(engine: &Engine) -> Option<&LoadedMission> { + engine.loaded.as_ref().map(|state| &state.summary) +} + +/// Returns the decoded mission document for the loaded mission. +#[must_use] +pub fn loaded_mission_document(engine: &Engine) -> Option<&MissionDocument> { + engine.loaded.as_ref().map(|state| &state.mission) +} + +/// Returns terrain runtime data for the loaded mission. +#[must_use] +pub fn loaded_terrain(engine: &Engine) -> Option<&TerrainWorld> { + engine.loaded.as_ref().map(|state| &state.terrain) +} + +/// Returns decoded build categories for the loaded game root. +#[must_use] +pub fn loaded_build_categories(engine: &Engine) -> Option<&[BuildCategory]> { + engine + .loaded + .as_ref() + .map(|state| state.build_categories.as_slice()) +} + +/// Returns the loaded prototype graph. +#[must_use] +pub fn loaded_prototype_graph(engine: &Engine) -> Option<&PrototypeGraph> { + engine.loaded.as_ref().map(|state| &state.prototype_graph) +} + +/// Returns the loaded prototype graph report. +#[must_use] +pub fn loaded_prototype_graph_report(engine: &Engine) -> Option<&PrototypeGraphReport> { + engine.loaded.as_ref().map(|state| &state.prototype_report) +} + +/// Returns resolved effective prototypes for the loaded mission. +#[must_use] +pub fn loaded_resolved_prototypes(engine: &Engine) -> Option<&[EffectivePrototype]> { + engine + .loaded + .as_ref() + .map(|state| state.resolved_prototypes.as_slice()) +} + +fn normalize_engine_path(role: &'static str, value: &str) -> Result { + normalize_relative(value.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| { + EngineError::Path { + role, + value: value.to_string(), + source, + } + }) +} + +fn read_vfs(vfs: &Arc, path: &NormalizedPath) -> Result, EngineError> { + vfs.read(path).map_err(|source| EngineError::Vfs { + path: path.as_str().to_string(), + source, + }) +} + +fn decode_nres( + vfs: &Arc, + path: &NormalizedPath, +) -> Result { + let bytes = read_vfs(vfs, path)?; + fparkan_nres::decode(bytes, fparkan_nres::ReadProfile::Compatible).map_err(|source| { + EngineError::Nres { + path: path.as_str().to_string(), + source, + } + }) +} + +fn terrain_paths( + mission: &MissionDocument, +) -> Result<(NormalizedPath, NormalizedPath), EngineError> { + terrain_paths_from_land_path(&mission.land_path) +} + +fn terrain_paths_from_land_path( + land_path: &LpString, +) -> Result<(NormalizedPath, NormalizedPath), EngineError> { + let land_path_raw = String::from_utf8_lossy(&land_path.raw).to_string(); + let normalized = + normalize_relative(&land_path.raw, PathPolicy::StrictLegacy).map_err(|source| { + EngineError::Path { + role: "mission land", + value: land_path_raw.clone(), + source, + } + })?; + let Some((parent, _stem)) = normalized.as_str().rsplit_once('/') else { + return Err(EngineError::Path { + role: "mission land", + value: normalized.as_str().to_string(), + source: PathError::Empty, + }); + }; + let mesh = normalize_engine_path("Land.msh", &format!("{parent}/Land.msh"))?; + let map = normalize_engine_path("Land.map", &format!("{parent}/Land.map"))?; + Ok((mesh, map)) +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_vfs::{DirectoryVfs, VfsEntry, VfsMetadata}; + use std::path::{Path, PathBuf}; + + #[test] + fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() { + let mut engine = create( + EngineConfig { + mode: EngineMode::Headless, + }, + EngineServices::default(), + ) + .expect("engine"); + let before = step_headless(&mut engine, InputSnapshot).expect("step"); + let err = load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".to_string(), + }, + ) + .expect_err("missing VFS"); + assert!(matches!(err, EngineError::MissingVfs)); + let after = step_headless(&mut engine, InputSnapshot).expect("step"); + assert_eq!(before.snapshot.objects, after.snapshot.objects); + } + + #[test] + fn load_trace_records_preparation_before_registration_and_raw_transforms() { + let root = workspace_root().join("testdata").join("IS"); + let vfs: Arc = Arc::new(DirectoryVfs::new(&root)); + let mut engine = create( + EngineConfig { + mode: EngineMode::Headless, + }, + EngineServices::new(vfs), + ) + .expect("engine"); + + let (loaded, trace) = load_mission_with_trace( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".to_string(), + }, + ) + .expect("load mission with trace"); + + assert_eq!( + trace.phases, + vec![ + MissionLoadPhase::Context, + MissionLoadPhase::Map, + MissionLoadPhase::Tma, + MissionLoadPhase::Graph, + MissionLoadPhase::Assets, + MissionLoadPhase::Construct, + MissionLoadPhase::Register, + ] + ); + assert_eq!(trace.drafts_before_registration, loaded.object_count); + assert_eq!(trace.registered_objects, loaded.object_count); + assert_eq!(trace.transforms.len(), loaded.object_count); + assert!(trace.transforms.iter().all(|transform| transform + .orientation_raw + .iter() + .all(|component| component.is_finite()))); + } + + #[test] + fn missing_map_and_missing_reachable_resource_fail_before_registration() { + let root = workspace_root().join("testdata").join("IS"); + for (denied, mission) in [ + ( + DenyRule::Suffix("Land.map"), + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".to_string(), + }, + ), + ( + DenyRule::Suffix("objects.rlb"), + MissionRequest { + key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(), + }, + ), + ] { + let vfs: Arc = Arc::new(DenyVfs { + inner: DirectoryVfs::new(&root), + denied, + }); + let mut engine = create( + EngineConfig { + mode: EngineMode::Headless, + }, + EngineServices::new(vfs), + ) + .expect("engine"); + let before = step_headless(&mut engine, InputSnapshot).expect("before"); + let err = load_mission(&mut engine, mission).expect_err("load error"); + match denied { + DenyRule::Suffix("Land.map") => assert!(matches!(err, EngineError::Vfs { .. })), + DenyRule::Suffix("objects.rlb") => { + assert!(matches!(err, EngineError::PrototypeGraph { .. })) + } + DenyRule::Suffix(unexpected) => panic!("unexpected deny rule {unexpected}"), + } + assert!(loaded_mission(&engine).is_none()); + let after = step_headless(&mut engine, InputSnapshot).expect("after"); + assert_eq!(before.snapshot.objects, after.snapshot.objects); + } + } + + #[test] + fn registration_phase_failure_uses_normal_teardown_and_keeps_engine_world() { + let root = workspace_root().join("testdata").join("IS"); + let vfs: Arc = Arc::new(DirectoryVfs::new(root)); + let mut engine = create( + EngineConfig { + mode: EngineMode::Headless, + }, + EngineServices::new(vfs), + ) + .expect("engine"); + let before = step_headless(&mut engine, InputSnapshot).expect("before"); + + let err = load_mission_with_options( + &mut engine, + MissionRequest { + key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(), + }, + MissionLoadOptions { + fail_after_registered_objects: Some(1), + }, + ) + .expect_err("forced registration failure"); + + assert!(matches!( + err, + EngineError::RegistrationTeardown { + registered_objects: 1, + released_objects: 1, + managers_released: true, + } + )); + assert!(loaded_mission(&engine).is_none()); + let after = step_headless(&mut engine, InputSnapshot).expect("after"); + assert_eq!(before.snapshot.objects, after.snapshot.objects); + } + + #[test] + fn selected_is_and_is2_missions_execute_10000_deterministic_ticks() { + for case in [ + HeadlessCase { + root: "IS", + mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma", + object_count: 33, + expected_hash: [ + 0x19, 0xdc, 0xd3, 0x9b, 0x35, 0xad, 0x90, 0x6c, 0x92, 0x2d, 0x83, 0x7b, 0x7a, + 0xb3, 0xa6, 0x15, 0xa6, 0x15, 0x92, 0x2d, 0x83, 0x7b, 0x7a, 0xb3, 0xe9, 0xcd, + 0x9a, 0x56, 0x48, 0xb6, 0x0c, 0xee, + ], + }, + HeadlessCase { + root: "IS2", + mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma", + object_count: 10, + expected_hash: [ + 0x59, 0x6e, 0x88, 0xcc, 0xd0, 0x3a, 0xd9, 0x68, 0x1b, 0x2d, 0xcb, 0x0d, 0x91, + 0x19, 0x5a, 0x27, 0x5a, 0x27, 0x1b, 0x2d, 0xcb, 0x0d, 0x91, 0x19, 0x44, 0x66, + 0x68, 0x9d, 0x6c, 0xb4, 0x2c, 0x37, + ], + }, + ] { + let first = run_headless_case(case); + let second = run_headless_case(case); + assert_eq!(first, second); + assert_eq!(first.tick.0, 10_000); + assert_eq!(first.objects.len(), case.object_count); + assert_eq!(first.hash.0, case.expected_hash); + } + } + + #[test] + fn licensed_corpora_load_all_mission_foundations() { + let root = workspace_root(); + let part1 = load_all(&root.join("testdata").join("IS")); + assert_eq!(part1.missions, 29); + assert_eq!(part1.paths, 34); + assert_eq!(part1.clans, 101); + assert_eq!(part1.objects, 864); + assert_eq!(part1.extras, 28); + assert_eq!(part1.unit_references, 463); + assert_eq!(part1.direct_references, 401); + assert_eq!(part1.unit_components, 4_300); + assert_eq!(part1.prototype_requests, 4_701); + assert_eq!(part1.material_slots, 36_954); + assert_eq!(part1.texture_requests, 48_806); + assert_eq!(part1.lightmap_requests, 139); + assert_eq!(part1.graph_failures, 0); + assert_eq!(part1.wear_requests, part1.prototype_requests); + assert_eq!(part1.wear_requests, part1.wear_resolved); + assert_eq!(part1.material_slots, part1.material_resolved); + assert_eq!(part1.texture_requests, part1.texture_resolved); + assert_eq!(part1.lightmap_requests, part1.lightmap_resolved); + + let part2 = load_all(&root.join("testdata").join("IS2")); + assert_eq!(part2.missions, 31); + assert_eq!(part2.paths, 61); + assert_eq!(part2.clans, 91); + assert_eq!(part2.objects, 885); + assert_eq!(part2.extras, 41); + assert_eq!(part2.unit_references, 561); + assert_eq!(part2.direct_references, 324); + assert_eq!(part2.unit_components, 5_521); + assert_eq!(part2.prototype_requests, 5_845); + assert_eq!(part2.material_slots, 50_888); + assert_eq!(part2.texture_requests, 68_603); + assert_eq!(part2.lightmap_requests, 214); + assert_eq!(part2.graph_failures, 0); + assert_eq!(part2.wear_requests, part2.prototype_requests); + assert_eq!(part2.wear_requests, part2.wear_resolved); + assert_eq!(part2.material_slots, part2.material_resolved); + assert_eq!(part2.texture_requests, part2.texture_resolved); + assert_eq!(part2.lightmap_requests, part2.lightmap_resolved); + } + + #[derive(Default)] + struct LoadTotals { + missions: usize, + paths: usize, + clans: usize, + objects: usize, + extras: usize, + unit_references: usize, + direct_references: usize, + unit_components: usize, + prototype_requests: usize, + wear_requests: usize, + wear_resolved: usize, + material_slots: usize, + material_resolved: usize, + texture_requests: usize, + texture_resolved: usize, + lightmap_requests: usize, + lightmap_resolved: usize, + graph_failures: usize, + } + + #[derive(Clone, Copy)] + struct HeadlessCase { + root: &'static str, + mission: &'static str, + object_count: usize, + expected_hash: [u8; 32], + } + + fn run_headless_case(case: HeadlessCase) -> WorldSnapshot { + let root = workspace_root().join("testdata").join(case.root); + let vfs: Arc = Arc::new(DirectoryVfs::new(root)); + let mut engine = create( + EngineConfig { + mode: EngineMode::Headless, + }, + EngineServices::new(vfs), + ) + .expect("engine"); + let loaded = load_mission( + &mut engine, + MissionRequest { + key: case.mission.to_string(), + }, + ) + .expect("load selected mission"); + assert_eq!(loaded.object_count, case.object_count); + + let mut snapshot = None; + for _ in 0..10_000 { + snapshot = Some( + step_headless(&mut engine, InputSnapshot) + .expect("selected mission deterministic tick") + .snapshot, + ); + } + snapshot.expect("at least one tick") + } + + fn load_all(root: &Path) -> LoadTotals { + assert!(root.is_dir(), "missing licensed corpus {}", root.display()); + let mut missions = mission_paths(root); + missions.sort(); + let vfs: Arc = Arc::new(DirectoryVfs::new(root)); + let mut totals = LoadTotals::default(); + for mission in missions { + let mut engine = create( + EngineConfig { + mode: EngineMode::Headless, + }, + EngineServices::new(vfs.clone()), + ) + .expect("engine"); + let loaded = load_mission(&mut engine, MissionRequest { key: mission }) + .expect("load mission foundation"); + assert_eq!(loaded.object_count, loaded.registered_objects); + assert_eq!(loaded.object_count, loaded.graph_root_count); + assert_eq!( + loaded.graph_direct_reference_count + loaded.graph_unit_component_count, + loaded.graph_resolved_count + ); + assert_eq!(loaded.graph_failure_count, 0); + assert_eq!( + loaded.graph_wear_request_count, + loaded.graph_wear_resolved_count + ); + assert_eq!( + loaded.graph_material_slot_count, + loaded.graph_material_resolved_count + ); + assert_eq!( + loaded.graph_texture_request_count, + loaded.graph_texture_resolved_count + ); + assert_eq!( + loaded.graph_lightmap_request_count, + loaded.graph_lightmap_resolved_count + ); + assert_eq!(loaded.build_category_count, 12); + assert!(loaded.areal_count > 0); + assert!(loaded.surface_count > 0); + totals.missions += 1; + totals.paths += loaded.path_count; + totals.clans += loaded.clan_count; + totals.objects += loaded.object_count; + totals.extras += loaded.extra_count; + totals.unit_references += loaded.graph_unit_reference_count; + totals.direct_references += loaded.graph_direct_reference_count; + totals.unit_components += loaded.graph_unit_component_count; + totals.prototype_requests += loaded.graph_resolved_count; + totals.wear_requests += loaded.graph_wear_request_count; + totals.wear_resolved += loaded.graph_wear_resolved_count; + totals.material_slots += loaded.graph_material_slot_count; + totals.material_resolved += loaded.graph_material_resolved_count; + totals.texture_requests += loaded.graph_texture_request_count; + totals.texture_resolved += loaded.graph_texture_resolved_count; + totals.lightmap_requests += loaded.graph_lightmap_request_count; + totals.lightmap_resolved += loaded.graph_lightmap_resolved_count; + totals.graph_failures += loaded.graph_failure_count; + } + totals + } + + fn mission_paths(root: &Path) -> Vec { + let mut out = Vec::new(); + collect_missions(root, root, &mut out); + out + } + + fn collect_missions(root: &Path, dir: &Path, out: &mut Vec) { + let mut children: Vec = std::fs::read_dir(dir) + .expect("read dir") + .map(|entry| entry.expect("entry").path()) + .collect(); + children.sort(); + for child in children { + if child.is_dir() { + collect_missions(root, &child, out); + } else if child + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("data.tma")) + { + let rel = child.strip_prefix(root).expect("relative"); + let rel = rel.to_str().expect("utf8 path").replace('\\', "/"); + out.push(rel); + } + } + } + + fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("workspace root") + .to_path_buf() + } + + #[derive(Clone, Copy)] + enum DenyRule { + Suffix(&'static str), + } + + struct DenyVfs { + inner: DirectoryVfs, + denied: DenyRule, + } + + impl DenyVfs { + fn denied(&self, path: &NormalizedPath) -> bool { + match self.denied { + DenyRule::Suffix(suffix) => path + .as_str() + .to_ascii_uppercase() + .ends_with(&suffix.to_ascii_uppercase()), + } + } + } + + impl Vfs for DenyVfs { + fn metadata(&self, path: &NormalizedPath) -> Result { + if self.denied(path) { + return Err(VfsError::NotFound(path.as_str().to_string())); + } + self.inner.metadata(path) + } + + fn read(&self, path: &NormalizedPath) -> Result, VfsError> { + if self.denied(path) { + return Err(VfsError::NotFound(path.as_str().to_string())); + } + self.inner.read(path) + } + + fn list(&self, prefix: &NormalizedPath) -> Result, VfsError> { + self.inner.list(prefix).map(|entries| { + entries + .into_iter() + .filter(|entry| !self.denied(&entry.path)) + .collect() + }) + } + } +} diff --git a/crates/fparkan-terrain-format/Cargo.toml b/crates/fparkan-terrain-format/Cargo.toml new file mode 100644 index 0000000..f23b357 --- /dev/null +++ b/crates/fparkan-terrain-format/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "fparkan-terrain-format" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-binary = { path = "../fparkan-binary" } +fparkan-nres = { path = "../fparkan-nres" } + +[lints] +workspace = true diff --git a/crates/fparkan-terrain-format/src/lib.rs b/crates/fparkan-terrain-format/src/lib.rs new file mode 100644 index 0000000..8b97d79 --- /dev/null +++ b/crates/fparkan-terrain-format/src/lib.rs @@ -0,0 +1,1910 @@ +#![forbid(unsafe_code)] +//! Terrain disk format primitives. + +use fparkan_binary::{checked_count_bytes, Cursor, DecodeError}; +use fparkan_nres::{EntryId, EntryMeta, NresDocument, NresError}; + +const TYPE_AREAL_MAP: u32 = 12; +const TYPE_NODES: u32 = 1; +const TYPE_SLOTS: u32 = 2; +const TYPE_POSITIONS: u32 = 3; +const TYPE_NORMALS: u32 = 4; +const TYPE_UV0: u32 = 5; +const TYPE_ACCELERATOR: u32 = 11; +const TYPE_AUX14: u32 = 14; +const TYPE_AUX18: u32 = 18; +const TYPE_FACES: u32 = 21; +const REQUIRED_TYPES: [u32; 9] = [ + TYPE_NODES, + TYPE_SLOTS, + TYPE_POSITIONS, + TYPE_NORMALS, + TYPE_UV0, + TYPE_AUX18, + TYPE_AUX14, + TYPE_ACCELERATOR, + TYPE_FACES, +]; +const AREAL_PREFIX_SIZE: usize = 56; +const SLOT_HEADER_SIZE: usize = 0x8c; +const SLOT_STRIDE: usize = 68; +const GRID_HIT_COUNT_BITS: u32 = 10; +const GRID_POOL_OFFSET_MASK: u32 = (1 << 22) - 1; + +/// Full surface mask. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FullSurfaceMask(pub u32); + +/// Compact surface mask. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CompactSurfaceMask(pub u16); + +/// Material class mask. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MaterialClassMask(pub u8); + +/// Terrain face with 28-byte source layout. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TerrainFace28 { + /// Full 32-bit surface mask/flags from bytes 0..4. + pub flags: FullSurfaceMask, + /// Opaque tag at bytes 4..6. + pub material_tag: u16, + /// Opaque tag at bytes 6..8. + pub aux_tag: u16, + /// Vertex indices at bytes 8..14. + pub vertices: [u16; 3], + /// Neighbor face indices at bytes 14..20. + pub neighbors: [Option; 3], + /// Preserved bytes 20..28. + pub tail_raw: [u8; 8], + /// Preserved raw bytes. + pub raw: [u8; 28], +} + +/// Terrain stream descriptor. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TerrainStream { + /// Stream type id. + pub type_id: u32, + /// Entry attributes. + pub attributes: TerrainStreamAttributes, + /// Payload size. + pub size: u32, +} + +/// Opaque stream attributes. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct TerrainStreamAttributes { + /// Attribute 1. + pub attr1: u32, + /// Attribute 2. + pub attr2: u32, + /// Attribute 3. + pub attr3: u32, +} + +/// Slot table metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TerrainSlotTable { + /// Raw 0x8c-byte header. + pub header_raw: Vec, + /// Slot records. + pub slots_raw: Vec<[u8; SLOT_STRIDE]>, +} + +/// Land mesh document. +#[derive(Clone, Debug, PartialEq)] +pub struct LandMeshDocument { + /// Stream descriptors in archive order. + pub streams: Vec, + /// Raw node/slot mapping bytes. + pub nodes_raw: Vec, + /// Slot table. + pub slots: TerrainSlotTable, + /// Positions from type 3. + pub positions: Vec<[f32; 3]>, + /// Packed normals from type 4. + pub normals: Vec<[i8; 4]>, + /// Packed UV from type 5. + pub uv0: Vec<[i16; 2]>, + /// Type 11 accelerator words. + pub accelerator: Vec<[u8; 4]>, + /// Type 14 auxiliary words. + pub aux14: Vec<[u8; 4]>, + /// Type 18 auxiliary words. + pub aux18: Vec<[u8; 4]>, + /// Faces. + pub faces: Vec, +} + +/// Decoded `Land.map` document. +#[derive(Clone, Debug, PartialEq)] +pub struct LandMapDocument { + /// Type 12 entry attributes. + pub entry: TerrainStream, + /// Areal count declared by entry attribute 1. + pub areal_count: u32, + /// Decoded areals. + pub areals: Vec, + /// Fast lookup grid. + pub grid: ArealGrid, +} + +/// Logical terrain area. +#[derive(Clone, Debug, PartialEq)] +pub struct Areal { + /// Preserved 56-byte prefix. + pub prefix_raw: [u8; AREAL_PREFIX_SIZE], + /// Anchor position. + pub anchor: [f32; 3], + /// Preserved float at prefix offset 12. + pub reserved_12: f32, + /// Area metric from the source file. + pub area_metric: f32, + /// Area normal. + pub normal: [f32; 3], + /// Logic flag. + pub logic_flag: u32, + /// Preserved integer at prefix offset 36. + pub reserved_36: u32, + /// Area class identifier. + pub class_id: u32, + /// Preserved integer at prefix offset 44. + pub reserved_44: u32, + /// Boundary vertices. + pub vertices: Vec<[f32; 3]>, + /// Edge and polygon links. + pub links: Vec, + /// Polygon payload blocks. + pub polygon_blocks: Vec, +} + +/// Neighbor link for an areal edge or polygon slot. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EdgeLink { + /// Raw signed area reference. + pub raw_area_ref: i32, + /// Raw signed edge reference. + pub raw_edge_ref: i32, + /// Referenced area, or `None` for `(-1, -1)`. + pub area_ref: Option, + /// Referenced edge/link slot in the target area, or `None` for `(-1, -1)`. + pub edge_ref: Option, +} + +/// Preserved polygon block. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArealPolygonBlock { + /// Leading `n` value. + pub n: u32, + /// Raw block following `n`. + pub body_raw: Vec, +} + +/// Fast area lookup grid. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArealGrid { + /// Number of cells on X axis. + pub cells_x: u32, + /// Number of cells on Y axis. + pub cells_y: u32, + /// Per-cell decoded candidates. + pub cells: Vec, + /// Concatenated candidate pool used by compact lookup. + pub candidate_pool: Vec, + /// Per-cell compact descriptor: high 10 bits are hit count, low 22 bits are pool offset. + pub compact_cells: Vec, +} + +/// Candidate list for one areal grid cell. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArealGridCell { + /// Area identifiers referenced by this cell. + pub area_ids: Vec, +} + +/// Build category from `BuildDat.lst`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BuildCategory { + /// Category name from the section header. + pub name: String, + /// Known category mask. + pub mask: u32, + /// Unit DAT paths listed in the section. + pub unit_paths: Vec, +} + +/// Terrain format error. +#[derive(Debug)] +pub enum TerrainFormatError { + /// Binary decode error. + Decode(DecodeError), + /// Nested `NRes` error. + Nres(NresError), + /// Invalid `Land.map` archive entry count. + InvalidLandMapEntryCount { + /// Observed entry count. + entry_count: usize, + }, + /// Invalid `Land.map` entry type. + InvalidLandMapEntryType { + /// Observed type id. + type_id: u32, + }, + /// Missing required stream. + MissingStream { + /// Stream type id. + type_id: u32, + }, + /// Duplicate required stream. + DuplicateStream { + /// Stream type id. + type_id: u32, + }, + /// Invalid stream stride. + InvalidStride { + /// Stream type id. + type_id: u32, + /// Observed stride. + stride: u32, + /// Expected stride. + expected: u32, + }, + /// Invalid stream size. + InvalidSize { + /// Stream type id. + type_id: u32, + /// Observed size. + size: usize, + /// Expected stride or framing. + stride: usize, + }, + /// Stream count does not match payload size. + CountMismatch { + /// Stream type id. + type_id: u32, + /// Attribute count. + attr_count: u32, + /// Payload-derived count. + payload_count: usize, + }, + /// Invalid vertex. + InvalidVertexIndex { + /// Face index. + face: usize, + /// Vertex index. + vertex: u16, + /// Position count. + position_count: usize, + }, + /// Invalid neighbor. + InvalidNeighborIndex { + /// Face index. + face: usize, + /// Neighbor index. + neighbor: u16, + /// Face count. + face_count: usize, + }, + /// Invalid areal link. + InvalidArealLink { + /// Source area index. + area: usize, + /// Source link index. + link: usize, + /// Raw area reference. + area_ref: i32, + /// Raw edge reference. + edge_ref: i32, + }, + /// Invalid grid dimensions. + InvalidGridSize { + /// Cells on X axis. + cells_x: u32, + /// Cells on Y axis. + cells_y: u32, + }, + /// Invalid area reference in a grid cell. + InvalidGridAreaRef { + /// Linear cell index. + cell: usize, + /// Referenced area. + area_ref: u32, + /// Total area count. + area_count: usize, + }, + /// Invalid `BuildDat.lst` text encoding. + InvalidBuildDatUtf8, + /// Invalid `BuildDat.lst` section structure. + InvalidBuildDatStructure { + /// One-based line number. + line: usize, + /// Reason. + reason: &'static str, + }, + /// Unknown `BuildDat.lst` category name. + UnknownBuildCategory { + /// One-based line number. + line: usize, + /// Category name. + name: String, + }, + /// Integer overflow. + IntegerOverflow, +} + +impl From for TerrainFormatError { + fn from(value: DecodeError) -> Self { + Self::Decode(value) + } +} + +impl From for TerrainFormatError { + fn from(value: NresError) -> Self { + Self::Nres(value) + } +} + +impl std::fmt::Display for TerrainFormatError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Decode(source) => write!(f, "{source}"), + Self::Nres(source) => write!(f, "{source}"), + Self::InvalidLandMapEntryCount { entry_count } => { + write!(f, "Land.map must contain exactly one entry, got {entry_count}") + } + Self::InvalidLandMapEntryType { type_id } => { + write!(f, "Land.map entry type must be 12, got {type_id}") + } + Self::MissingStream { type_id } => write!(f, "missing Land.msh stream {type_id}"), + Self::DuplicateStream { type_id } => write!(f, "duplicate Land.msh stream {type_id}"), + Self::InvalidStride { + type_id, + stride, + expected, + } => write!( + f, + "invalid Land.msh stream {type_id} stride {stride}, expected {expected}" + ), + Self::InvalidSize { + type_id, + size, + stride, + } => write!( + f, + "invalid Land.msh stream {type_id} size {size}, stride/framing {stride}" + ), + Self::CountMismatch { + type_id, + attr_count, + payload_count, + } => write!( + f, + "Land.msh stream {type_id} count mismatch: attr={attr_count}, payload={payload_count}" + ), + Self::InvalidVertexIndex { + face, + vertex, + position_count, + } => write!( + f, + "Land.msh face {face} vertex {vertex} outside {position_count} positions" + ), + Self::InvalidNeighborIndex { + face, + neighbor, + face_count, + } => write!( + f, + "Land.msh face {face} neighbor {neighbor} outside {face_count} faces" + ), + Self::InvalidArealLink { + area, + link, + area_ref, + edge_ref, + } => write!( + f, + "Land.map area {area} link {link} has invalid reference ({area_ref}, {edge_ref})" + ), + Self::InvalidGridSize { cells_x, cells_y } => { + write!(f, "Land.map invalid grid size {cells_x}x{cells_y}") + } + Self::InvalidGridAreaRef { + cell, + area_ref, + area_count, + } => write!( + f, + "Land.map grid cell {cell} references area {area_ref} outside {area_count} areas" + ), + Self::InvalidBuildDatUtf8 => write!(f, "BuildDat.lst is not valid UTF-8/ASCII text"), + Self::InvalidBuildDatStructure { line, reason } => { + write!(f, "invalid BuildDat.lst structure at line {line}: {reason}") + } + Self::UnknownBuildCategory { line, name } => { + write!(f, "unknown BuildDat.lst category '{name}' at line {line}") + } + Self::IntegerOverflow => write!(f, "integer overflow"), + } + } +} + +impl std::error::Error for TerrainFormatError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Decode(source) => Some(source), + Self::Nres(source) => Some(source), + Self::InvalidLandMapEntryCount { .. } + | Self::InvalidLandMapEntryType { .. } + | Self::MissingStream { .. } + | Self::DuplicateStream { .. } + | Self::InvalidStride { .. } + | Self::InvalidSize { .. } + | Self::CountMismatch { .. } + | Self::InvalidVertexIndex { .. } + | Self::InvalidNeighborIndex { .. } + | Self::InvalidArealLink { .. } + | Self::InvalidGridSize { .. } + | Self::InvalidGridAreaRef { .. } + | Self::InvalidBuildDatUtf8 + | Self::InvalidBuildDatStructure { .. } + | Self::UnknownBuildCategory { .. } + | Self::IntegerOverflow => None, + } + } +} + +/// Decodes a `Land.msh` `NRes` document. +/// +/// # Errors +/// +/// Returns [`TerrainFormatError`] when required streams are missing, stream +/// strides/counts do not match, or face vertex/neighbor references are invalid. +pub fn decode_land_msh(nres: &NresDocument) -> Result { + for type_id in REQUIRED_TYPES { + require_single_stream(nres, type_id)?; + } + + let nodes = stream_payload(nres, TYPE_NODES)?; + let slots = stream_payload(nres, TYPE_SLOTS)?; + let positions = stream_payload(nres, TYPE_POSITIONS)?; + let normals = stream_payload(nres, TYPE_NORMALS)?; + let uv0 = stream_payload(nres, TYPE_UV0)?; + let accelerator = stream_payload(nres, TYPE_ACCELERATOR)?; + let aux14 = stream_payload(nres, TYPE_AUX14)?; + let aux18 = stream_payload(nres, TYPE_AUX18)?; + let faces = stream_payload(nres, TYPE_FACES)?; + + validate_stream(nres, TYPE_NODES, 38, nodes.len() / 38)?; + validate_slots(nres, slots)?; + let positions = parse_positions(nres, positions)?; + let normals = parse_i8x4_stream(nres, TYPE_NORMALS, normals)?; + let uv0 = parse_i16x2_stream(nres, TYPE_UV0, uv0)?; + let accelerator = parse_word_stream(nres, TYPE_ACCELERATOR, accelerator)?; + let aux14 = parse_word_stream(nres, TYPE_AUX14, aux14)?; + let aux18 = parse_word_stream(nres, TYPE_AUX18, aux18)?; + let faces = parse_faces(nres, faces)?; + validate_faces(&faces, positions.len())?; + + Ok(LandMeshDocument { + streams: nres + .entries() + .iter() + .map(|entry| TerrainStream { + type_id: entry.meta().type_id, + attributes: attributes(entry.meta()), + size: entry.meta().data_size, + }) + .collect(), + nodes_raw: nodes.to_vec(), + slots: parse_slot_table(slots), + positions, + normals, + uv0, + accelerator, + aux14, + aux18, + faces, + }) +} + +/// Decodes a `Land.map` `NRes` document. +/// +/// # Errors +/// +/// Returns [`TerrainFormatError`] when the archive does not contain exactly one +/// type 12 entry, the payload framing is invalid, references are out of range, +/// or the parser does not finish exactly at EOF. +pub fn decode_land_map(nres: &NresDocument) -> Result { + if nres.entry_count() != 1 { + return Err(TerrainFormatError::InvalidLandMapEntryCount { + entry_count: nres.entry_count(), + }); + } + let entry = &nres.entries()[0]; + let meta = entry.meta(); + if meta.type_id != TYPE_AREAL_MAP { + return Err(TerrainFormatError::InvalidLandMapEntryType { + type_id: meta.type_id, + }); + } + let payload = nres.payload(entry.id())?; + let areal_count = + usize::try_from(meta.attr1).map_err(|_| TerrainFormatError::IntegerOverflow)?; + let mut cursor = Cursor::new(payload); + let mut areals = Vec::with_capacity(areal_count); + for area_index in 0..areal_count { + areals.push(parse_areal(&mut cursor, area_index)?); + } + validate_areal_links(&areals)?; + let grid = parse_areal_grid(&mut cursor, areals.len())?; + cursor.require_eof()?; + + Ok(LandMapDocument { + entry: TerrainStream { + type_id: meta.type_id, + attributes: attributes(meta), + size: meta.data_size, + }, + areal_count: meta.attr1, + areals, + grid, + }) +} + +/// Decodes `Build.dat`. +/// +/// # Errors +/// +/// Returns [`TerrainFormatError`] when the file contains malformed sections, +/// unknown category names, invalid counts, or invalid quoted unit paths. +pub fn decode_build_dat(bytes: &[u8]) -> Result, TerrainFormatError> { + let text = std::str::from_utf8(bytes).map_err(|_| TerrainFormatError::InvalidBuildDatUtf8)?; + let mut categories = Vec::new(); + let mut iter = text.lines().enumerate().peekable(); + + while let Some((line_index, raw_line)) = iter.next() { + let line_no = line_index + 1; + let line = raw_line.trim(); + if line.is_empty() || line.starts_with("//") { + continue; + } + + let (name, count) = parse_build_header(line_no, line)?; + let mask = + build_category_mask(name).ok_or_else(|| TerrainFormatError::UnknownBuildCategory { + line: line_no, + name: name.to_string(), + })?; + let mut unit_paths = Vec::with_capacity(count); + for _ in 0..count { + let Some((path_line_index, path_line_raw)) = iter.next() else { + return Err(TerrainFormatError::InvalidBuildDatStructure { + line: line_no, + reason: "section ended before declared path count", + }); + }; + let path_line_no = path_line_index + 1; + let path_line = path_line_raw.trim(); + unit_paths.push(parse_quoted_path(path_line_no, path_line)?); + } + categories.push(BuildCategory { + name: name.to_string(), + mask, + unit_paths, + }); + } + + Ok(categories) +} + +/// Converts full mask to compact mask with explicit bit preservation policy. +#[must_use] +pub fn full_to_compact(mask: FullSurfaceMask) -> CompactSurfaceMask { + let mut compact = 0u16; + for (full_bit, compact_bit) in SURFACE_MASK_MAP { + if mask.0 & full_bit != 0 { + compact |= compact_bit; + } + } + CompactSurfaceMask(compact) +} + +/// Converts compact mask to full mask. +#[must_use] +pub fn compact_to_full(mask: CompactSurfaceMask) -> FullSurfaceMask { + let mut full = 0u32; + for (full_bit, compact_bit) in SURFACE_MASK_MAP { + if mask.0 & compact_bit != 0 { + full |= full_bit; + } + } + FullSurfaceMask(full) +} + +/// Converts full mask to compact material class mask. +#[must_use] +pub fn full_to_material_class(mask: FullSurfaceMask) -> MaterialClassMask { + let mut compact = 0u8; + for (full_bit, compact_bit) in MATERIAL_MASK_MAP { + if mask.0 & full_bit != 0 { + compact |= compact_bit; + } + } + MaterialClassMask(compact) +} + +/// Validates face references. +/// +/// # Errors +/// +/// Returns [`TerrainFormatError`] when a face references a vertex or neighbor +/// outside the decoded document. +pub fn validate_faces( + faces: &[TerrainFace28], + vertex_count: usize, +) -> Result<(), TerrainFormatError> { + for (face_index, face) in faces.iter().enumerate() { + for vertex in face.vertices { + if usize::from(vertex) >= vertex_count { + return Err(TerrainFormatError::InvalidVertexIndex { + face: face_index, + vertex, + position_count: vertex_count, + }); + } + } + for neighbor in face.neighbors.iter().flatten() { + if usize::from(*neighbor) >= faces.len() { + return Err(TerrainFormatError::InvalidNeighborIndex { + face: face_index, + neighbor: *neighbor, + face_count: faces.len(), + }); + } + } + } + Ok(()) +} + +const BUILD_CATEGORY_MASKS: &[(&str, u32)] = &[ + ("Bunker_Small", 0x8001_0000), + ("Bunker_Medium", 0x8002_0000), + ("Bunker_Large", 0x8004_0000), + ("Generator", 0x8000_0002), + ("Mine", 0x8000_0004), + ("Storage", 0x8000_0008), + ("Plant", 0x8000_0010), + ("Hangar", 0x8000_0040), + ("MainTeleport", 0x8000_0200), + ("Institute", 0x8000_0400), + ("Tower_Medium", 0x8010_0000), + ("Tower_Large", 0x8020_0000), +]; + +const SURFACE_MASK_MAP: &[(u32, u16)] = &[ + (0x0000_0001, 0x0001), + (0x0000_0008, 0x0002), + (0x0000_0010, 0x0004), + (0x0000_0020, 0x0008), + (0x0000_1000, 0x0010), + (0x0000_4000, 0x0020), + (0x0000_0002, 0x0040), + (0x0000_0400, 0x0080), + (0x0000_0800, 0x0100), + (0x0002_0000, 0x0200), + (0x0000_2000, 0x0400), + (0x0000_0200, 0x0800), + (0x0000_0004, 0x1000), + (0x0000_0040, 0x2000), + (0x0020_0000, 0x8000), +]; + +const MATERIAL_MASK_MAP: &[(u32, u8)] = &[ + (0x0000_0100, 0x01), + (0x0000_8000, 0x02), + (0x0001_0000, 0x04), + (0x0004_0000, 0x08), + (0x0008_0000, 0x10), + (0x0000_0080, 0x20), +]; + +fn parse_build_header(line: usize, text: &str) -> Result<(&str, usize), TerrainFormatError> { + let mut parts = text.split_ascii_whitespace(); + let name = parts + .next() + .ok_or(TerrainFormatError::InvalidBuildDatStructure { + line, + reason: "missing category name", + })?; + let count_raw = parts + .next() + .ok_or(TerrainFormatError::InvalidBuildDatStructure { + line, + reason: "missing category count", + })?; + if parts.next().is_some() { + return Err(TerrainFormatError::InvalidBuildDatStructure { + line, + reason: "extra fields in category header", + }); + } + let count = + count_raw + .parse::() + .map_err(|_| TerrainFormatError::InvalidBuildDatStructure { + line, + reason: "invalid category count", + })?; + Ok((name, count)) +} + +fn parse_quoted_path(line: usize, text: &str) -> Result { + if text.len() < 2 || !text.starts_with('"') || !text.ends_with('"') { + return Err(TerrainFormatError::InvalidBuildDatStructure { + line, + reason: "unit path must be quoted", + }); + } + let path = &text[1..text.len() - 1]; + if path.is_empty() { + return Err(TerrainFormatError::InvalidBuildDatStructure { + line, + reason: "unit path must not be empty", + }); + } + if !path.bytes().all(is_build_path_byte) { + return Err(TerrainFormatError::InvalidBuildDatStructure { + line, + reason: "unit path contains invalid byte", + }); + } + Ok(path.to_string()) +} + +fn is_build_path_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'/' | b'\\' | b'-') +} + +fn build_category_mask(name: &str) -> Option { + BUILD_CATEGORY_MASKS + .iter() + .find_map(|(category, mask)| (*category == name).then_some(*mask)) +} + +fn require_single_stream(nres: &NresDocument, type_id: u32) -> Result { + let mut found = None; + for entry in nres + .entries() + .iter() + .filter(|entry| entry.meta().type_id == type_id) + { + if found.is_some() { + return Err(TerrainFormatError::DuplicateStream { type_id }); + } + found = Some(entry.id()); + } + found.ok_or(TerrainFormatError::MissingStream { type_id }) +} + +fn stream_payload(nres: &NresDocument, type_id: u32) -> Result<&[u8], TerrainFormatError> { + let id = require_single_stream(nres, type_id)?; + nres.payload(id).map_err(Into::into) +} + +fn stream_meta(nres: &NresDocument, type_id: u32) -> Result<&EntryMeta, TerrainFormatError> { + let id = require_single_stream(nres, type_id)?; + nres.entry(id) + .map(fparkan_nres::NresEntry::meta) + .ok_or(TerrainFormatError::MissingStream { type_id }) +} + +fn validate_stream( + nres: &NresDocument, + type_id: u32, + stride: usize, + count: usize, +) -> Result<(), TerrainFormatError> { + let meta = stream_meta(nres, type_id)?; + let expected = u32::try_from(stride).map_err(|_| TerrainFormatError::IntegerOverflow)?; + if meta.attr3 != expected { + return Err(TerrainFormatError::InvalidStride { + type_id, + stride: meta.attr3, + expected, + }); + } + let attr_count = + usize::try_from(meta.attr1).map_err(|_| TerrainFormatError::IntegerOverflow)?; + if attr_count != count { + return Err(TerrainFormatError::CountMismatch { + type_id, + attr_count: meta.attr1, + payload_count: count, + }); + } + Ok(()) +} + +fn validate_slots(nres: &NresDocument, payload: &[u8]) -> Result<(), TerrainFormatError> { + let meta = stream_meta(nres, TYPE_SLOTS)?; + if payload.len() < SLOT_HEADER_SIZE { + return Err(TerrainFormatError::InvalidSize { + type_id: TYPE_SLOTS, + size: payload.len(), + stride: SLOT_HEADER_SIZE, + }); + } + let tail = payload.len() - SLOT_HEADER_SIZE; + if !tail.is_multiple_of(SLOT_STRIDE) { + return Err(TerrainFormatError::InvalidSize { + type_id: TYPE_SLOTS, + size: payload.len(), + stride: SLOT_STRIDE, + }); + } + let slots = tail / SLOT_STRIDE; + let attr_count = + usize::try_from(meta.attr1).map_err(|_| TerrainFormatError::IntegerOverflow)?; + if attr_count != slots { + return Err(TerrainFormatError::CountMismatch { + type_id: TYPE_SLOTS, + attr_count: meta.attr1, + payload_count: slots, + }); + } + Ok(()) +} + +fn parse_slot_table(payload: &[u8]) -> TerrainSlotTable { + let mut slots_raw = Vec::new(); + for chunk in payload[SLOT_HEADER_SIZE..].chunks_exact(SLOT_STRIDE) { + let mut raw = [0; SLOT_STRIDE]; + raw.copy_from_slice(chunk); + slots_raw.push(raw); + } + TerrainSlotTable { + header_raw: payload[..SLOT_HEADER_SIZE].to_vec(), + slots_raw, + } +} + +fn parse_positions( + nres: &NresDocument, + payload: &[u8], +) -> Result, TerrainFormatError> { + if !payload.len().is_multiple_of(12) { + return Err(TerrainFormatError::InvalidSize { + type_id: TYPE_POSITIONS, + size: payload.len(), + stride: 12, + }); + } + let count = payload.len() / 12; + validate_stream(nres, TYPE_POSITIONS, 12, count)?; + let mut out = Vec::with_capacity(count); + for chunk in payload.chunks_exact(12) { + out.push([ + read_f32(chunk, 0)?, + read_f32(chunk, 4)?, + read_f32(chunk, 8)?, + ]); + } + Ok(out) +} + +fn parse_i8x4_stream( + nres: &NresDocument, + type_id: u32, + payload: &[u8], +) -> Result, TerrainFormatError> { + if !payload.len().is_multiple_of(4) { + return Err(TerrainFormatError::InvalidSize { + type_id, + size: payload.len(), + stride: 4, + }); + } + let count = payload.len() / 4; + validate_stream(nres, type_id, 4, count)?; + Ok(payload + .chunks_exact(4) + .map(|chunk| { + [ + i8::from_le_bytes([chunk[0]]), + i8::from_le_bytes([chunk[1]]), + i8::from_le_bytes([chunk[2]]), + i8::from_le_bytes([chunk[3]]), + ] + }) + .collect()) +} + +fn parse_i16x2_stream( + nres: &NresDocument, + type_id: u32, + payload: &[u8], +) -> Result, TerrainFormatError> { + if !payload.len().is_multiple_of(4) { + return Err(TerrainFormatError::InvalidSize { + type_id, + size: payload.len(), + stride: 4, + }); + } + let count = payload.len() / 4; + validate_stream(nres, type_id, 4, count)?; + let mut out = Vec::with_capacity(count); + for chunk in payload.chunks_exact(4) { + out.push([read_i16(chunk, 0)?, read_i16(chunk, 2)?]); + } + Ok(out) +} + +fn parse_word_stream( + nres: &NresDocument, + type_id: u32, + payload: &[u8], +) -> Result, TerrainFormatError> { + if !payload.len().is_multiple_of(4) { + return Err(TerrainFormatError::InvalidSize { + type_id, + size: payload.len(), + stride: 4, + }); + } + let count = payload.len() / 4; + validate_stream(nres, type_id, 4, count)?; + Ok(payload + .chunks_exact(4) + .map(|chunk| [chunk[0], chunk[1], chunk[2], chunk[3]]) + .collect()) +} + +fn parse_faces( + nres: &NresDocument, + payload: &[u8], +) -> Result, TerrainFormatError> { + if !payload.len().is_multiple_of(28) { + return Err(TerrainFormatError::InvalidSize { + type_id: TYPE_FACES, + size: payload.len(), + stride: 28, + }); + } + let count = payload.len() / 28; + validate_stream(nres, TYPE_FACES, 28, count)?; + let mut out = Vec::with_capacity(count); + for chunk in payload.chunks_exact(28) { + let mut raw = [0; 28]; + raw.copy_from_slice(chunk); + let mut tail_raw = [0; 8]; + tail_raw.copy_from_slice(&chunk[20..28]); + out.push(TerrainFace28 { + flags: FullSurfaceMask(read_u32(chunk, 0)?), + material_tag: read_u16(chunk, 4)?, + aux_tag: read_u16(chunk, 6)?, + vertices: [ + read_u16(chunk, 8)?, + read_u16(chunk, 10)?, + read_u16(chunk, 12)?, + ], + neighbors: [ + neighbor(read_u16(chunk, 14)?), + neighbor(read_u16(chunk, 16)?), + neighbor(read_u16(chunk, 18)?), + ], + tail_raw, + raw, + }); + } + Ok(out) +} + +fn neighbor(raw: u16) -> Option { + (raw != u16::MAX).then_some(raw) +} + +fn parse_areal(cursor: &mut Cursor<'_>, _area_index: usize) -> Result { + let prefix = cursor.read_exact(AREAL_PREFIX_SIZE)?; + let mut prefix_raw = [0; AREAL_PREFIX_SIZE]; + prefix_raw.copy_from_slice(prefix); + let vertex_count = read_u32(prefix, 48)?; + let poly_count = read_u32(prefix, 52)?; + let vertices = parse_areal_vertices(cursor, vertex_count)?; + let link_count = vertex_count + .checked_add( + poly_count + .checked_mul(3) + .ok_or(TerrainFormatError::IntegerOverflow)?, + ) + .ok_or(TerrainFormatError::IntegerOverflow)?; + let links = parse_edge_links(cursor, link_count)?; + let polygon_blocks = parse_polygon_blocks(cursor, poly_count)?; + + Ok(Areal { + prefix_raw, + anchor: [ + read_f32(prefix, 0)?, + read_f32(prefix, 4)?, + read_f32(prefix, 8)?, + ], + reserved_12: read_f32(prefix, 12)?, + area_metric: read_f32(prefix, 16)?, + normal: [ + read_f32(prefix, 20)?, + read_f32(prefix, 24)?, + read_f32(prefix, 28)?, + ], + logic_flag: read_u32(prefix, 32)?, + reserved_36: read_u32(prefix, 36)?, + class_id: read_u32(prefix, 40)?, + reserved_44: read_u32(prefix, 44)?, + vertices, + links, + polygon_blocks, + }) +} + +fn parse_areal_vertices( + cursor: &mut Cursor<'_>, + vertex_count: u32, +) -> Result, TerrainFormatError> { + checked_count_bytes(u64::from(vertex_count), 12, cursor.remaining() as u64)?; + let count = usize::try_from(vertex_count).map_err(|_| TerrainFormatError::IntegerOverflow)?; + let mut vertices = Vec::with_capacity(count); + for _ in 0..count { + vertices.push([ + cursor.read_f32_le()?, + cursor.read_f32_le()?, + cursor.read_f32_le()?, + ]); + } + Ok(vertices) +} + +fn parse_edge_links( + cursor: &mut Cursor<'_>, + link_count: u32, +) -> Result, TerrainFormatError> { + checked_count_bytes(u64::from(link_count), 8, cursor.remaining() as u64)?; + let count = usize::try_from(link_count).map_err(|_| TerrainFormatError::IntegerOverflow)?; + let mut links = Vec::with_capacity(count); + for _ in 0..count { + let raw_area_ref = cursor.read_i32_le()?; + let raw_edge_ref = cursor.read_i32_le()?; + let (area_ref, edge_ref) = match (raw_area_ref, raw_edge_ref) { + (-1, -1) => (None, None), + (area, edge) if area >= 0 && edge >= 0 => { + let area = u32::try_from(area).map_err(|_| TerrainFormatError::IntegerOverflow)?; + let edge = u32::try_from(edge).map_err(|_| TerrainFormatError::IntegerOverflow)?; + (Some(area), Some(edge)) + } + _ => (None, None), + }; + links.push(EdgeLink { + raw_area_ref, + raw_edge_ref, + area_ref, + edge_ref, + }); + } + Ok(links) +} + +fn parse_polygon_blocks( + cursor: &mut Cursor<'_>, + poly_count: u32, +) -> Result, TerrainFormatError> { + let count = usize::try_from(poly_count).map_err(|_| TerrainFormatError::IntegerOverflow)?; + let mut blocks = Vec::with_capacity(count); + for _ in 0..count { + let n = cursor.read_u32_le()?; + let word_count = u64::from(n) + .checked_mul(3) + .and_then(|count| count.checked_add(1)) + .ok_or(TerrainFormatError::IntegerOverflow)?; + let byte_count = checked_count_bytes(word_count, 4, cursor.remaining() as u64)?; + blocks.push(ArealPolygonBlock { + n, + body_raw: cursor.read_exact(byte_count)?.to_vec(), + }); + } + Ok(blocks) +} + +fn validate_areal_links(areals: &[Areal]) -> Result<(), TerrainFormatError> { + for (area_index, area) in areals.iter().enumerate() { + for (link_index, link) in area.links.iter().enumerate() { + match (link.area_ref, link.edge_ref) { + (None, None) if link.raw_area_ref == -1 && link.raw_edge_ref == -1 => {} + (Some(area_ref), Some(edge_ref)) => { + let Some(target) = usize::try_from(area_ref) + .ok() + .and_then(|index| areals.get(index)) + else { + return Err(invalid_areal_link(area_index, link_index, link)); + }; + let edge_index = usize::try_from(edge_ref) + .map_err(|_| TerrainFormatError::IntegerOverflow)?; + if edge_index >= target.links.len() { + return Err(invalid_areal_link(area_index, link_index, link)); + } + } + _ => return Err(invalid_areal_link(area_index, link_index, link)), + } + } + } + Ok(()) +} + +fn invalid_areal_link(area: usize, link: usize, edge_link: &EdgeLink) -> TerrainFormatError { + TerrainFormatError::InvalidArealLink { + area, + link, + area_ref: edge_link.raw_area_ref, + edge_ref: edge_link.raw_edge_ref, + } +} + +fn parse_areal_grid( + cursor: &mut Cursor<'_>, + area_count: usize, +) -> Result { + let cells_x = cursor.read_u32_le()?; + let cells_y = cursor.read_u32_le()?; + let cell_count = cells_x + .checked_mul(cells_y) + .ok_or(TerrainFormatError::IntegerOverflow)?; + if cell_count == 0 { + return Err(TerrainFormatError::InvalidGridSize { cells_x, cells_y }); + } + let cell_count_usize = + usize::try_from(cell_count).map_err(|_| TerrainFormatError::IntegerOverflow)?; + let mut cells = Vec::with_capacity(cell_count_usize); + let mut candidate_pool = Vec::new(); + let mut compact_cells = Vec::with_capacity(cell_count_usize); + for cell_index in 0..cell_count_usize { + let hit_count = cursor.read_u16_le()?; + let pool_offset = + u32::try_from(candidate_pool.len()).map_err(|_| TerrainFormatError::IntegerOverflow)?; + if u32::from(hit_count) >= (1 << GRID_HIT_COUNT_BITS) || pool_offset > GRID_POOL_OFFSET_MASK + { + return Err(TerrainFormatError::IntegerOverflow); + } + let mut area_ids = Vec::with_capacity(usize::from(hit_count)); + for _ in 0..hit_count { + let area_ref = u32::from(cursor.read_u16_le()?); + if usize::try_from(area_ref).map_or(true, |index| index >= area_count) { + return Err(TerrainFormatError::InvalidGridAreaRef { + cell: cell_index, + area_ref, + area_count, + }); + } + area_ids.push(area_ref); + candidate_pool.push(area_ref); + } + compact_cells.push((u32::from(hit_count) << 22) | pool_offset); + cells.push(ArealGridCell { area_ids }); + } + Ok(ArealGrid { + cells_x, + cells_y, + cells, + candidate_pool, + compact_cells, + }) +} + +fn attributes(meta: &EntryMeta) -> TerrainStreamAttributes { + TerrainStreamAttributes { + attr1: meta.attr1, + attr2: meta.attr2, + attr3: meta.attr3, + } +} + +fn read_u16(bytes: &[u8], offset: usize) -> Result { + let raw = bytes + .get(offset..offset + 2) + .ok_or(TerrainFormatError::IntegerOverflow)?; + Ok(u16::from_le_bytes([raw[0], raw[1]])) +} + +fn read_i16(bytes: &[u8], offset: usize) -> Result { + let raw = bytes + .get(offset..offset + 2) + .ok_or(TerrainFormatError::IntegerOverflow)?; + Ok(i16::from_le_bytes([raw[0], raw[1]])) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Result { + let raw = bytes + .get(offset..offset + 4) + .ok_or(TerrainFormatError::IntegerOverflow)?; + Ok(u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]])) +} + +fn read_f32(bytes: &[u8], offset: usize) -> Result { + Ok(f32::from_bits(read_u32(bytes, offset)?)) +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_nres::ReadProfile; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + + static SLOT_HEADER_ZERO: [u8; SLOT_HEADER_SIZE] = [0; SLOT_HEADER_SIZE]; + static STREAM12_ZERO: [u8; 12] = [0; 12]; + + #[test] + fn decodes_minimal_land_msh() { + let nres = + decode_nres(&minimal_land_msh(&face([0, 1, 2], [None, None, None]))).expect("nres"); + let document = decode_land_msh(&nres).expect("land mesh"); + + assert_eq!(document.positions.len(), 3); + assert_eq!(document.faces.len(), 1); + assert_eq!(document.faces[0].vertices, [0, 1, 2]); + assert_eq!(document.faces[0].neighbors, [None, None, None]); + } + + #[test] + fn land_msh_required_streams_are_order_independent_and_stride_checked() { + let face = face([0, 1, 2], [None, None, None]); + let positions = minimal_positions_payload(); + let entries = minimal_land_msh_entries(&face, &positions); + let shuffled = [ + entries[8], entries[2], entries[0], entries[7], entries[4], entries[3], entries[6], + entries[5], entries[1], + ]; + let nres = decode_nres(&build_nres(&shuffled)).expect("nres"); + let document = decode_land_msh(&nres).expect("land mesh"); + assert_eq!(document.positions.len(), 3); + assert_eq!( + document + .streams + .iter() + .map(|stream| stream.type_id) + .collect::>(), + vec![ + TYPE_FACES, + TYPE_POSITIONS, + TYPE_NODES, + TYPE_ACCELERATOR, + TYPE_UV0, + TYPE_NORMALS, + TYPE_AUX14, + TYPE_AUX18, + TYPE_SLOTS, + ] + ); + + let bad_stride = [ + entries[0], + entries[1], + entries[2], + entry(TYPE_NORMALS, 3, 8, &[0; 12]), + entries[4], + entries[5], + entries[6], + entries[7], + entries[8], + ]; + let nres = decode_nres(&build_nres(&bad_stride)).expect("nres"); + assert!(matches!( + decode_land_msh(&nres), + Err(TerrainFormatError::InvalidStride { + type_id: TYPE_NORMALS, + .. + }) + )); + } + + #[test] + fn rejects_invalid_vertex_index() { + let nres = + decode_nres(&minimal_land_msh(&face([0, 1, 3], [None, None, None]))).expect("nres"); + let err = decode_land_msh(&nres).expect_err("invalid vertex"); + + assert!(matches!( + err, + TerrainFormatError::InvalidVertexIndex { vertex: 3, .. } + )); + } + + #[test] + fn rejects_invalid_neighbor_index() { + let nres = + decode_nres(&minimal_land_msh(&face([0, 1, 2], [Some(1), None, None]))).expect("nres"); + let err = decode_land_msh(&nres).expect_err("invalid neighbor"); + + assert!(matches!( + err, + TerrainFormatError::InvalidNeighborIndex { neighbor: 1, .. } + )); + } + + #[test] + fn face_layout_preserves_tail_and_all_surface_mask_mappings_are_explicit() { + let mut raw_face = face([0, 1, 2], [None, None, None]); + raw_face[20..28].copy_from_slice(b"UNKNOWN!"); + let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres"); + let document = decode_land_msh(&nres).expect("land mesh"); + assert_eq!(document.faces[0].tail_raw, *b"UNKNOWN!"); + assert_eq!(document.faces[0].raw, raw_face); + + for (full, compact) in SURFACE_MASK_MAP { + assert_eq!( + full_to_compact(FullSurfaceMask(*full)), + CompactSurfaceMask(*compact) + ); + assert_eq!( + compact_to_full(CompactSurfaceMask(*compact)), + FullSurfaceMask(*full) + ); + } + assert_eq!( + full_to_compact(FullSurfaceMask(0x0000_0008)), + CompactSurfaceMask(0x0002) + ); + assert_eq!( + full_to_compact(FullSurfaceMask(0x0020_0000)), + CompactSurfaceMask(0x8000) + ); + assert_eq!( + compact_to_full(CompactSurfaceMask(0x8000)), + FullSurfaceMask(0x0020_0000) + ); + assert_eq!( + full_to_material_class(FullSurfaceMask(0x0000_8000 | 0x0000_0080)), + MaterialClassMask(0x22) + ); + } + + #[test] + fn decodes_minimal_land_map() { + let nres = decode_nres(&minimal_land_map([(-1, -1), (-1, -1)], 0)).expect("nres"); + let document = decode_land_map(&nres).expect("land map"); + + assert_eq!(document.areal_count, 1); + assert_eq!(document.areals.len(), 1); + assert_eq!(document.areals[0].vertices.len(), 2); + assert_eq!(document.areals[0].links.len(), 2); + assert_eq!(document.grid.cells_x, 1); + assert_eq!(document.grid.cells_y, 1); + assert_eq!(document.grid.cells[0].area_ids, [0]); + assert_eq!(document.grid.compact_cells, [0x0040_0000]); + } + + #[test] + fn land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof() { + let nres = decode_nres(&minimal_land_map_with_poly(1, true)).expect("nres"); + let document = decode_land_map(&nres).expect("land map"); + assert_eq!(document.areals[0].prefix_raw.len(), AREAL_PREFIX_SIZE); + assert_eq!(document.areals[0].anchor, [0.0, 0.0, 0.0]); + assert_eq!(document.areals[0].area_metric, 2.0); + assert_eq!(document.areals[0].links[0].area_ref, None); + assert_eq!(document.areals[0].polygon_blocks.len(), 1); + assert_eq!(document.areals[0].links.len(), 5); + assert_eq!(document.grid.cells_x, 1); + assert_eq!(document.grid.cells_y, 1); + + let nres = decode_nres(&minimal_land_map_with_vertex_count(3)).expect("nres"); + assert!(decode_land_map(&nres).is_err()); + + let nres = decode_nres(&minimal_land_map_with_poly(1_000_000, true)).expect("nres"); + assert!(decode_land_map(&nres).is_err()); + + let nres = decode_nres(&minimal_land_map_with_poly(0, false)).expect("nres"); + assert!(matches!( + decode_land_map(&nres), + Err(TerrainFormatError::InvalidGridSize { cells_x: 0, .. }) + )); + + let nres = decode_nres(&minimal_land_map_with_payload_tail()).expect("nres"); + assert!(decode_land_map(&nres).is_err()); + } + + #[test] + fn rejects_invalid_areal_link() { + let nres = decode_nres(&minimal_land_map([(1, 0), (-1, -1)], 0)).expect("nres"); + let err = decode_land_map(&nres).expect_err("invalid link"); + + assert!(matches!( + err, + TerrainFormatError::InvalidArealLink { + area: 0, + link: 0, + area_ref: 1, + edge_ref: 0 + } + )); + } + + #[test] + fn rejects_invalid_grid_area_ref() { + let nres = decode_nres(&minimal_land_map([(-1, -1), (-1, -1)], 1)).expect("nres"); + let err = decode_land_map(&nres).expect_err("invalid grid"); + + assert!(matches!( + err, + TerrainFormatError::InvalidGridAreaRef { + cell: 0, + area_ref: 1, + area_count: 1 + } + )); + } + + #[test] + fn decodes_synthetic_build_dat() { + let bytes = br#" +// comment +Bunker_Small 2 + "UNITS\BUILDS\BUNKER\sbunk01.dat" + "UNITS\BUILDS\BUNKER\sbunk02.dat" +Generator 1 + "UNITS\BUILDS\GENER\gener01.dat" +"#; + let categories = decode_build_dat(bytes).expect("BuildDat"); + + assert_eq!(categories.len(), 2); + assert_eq!(categories[0].name, "Bunker_Small"); + assert_eq!(categories[0].mask, 0x8001_0000); + assert_eq!(categories[0].unit_paths.len(), 2); + assert_eq!(categories[1].name, "Generator"); + assert_eq!(categories[1].mask, 0x8000_0002); + } + + #[test] + fn rejects_unknown_build_category() { + let err = decode_build_dat(br#"Unknown 0"#).expect_err("unknown category"); + + assert!(matches!( + err, + TerrainFormatError::UnknownBuildCategory { line: 1, .. } + )); + } + + #[test] + fn rejects_build_category_count_mismatch() { + let err = decode_build_dat( + br#"Bunker_Small 2 + "UNITS\BUILDS\BUNKER\sbunk01.dat" +"#, + ) + .expect_err("count mismatch"); + + assert!(matches!( + err, + TerrainFormatError::InvalidBuildDatStructure { line: 1, .. } + )); + } + + #[test] + fn licensed_corpus_land_msh_validate() { + for (corpus, expected_files, expected_vertices, expected_faces) in [ + ("IS", 33_usize, 299_450_usize, 275_882_usize), + ("IS2", 32_usize, 188_024_usize, 184_454_usize), + ] { + let Some(root) = corpus_root(corpus) else { + continue; + }; + let mut files = 0usize; + let mut vertices = 0usize; + let mut faces = 0usize; + for path in files_under(&root) { + if !path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("Land.msh")) + { + continue; + } + let bytes = std::fs::read(&path).expect("read Land.msh"); + let nres = fparkan_nres::decode( + Arc::from(bytes.into_boxed_slice()), + ReadProfile::Compatible, + ) + .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + let document = + decode_land_msh(&nres).unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + files += 1; + vertices += document.positions.len(); + faces += document.faces.len(); + assert_eq!( + document + .streams + .iter() + .map(|stream| stream.type_id) + .collect::>(), + REQUIRED_TYPES, + "{corpus} {path:?} stream order" + ); + } + + assert_eq!(files, expected_files, "{corpus} Land.msh count"); + assert_eq!(vertices, expected_vertices, "{corpus} vertex count"); + assert_eq!(faces, expected_faces, "{corpus} face count"); + } + } + + #[test] + fn licensed_corpus_build_dat_validate() { + for (corpus, expected_ai_prefix) in [("IS", false), ("IS2", true)] { + let Some(root) = corpus_root(corpus) else { + continue; + }; + let path = root.join("BuildDat.lst"); + let bytes = std::fs::read(&path).expect("read BuildDat.lst"); + let categories = + decode_build_dat(&bytes).unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + + assert_eq!(categories.len(), BUILD_CATEGORY_MASKS.len(), "{corpus}"); + assert_eq!( + categories + .iter() + .map(|category| (category.name.as_str(), category.mask)) + .collect::>(), + BUILD_CATEGORY_MASKS, + "{corpus} category order/masks" + ); + assert_eq!( + categories + .iter() + .map(|category| category.unit_paths.len()) + .sum::(), + 32, + "{corpus} unit path count" + ); + assert!( + categories + .iter() + .all( + |category| category.unit_paths.iter().all(|path| path.starts_with( + if expected_ai_prefix { + "UNITS\\BUILDS\\AI\\" + } else { + "UNITS\\BUILDS\\" + } + ) && path + .to_ascii_lowercase() + .ends_with(".dat")) + ), + "{corpus} unit path prefixes" + ); + } + } + + #[test] + fn licensed_corpus_land_map_validate() { + for (corpus, expected_files, expected_areals, expected_vertices, expected_max_hits) in [ + ("IS", 33_usize, 34_662_usize, 197_698_usize, 20_usize), + ("IS2", 32_usize, 18_984_usize, 114_968_usize, 14_usize), + ] { + let Some(root) = corpus_root(corpus) else { + continue; + }; + let mut files = 0usize; + let mut areals = 0usize; + let mut vertices = 0usize; + let mut max_hits = 0usize; + for path in files_under(&root) { + if !path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("Land.map")) + { + continue; + } + let bytes = std::fs::read(&path).expect("read Land.map"); + let nres = fparkan_nres::decode( + Arc::from(bytes.into_boxed_slice()), + ReadProfile::Compatible, + ) + .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + let document = + decode_land_map(&nres).unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + files += 1; + areals += document.areals.len(); + vertices += document + .areals + .iter() + .map(|area| area.vertices.len()) + .sum::(); + max_hits = max_hits.max( + document + .grid + .cells + .iter() + .map(|cell| cell.area_ids.len()) + .max() + .unwrap_or(0), + ); + assert_eq!(document.grid.cells_x, 128, "{corpus} {path:?} cells_x"); + assert_eq!(document.grid.cells_y, 128, "{corpus} {path:?} cells_y"); + assert!( + document + .areals + .iter() + .all(|area| area.polygon_blocks.is_empty()), + "{corpus} {path:?} polygon blocks" + ); + } + + assert_eq!(files, expected_files, "{corpus} Land.map count"); + assert_eq!(areals, expected_areals, "{corpus} areal count"); + assert_eq!(vertices, expected_vertices, "{corpus} areal vertex count"); + assert_eq!(max_hits, expected_max_hits, "{corpus} max grid hits"); + } + } + + fn decode_nres(bytes: &[u8]) -> Result { + fparkan_nres::decode( + Arc::from(bytes.to_vec().into_boxed_slice()), + ReadProfile::Compatible, + ) + } + + fn minimal_land_msh(face: &[u8; 28]) -> Vec { + let positions = minimal_positions_payload(); + build_nres(&minimal_land_msh_entries(face, &positions)) + } + + fn minimal_positions_payload() -> Vec { + [ + 0.0_f32.to_le_bytes(), + 0.0_f32.to_le_bytes(), + 0.0_f32.to_le_bytes(), + 1.0_f32.to_le_bytes(), + 0.0_f32.to_le_bytes(), + 0.0_f32.to_le_bytes(), + 0.0_f32.to_le_bytes(), + 1.0_f32.to_le_bytes(), + 0.0_f32.to_le_bytes(), + ] + .concat() + } + + fn minimal_land_msh_entries<'a>(face: &'a [u8; 28], positions: &'a [u8]) -> [TestEntry<'a>; 9] { + [ + entry(TYPE_NODES, 0, 38, &[]), + entry(TYPE_SLOTS, 0, 0, &SLOT_HEADER_ZERO), + entry(TYPE_POSITIONS, 3, 12, positions), + entry(TYPE_NORMALS, 3, 4, &STREAM12_ZERO), + entry(TYPE_UV0, 3, 4, &STREAM12_ZERO), + entry(TYPE_AUX18, 0, 4, &[]), + entry(TYPE_AUX14, 0, 4, &[]), + entry(TYPE_ACCELERATOR, 0, 4, &[]), + entry(TYPE_FACES, 1, 28, face), + ] + } + + fn minimal_land_map(links: [(i32, i32); 2], grid_area_ref: u16) -> Vec { + let mut payload = Vec::new(); + push_f32(&mut payload, 0.0); + push_f32(&mut payload, 0.0); + push_f32(&mut payload, 0.0); + push_f32(&mut payload, 0.0); + push_f32(&mut payload, 2.0); + push_f32(&mut payload, 0.0); + push_f32(&mut payload, 1.0); + push_f32(&mut payload, 0.0); + push_u32(&mut payload, 0); + push_u32(&mut payload, 0); + push_u32(&mut payload, 7); + push_u32(&mut payload, 0); + push_u32(&mut payload, 2); + push_u32(&mut payload, 0); + push_f32(&mut payload, 0.0); + push_f32(&mut payload, 0.0); + push_f32(&mut payload, 0.0); + push_f32(&mut payload, 1.0); + push_f32(&mut payload, 0.0); + push_f32(&mut payload, 0.0); + for (area_ref, edge_ref) in links { + push_i32(&mut payload, area_ref); + push_i32(&mut payload, edge_ref); + } + push_u32(&mut payload, 1); + push_u32(&mut payload, 1); + push_u16(&mut payload, 1); + push_u16(&mut payload, grid_area_ref); + build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)]) + } + + fn minimal_land_map_with_poly(poly_n: u32, valid_grid: bool) -> Vec { + let mut payload = Vec::new(); + push_areal_prefix(&mut payload, 2, 1); + push_f32(&mut payload, 0.0); + push_f32(&mut payload, 0.0); + push_f32(&mut payload, 0.0); + push_f32(&mut payload, 1.0); + push_f32(&mut payload, 0.0); + push_f32(&mut payload, 0.0); + for _ in 0..5 { + push_i32(&mut payload, -1); + push_i32(&mut payload, -1); + } + push_u32(&mut payload, poly_n); + match poly_n { + 0 => payload.extend_from_slice(&[0; 4]), + 1 => payload.extend_from_slice(&[0; 16]), + _ => {} + } + if valid_grid { + push_u32(&mut payload, 1); + push_u32(&mut payload, 1); + push_u16(&mut payload, 1); + push_u16(&mut payload, 0); + } else { + push_u32(&mut payload, 0); + push_u32(&mut payload, 1); + } + build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)]) + } + + fn minimal_land_map_with_vertex_count(vertex_count: u32) -> Vec { + let mut payload = Vec::new(); + push_areal_prefix(&mut payload, vertex_count, 0); + build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)]) + } + + fn minimal_land_map_with_payload_tail() -> Vec { + let mut payload = Vec::new(); + push_areal_prefix(&mut payload, 2, 0); + push_f32(&mut payload, 0.0); + push_f32(&mut payload, 0.0); + push_f32(&mut payload, 0.0); + push_f32(&mut payload, 1.0); + push_f32(&mut payload, 0.0); + push_f32(&mut payload, 0.0); + for _ in 0..2 { + push_i32(&mut payload, -1); + push_i32(&mut payload, -1); + } + push_u32(&mut payload, 1); + push_u32(&mut payload, 1); + push_u16(&mut payload, 1); + push_u16(&mut payload, 0); + payload.push(0); + build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)]) + } + + fn push_areal_prefix(payload: &mut Vec, vertex_count: u32, poly_count: u32) { + push_f32(payload, 0.0); + push_f32(payload, 0.0); + push_f32(payload, 0.0); + push_f32(payload, 0.0); + push_f32(payload, 2.0); + push_f32(payload, 0.0); + push_f32(payload, 1.0); + push_f32(payload, 0.0); + push_u32(payload, 0); + push_u32(payload, 0); + push_u32(payload, 7); + push_u32(payload, 0); + push_u32(payload, vertex_count); + push_u32(payload, poly_count); + } + + fn face(vertices: [u16; 3], neighbors: [Option; 3]) -> [u8; 28] { + let mut out = [0; 28]; + out[8..10].copy_from_slice(&vertices[0].to_le_bytes()); + out[10..12].copy_from_slice(&vertices[1].to_le_bytes()); + out[12..14].copy_from_slice(&vertices[2].to_le_bytes()); + for (idx, neighbor) in neighbors.iter().enumerate() { + let raw = neighbor.unwrap_or(u16::MAX); + let offset = 14 + idx * 2; + out[offset..offset + 2].copy_from_slice(&raw.to_le_bytes()); + } + out[20..28].copy_from_slice(b"TAILFACE"); + out + } + + fn entry(type_id: u32, attr1: u32, attr3: u32, payload: &[u8]) -> TestEntry<'_> { + TestEntry { + type_id, + attr1, + attr3, + payload, + } + } + + #[derive(Clone, Copy)] + struct TestEntry<'a> { + type_id: u32, + attr1: u32, + attr3: u32, + payload: &'a [u8], + } + + fn build_nres(entries: &[TestEntry<'_>]) -> Vec { + 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 order: Vec = (0..entries.len()).collect(); + for (idx, entry) in entries.iter().enumerate() { + push_u32(&mut out, entry.type_id); + push_u32(&mut out, entry.attr1); + push_u32(&mut out, 0); + push_u32( + &mut out, + u32::try_from(entry.payload.len()).expect("payload"), + ); + push_u32(&mut out, entry.attr3); + let mut name_raw = [0; 36]; + let name = format!("Res{}", entry.type_id); + copy_cstr(&mut name_raw, name.as_bytes()); + 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 copy_cstr(dst: &mut [u8], src: &[u8]) { + let len = dst.len().saturating_sub(1).min(src.len()); + dst[..len].copy_from_slice(&src[..len]); + } + + fn push_u32(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); + } + + fn push_i32(out: &mut Vec, value: i32) { + out.extend_from_slice(&value.to_le_bytes()); + } + + fn push_u16(out: &mut Vec, value: u16) { + out.extend_from_slice(&value.to_le_bytes()); + } + + fn push_f32(out: &mut Vec, value: f32) { + out.extend_from_slice(&value.to_le_bytes()); + } + + fn corpus_root(name: &str) -> Option { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("testdata") + .join(name); + root.is_dir().then_some(root) + } + + fn files_under(root: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(path) = stack.pop() { + let Ok(read_dir) = std::fs::read_dir(path) else { + continue; + }; + for entry in read_dir.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + out.push(path); + } + } + } + out.sort(); + out + } +} diff --git a/crates/fparkan-terrain/Cargo.toml b/crates/fparkan-terrain/Cargo.toml new file mode 100644 index 0000000..c874c52 --- /dev/null +++ b/crates/fparkan-terrain/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "fparkan-terrain" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-terrain-format = { path = "../fparkan-terrain-format" } + +[dev-dependencies] +fparkan-nres = { path = "../fparkan-nres" } + +[lints] +workspace = true diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs new file mode 100644 index 0000000..b28fca6 --- /dev/null +++ b/crates/fparkan-terrain/src/lib.rs @@ -0,0 +1,1079 @@ +#![forbid(unsafe_code)] +//! Validated terrain runtime queries. + +use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument}; +use std::collections::VecDeque; + +/// Terrain world. +#[derive(Clone, Debug, Default)] +pub struct TerrainWorld { + areals: Vec, + grid: RuntimeGrid, + adjacency: Vec>, + surfaces: Vec, +} + +/// Surface hit. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SurfaceHit { + /// Height. + pub height: f32, + /// Hit position. + pub position: [f32; 3], + /// Ray distance parameter. + pub distance: f32, + /// Source face index. + pub face: usize, +} + +/// Areal id. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct ArealId(pub u32); + +/// Route request. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RouteRequest { + /// Start. + pub start: [f32; 3], + /// Goal. + pub goal: [f32; 3], +} + +/// Areal route. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArealRoute { + /// Areas. + pub areas: Vec, +} + +/// Terrain error. +#[derive(Debug)] +pub enum TerrainError { + /// Query is not supported by current data. + Unsupported, + /// Area count exceeds runtime id range. + TooManyAreals { + /// Area count. + count: usize, + }, + /// Grid references an out-of-range area. + InvalidGridReference { + /// Referenced area. + area: u32, + /// Area count. + area_count: usize, + }, + /// Areal graph references an out-of-range area. + InvalidArealReference { + /// Source area. + source: usize, + /// Referenced area. + target: u32, + /// Area count. + area_count: usize, + }, + /// Terrain face references an out-of-range vertex. + InvalidSurfaceVertex { + /// Source face. + face: usize, + /// Referenced vertex. + vertex: u16, + /// Position count. + position_count: usize, + }, +} + +impl std::fmt::Display for TerrainError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unsupported => write!(f, "terrain query unsupported by current data"), + Self::TooManyAreals { count } => write!(f, "too many areals: {count}"), + Self::InvalidGridReference { area, area_count } => { + write!(f, "grid references area {area} outside {area_count} areas") + } + Self::InvalidArealReference { + source, + target, + area_count, + } => write!( + f, + "area {source} references area {target} outside {area_count} areas" + ), + Self::InvalidSurfaceVertex { + face, + vertex, + position_count, + } => write!( + f, + "terrain face {face} references vertex {vertex} outside {position_count} positions" + ), + } + } +} + +impl std::error::Error for TerrainError {} + +/// Surface query. +pub trait SurfaceQuery { + /// Height at position. + /// + /// # Errors + /// + /// Returns [`TerrainError`] when the current world lacks surface geometry. + fn height_at(&self, position: [f32; 2]) -> Result, TerrainError>; + + /// Raycast. + /// + /// # Errors + /// + /// Returns [`TerrainError`] when the current world lacks surface geometry. + fn raycast( + &self, + origin: [f32; 3], + direction: [f32; 3], + mask: FullSurfaceMask, + ) -> Result, TerrainError>; +} + +/// Navigation query. +pub trait NavigationQuery { + /// Locate areal. + /// + /// # Errors + /// + /// Returns [`TerrainError`] when runtime indexes are invalid. + fn locate_areal(&self, position: [f32; 3]) -> Result, TerrainError>; + + /// Route. + /// + /// # Errors + /// + /// Returns [`TerrainError`] when runtime indexes are invalid. + fn route(&self, request: RouteRequest) -> Result, TerrainError>; +} + +impl TerrainWorld { + /// Builds navigation runtime data from a decoded `Land.map`. + /// + /// # Errors + /// + /// Returns [`TerrainError`] if ids or references cannot be represented by + /// runtime indexes. + pub fn from_land_map(map: &LandMapDocument) -> Result { + let areal_count = map.areals.len(); + if u32::try_from(areal_count).is_err() { + return Err(TerrainError::TooManyAreals { count: areal_count }); + } + let mut areals = Vec::with_capacity(areal_count); + for (index, areal) in map.areals.iter().enumerate() { + let id = ArealId( + u32::try_from(index) + .map_err(|_| TerrainError::TooManyAreals { count: areal_count })?, + ); + areals.push(RuntimeAreal { + id, + polygon: areal + .vertices + .iter() + .map(|vertex| [vertex[0], vertex[2]]) + .collect(), + }); + } + + let mut adjacency = vec![Vec::new(); areal_count]; + for (source_index, areal) in map.areals.iter().enumerate() { + for link in &areal.links { + let Some(target) = link.area_ref else { + continue; + }; + let target_index = + usize::try_from(target).map_err(|_| TerrainError::InvalidArealReference { + source: source_index, + target, + area_count: areal_count, + })?; + if target_index >= areal_count { + return Err(TerrainError::InvalidArealReference { + source: source_index, + target, + area_count: areal_count, + }); + } + let id = ArealId(target); + if !adjacency[source_index].contains(&id) { + adjacency[source_index].push(id); + } + } + adjacency[source_index].sort_by_key(|id| id.0); + } + + let grid = RuntimeGrid::from_land_map(map)?; + Ok(Self { + areals, + grid, + adjacency, + surfaces: Vec::new(), + }) + } + + /// Builds surface runtime data from a decoded `Land.msh`. + /// + /// # Errors + /// + /// Returns [`TerrainError`] if a face cannot be represented by runtime + /// indexes. + pub fn from_land_msh(mesh: &LandMeshDocument) -> Result { + Ok(Self { + surfaces: build_surfaces(mesh)?, + ..Self::default() + }) + } + + /// Builds terrain runtime data from decoded `Land.msh` and `Land.map`. + /// + /// # Errors + /// + /// Returns [`TerrainError`] if surface or navigation runtime indexes are + /// invalid. + pub fn from_land_assets( + mesh: &LandMeshDocument, + map: &LandMapDocument, + ) -> Result { + let mut world = Self::from_land_map(map)?; + world.surfaces = build_surfaces(mesh)?; + Ok(world) + } + + /// Returns the number of navigation areas. + #[must_use] + pub fn areal_count(&self) -> usize { + self.areals.len() + } + + /// Returns the number of surface triangles. + #[must_use] + pub fn surface_count(&self) -> usize { + self.surfaces.len() + } + + fn locate_by_candidates( + &self, + position: [f32; 3], + candidates: &[ArealId], + ) -> Result, TerrainError> { + let point = [position[0], position[2]]; + for candidate in candidates { + let Some(areal) = usize::try_from(candidate.0) + .ok() + .and_then(|index| self.areals.get(index)) + else { + return Err(TerrainError::InvalidGridReference { + area: candidate.0, + area_count: self.areals.len(), + }); + }; + if areal.contains(point) { + return Ok(Some(areal.id)); + } + } + Ok(None) + } + + fn route_ids(&self, start: ArealId, goal: ArealId) -> Result, TerrainError> { + let start_index = + usize::try_from(start.0).map_err(|_| TerrainError::InvalidArealReference { + source: 0, + target: start.0, + area_count: self.areals.len(), + })?; + let goal_index = + usize::try_from(goal.0).map_err(|_| TerrainError::InvalidArealReference { + source: start_index, + target: goal.0, + area_count: self.areals.len(), + })?; + if start_index >= self.areals.len() { + return Err(TerrainError::InvalidArealReference { + source: start_index, + target: start.0, + area_count: self.areals.len(), + }); + } + if goal_index >= self.areals.len() { + return Err(TerrainError::InvalidArealReference { + source: start_index, + target: goal.0, + area_count: self.areals.len(), + }); + } + if start == goal { + return Ok(Some(ArealRoute { areas: vec![start] })); + } + + let mut previous = vec![None; self.areals.len()]; + let mut visited = vec![false; self.areals.len()]; + let mut queue = VecDeque::new(); + visited[start_index] = true; + queue.push_back(start_index); + + while let Some(current) = queue.pop_front() { + for next in &self.adjacency[current] { + let next_index = + usize::try_from(next.0).map_err(|_| TerrainError::InvalidArealReference { + source: current, + target: next.0, + area_count: self.areals.len(), + })?; + if next_index >= self.areals.len() { + return Err(TerrainError::InvalidArealReference { + source: current, + target: next.0, + area_count: self.areals.len(), + }); + } + if visited[next_index] { + continue; + } + visited[next_index] = true; + previous[next_index] = Some(current); + if next_index == goal_index { + return Ok(Some(reconstruct_route(&previous, start_index, goal_index))); + } + queue.push_back(next_index); + } + } + Ok(None) + } +} + +impl SurfaceQuery for TerrainWorld { + fn height_at(&self, position: [f32; 2]) -> Result, TerrainError> { + if self.surfaces.is_empty() { + return Err(TerrainError::Unsupported); + } + let mut best = None; + for triangle in &self.surfaces { + if let Some(height) = triangle.height_at(position) { + best = Some(best.map_or(height, |current: f32| current.max(height))); + } + } + Ok(best) + } + + fn raycast( + &self, + origin: [f32; 3], + direction: [f32; 3], + mask: FullSurfaceMask, + ) -> Result, TerrainError> { + if self.surfaces.is_empty() { + return Err(TerrainError::Unsupported); + } + let mut best: Option = None; + for triangle in &self.surfaces { + if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 { + continue; + } + let Some(distance) = triangle.raycast(origin, direction) else { + continue; + }; + if best.is_some_and(|hit| hit.distance <= distance) { + continue; + } + let position = [ + origin[0] + direction[0] * distance, + origin[1] + direction[1] * distance, + origin[2] + direction[2] * distance, + ]; + best = Some(SurfaceHit { + height: position[1], + position, + distance, + face: triangle.face, + }); + } + Ok(best) + } +} + +impl NavigationQuery for TerrainWorld { + fn locate_areal(&self, position: [f32; 3]) -> Result, TerrainError> { + if let Some(candidates) = self.grid.candidates(position) { + if let Some(id) = self.locate_by_candidates(position, candidates)? { + return Ok(Some(id)); + } + } + let all: Vec = self.areals.iter().map(|areal| areal.id).collect(); + self.locate_by_candidates(position, &all) + } + + fn route(&self, request: RouteRequest) -> Result, TerrainError> { + let Some(start) = self.locate_areal(request.start)? else { + return Ok(None); + }; + let Some(goal) = self.locate_areal(request.goal)? else { + return Ok(None); + }; + self.route_ids(start, goal) + } +} + +#[derive(Clone, Debug)] +struct RuntimeTriangle { + face: usize, + mask: FullSurfaceMask, + vertices: [[f32; 3]; 3], +} + +impl RuntimeTriangle { + fn height_at(&self, position: [f32; 2]) -> Option { + let a = [self.vertices[0][0], self.vertices[0][2]]; + let b = [self.vertices[1][0], self.vertices[1][2]]; + let c = [self.vertices[2][0], self.vertices[2][2]]; + let weights = barycentric_2d(position, a, b, c)?; + if weights + .iter() + .all(|weight| *weight >= -1.0e-4 && *weight <= 1.0001) + { + Some( + weights[0] * self.vertices[0][1] + + weights[1] * self.vertices[1][1] + + weights[2] * self.vertices[2][1], + ) + } else { + None + } + } + + fn raycast(&self, origin: [f32; 3], direction: [f32; 3]) -> Option { + let edge1 = sub3(self.vertices[1], self.vertices[0]); + let edge2 = sub3(self.vertices[2], self.vertices[0]); + let pvec = cross3(direction, edge2); + let det = dot3(edge1, pvec); + if det.abs() <= 1.0e-6 { + return None; + } + let inv_det = 1.0 / det; + let tvec = sub3(origin, self.vertices[0]); + let u = dot3(tvec, pvec) * inv_det; + if !(-1.0e-5..=1.00001).contains(&u) { + return None; + } + let qvec = cross3(tvec, edge1); + let v = dot3(direction, qvec) * inv_det; + if v < -1.0e-5 || u + v > 1.00001 { + return None; + } + let distance = dot3(edge2, qvec) * inv_det; + (distance >= 0.0).then_some(distance) + } +} + +#[derive(Clone, Debug)] +struct RuntimeAreal { + id: ArealId, + polygon: Vec<[f32; 2]>, +} + +impl RuntimeAreal { + fn contains(&self, point: [f32; 2]) -> bool { + if self.polygon.len() < 3 { + return false; + } + if self.on_boundary(point) { + return true; + } + + let mut inside = false; + let mut prev = self.polygon[self.polygon.len() - 1]; + for current in &self.polygon { + let crosses = (current[1] > point[1]) != (prev[1] > point[1]); + if crosses { + let x_intersect = (prev[0] - current[0]) * (point[1] - current[1]) + / (prev[1] - current[1]) + + current[0]; + if point[0] < x_intersect { + inside = !inside; + } + } + prev = *current; + } + inside + } + + fn on_boundary(&self, point: [f32; 2]) -> bool { + let mut prev = self.polygon[self.polygon.len() - 1]; + for current in &self.polygon { + if point_on_segment(point, prev, *current) { + return true; + } + prev = *current; + } + false + } +} + +#[derive(Clone, Debug, Default)] +struct RuntimeGrid { + cells_x: u32, + cells_y: u32, + min: [f32; 2], + max: [f32; 2], + cells: Vec>, +} + +impl RuntimeGrid { + fn from_land_map(map: &LandMapDocument) -> Result { + let mut min = [f32::INFINITY, f32::INFINITY]; + let mut max = [f32::NEG_INFINITY, f32::NEG_INFINITY]; + for areal in &map.areals { + for vertex in &areal.vertices { + min[0] = min[0].min(vertex[0]); + min[1] = min[1].min(vertex[2]); + max[0] = max[0].max(vertex[0]); + max[1] = max[1].max(vertex[2]); + } + } + if !min[0].is_finite() || !min[1].is_finite() || !max[0].is_finite() || !max[1].is_finite() + { + min = [0.0, 0.0]; + max = [1.0, 1.0]; + } + if (min[0] - max[0]).abs() <= f32::EPSILON { + max[0] += 1.0; + } + if (min[1] - max[1]).abs() <= f32::EPSILON { + max[1] += 1.0; + } + + let mut cells = Vec::with_capacity(map.grid.cells.len()); + for cell in &map.grid.cells { + let mut ids = Vec::with_capacity(cell.area_ids.len()); + for area in &cell.area_ids { + let index = + usize::try_from(*area).map_err(|_| TerrainError::InvalidGridReference { + area: *area, + area_count: map.areals.len(), + })?; + if index >= map.areals.len() { + return Err(TerrainError::InvalidGridReference { + area: *area, + area_count: map.areals.len(), + }); + } + ids.push(ArealId(*area)); + } + cells.push(ids); + } + Ok(Self { + cells_x: map.grid.cells_x, + cells_y: map.grid.cells_y, + min, + max, + cells, + }) + } + + fn candidates(&self, position: [f32; 3]) -> Option<&[ArealId]> { + if self.cells_x == 0 || self.cells_y == 0 || self.cells.is_empty() { + return None; + } + let point = [position[0], position[2]]; + if point[0] < self.min[0] + || point[0] > self.max[0] + || point[1] < self.min[1] + || point[1] > self.max[1] + { + return None; + } + let nx = normalized_cell(point[0], self.min[0], self.max[0], self.cells_x); + let ny = normalized_cell(point[1], self.min[1], self.max[1], self.cells_y); + let index_u32 = ny.checked_mul(self.cells_x)?.checked_add(nx)?; + let index = usize::try_from(index_u32).ok()?; + self.cells.get(index).map(Vec::as_slice) + } +} + +fn build_surfaces(mesh: &LandMeshDocument) -> Result, TerrainError> { + let mut triangles = Vec::with_capacity(mesh.faces.len()); + for (face_index, face) in mesh.faces.iter().enumerate() { + let vertices = [ + surface_vertex(mesh, face_index, face.vertices[0])?, + surface_vertex(mesh, face_index, face.vertices[1])?, + surface_vertex(mesh, face_index, face.vertices[2])?, + ]; + triangles.push(RuntimeTriangle { + face: face_index, + mask: face.flags, + vertices, + }); + } + Ok(triangles) +} + +fn surface_vertex( + mesh: &LandMeshDocument, + face: usize, + vertex: u16, +) -> Result<[f32; 3], TerrainError> { + mesh.positions + .get(usize::from(vertex)) + .copied() + .ok_or(TerrainError::InvalidSurfaceVertex { + face, + vertex, + position_count: mesh.positions.len(), + }) +} + +fn barycentric_2d( + point: [f32; 2], + first: [f32; 2], + second: [f32; 2], + third: [f32; 2], +) -> Option<[f32; 3]> { + let edge_second = [second[0] - first[0], second[1] - first[1]]; + let edge_third = [third[0] - first[0], third[1] - first[1]]; + let point_delta = [point[0] - first[0], point[1] - first[1]]; + let denom = edge_second[0] * edge_third[1] - edge_third[0] * edge_second[1]; + if denom.abs() <= 1.0e-6 { + return None; + } + let inv = 1.0 / denom; + let second_weight = (point_delta[0] * edge_third[1] - edge_third[0] * point_delta[1]) * inv; + let third_weight = (edge_second[0] * point_delta[1] - point_delta[0] * edge_second[1]) * inv; + let first_weight = 1.0 - second_weight - third_weight; + Some([first_weight, second_weight, third_weight]) +} + +fn sub3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn normalized_cell(value: f32, min: f32, max: f32, cells: u32) -> u32 { + let span = max - min; + if span <= 0.0 { + return 0; + } + if value <= min { + return 0; + } + if value >= max { + return cells.saturating_sub(1); + } + let value = f64::from(value); + let min = f64::from(min); + let span = f64::from(span); + for cell in 0..cells { + let upper = min + span * f64::from(cell + 1) / f64::from(cells); + if value <= upper { + return cell; + } + } + cells.saturating_sub(1) +} + +fn point_on_segment(point: [f32; 2], a: [f32; 2], b: [f32; 2]) -> bool { + let cross = (point[1] - a[1]) * (b[0] - a[0]) - (point[0] - a[0]) * (b[1] - a[1]); + if cross.abs() > 1.0e-4 { + return false; + } + let min_x = a[0].min(b[0]) - 1.0e-4; + let max_x = a[0].max(b[0]) + 1.0e-4; + let min_y = a[1].min(b[1]) - 1.0e-4; + let max_y = a[1].max(b[1]) + 1.0e-4; + point[0] >= min_x && point[0] <= max_x && point[1] >= min_y && point[1] <= max_y +} + +fn reconstruct_route(previous: &[Option], start: usize, goal: usize) -> ArealRoute { + let mut route = Vec::new(); + let mut current = goal; + route.push(ArealId(u32::try_from(current).unwrap_or(u32::MAX))); + while current != start { + let Some(prev) = previous[current] else { + break; + }; + current = prev; + route.push(ArealId(u32::try_from(current).unwrap_or(u32::MAX))); + } + route.reverse(); + ArealRoute { areas: route } +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_nres::ReadProfile; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + + #[test] + fn locates_areal_and_routes_synthetic_neighbors() { + let map = synthetic_land_map(); + let world = TerrainWorld::from_land_map(&map).expect("world"); + + assert_eq!(world.areal_count(), 2); + assert_eq!( + world.locate_areal([0.25, 0.0, 0.25]).expect("locate"), + Some(ArealId(0)) + ); + assert_eq!( + world.locate_areal([1.75, 0.0, 0.25]).expect("locate"), + Some(ArealId(1)) + ); + assert_eq!( + world + .route(RouteRequest { + start: [0.25, 0.0, 0.25], + goal: [1.75, 0.0, 0.25], + }) + .expect("route"), + Some(ArealRoute { + areas: vec![ArealId(0), ArealId(1)] + }) + ); + } + + #[test] + fn missing_start_or_goal_returns_no_route() { + let world = TerrainWorld::from_land_map(&synthetic_land_map()).expect("world"); + + assert_eq!( + world + .route(RouteRequest { + start: [10.0, 0.0, 10.0], + goal: [1.75, 0.0, 0.25], + }) + .expect("route"), + None + ); + } + + #[test] + fn synthetic_surface_height_and_raycast_work() { + let world = TerrainWorld::from_land_msh(&synthetic_land_mesh()).expect("world"); + + assert_eq!(world.surface_count(), 2); + assert_eq!(world.height_at([0.25, 0.25]).expect("height"), Some(0.5)); + assert_eq!(world.height_at([10.0, 10.0]).expect("height"), None); + + let hit = world + .raycast( + [0.25, 2.0, 0.25], + [0.0, -1.0, 0.0], + FullSurfaceMask(0x0000_0001), + ) + .expect("raycast") + .expect("hit"); + assert_eq!(hit.face, 0); + assert!((hit.height - 0.5).abs() < 1.0e-5); + assert!((hit.distance - 1.5).abs() < 1.0e-5); + + assert_eq!( + world + .raycast( + [0.25, 2.0, 0.25], + [0.0, -1.0, 0.0], + FullSurfaceMask(0x8000_0000) + ) + .expect("raycast"), + None + ); + } + + #[test] + fn licensed_corpus_land_maps_build_navigation_worlds() { + for (corpus, expected_files, expected_areals) in [ + ("IS", 33_usize, 34_662_usize), + ("IS2", 32_usize, 18_984_usize), + ] { + let Some(root) = corpus_root(corpus) else { + continue; + }; + let mut files = 0usize; + let mut areals = 0usize; + let mut located_centers = 0usize; + for path in files_under(&root) { + if !path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("Land.map")) + { + continue; + } + let bytes = std::fs::read(&path).expect("read Land.map"); + let nres = fparkan_nres::decode( + Arc::from(bytes.into_boxed_slice()), + ReadProfile::Compatible, + ) + .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + let map = fparkan_terrain_format::decode_land_map(&nres) + .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + let world = TerrainWorld::from_land_map(&map) + .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + files += 1; + areals += world.areal_count(); + for (index, areal) in map.areals.iter().take(8).enumerate() { + if let Some(point) = polygon_probe_point(&areal.vertices) { + let located = world + .locate_areal([point[0], point[1], point[2]]) + .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + assert!( + located.is_some(), + "{corpus} {path:?} area {index} probe point was not located" + ); + located_centers += 1; + } + } + } + + assert_eq!(files, expected_files, "{corpus} Land.map count"); + assert_eq!(areals, expected_areals, "{corpus} areal count"); + assert!( + located_centers >= expected_files, + "{corpus} located center coverage" + ); + } + } + + #[test] + fn licensed_corpus_land_meshes_build_surface_worlds() { + for (corpus, expected_files, expected_faces) in [ + ("IS", 33_usize, 275_882_usize), + ("IS2", 32_usize, 184_454_usize), + ] { + let Some(root) = corpus_root(corpus) else { + continue; + }; + let mut files = 0usize; + let mut faces = 0usize; + for path in files_under(&root) { + if !path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("Land.msh")) + { + continue; + } + let bytes = std::fs::read(&path).expect("read Land.msh"); + let nres = fparkan_nres::decode( + Arc::from(bytes.into_boxed_slice()), + ReadProfile::Compatible, + ) + .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + let mesh = fparkan_terrain_format::decode_land_msh(&nres) + .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + let world = TerrainWorld::from_land_msh(&mesh) + .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + files += 1; + faces += world.surface_count(); + } + + assert_eq!(files, expected_files, "{corpus} Land.msh count"); + assert_eq!(faces, expected_faces, "{corpus} surface face count"); + } + } + + fn synthetic_land_mesh() -> LandMeshDocument { + use fparkan_terrain_format::{TerrainSlotTable, TerrainStream}; + + let face0 = terrain_face(FullSurfaceMask(0x0000_0001), [0, 1, 2]); + let face1 = terrain_face(FullSurfaceMask(0x0000_0002), [1, 3, 2]); + LandMeshDocument { + streams: Vec::::new(), + nodes_raw: Vec::new(), + slots: TerrainSlotTable { + header_raw: Vec::new(), + slots_raw: Vec::new(), + }, + positions: vec![ + [0.0, 0.0, 0.0], + [1.0, 1.0, 0.0], + [0.0, 1.0, 1.0], + [1.0, 2.0, 1.0], + ], + normals: Vec::new(), + uv0: Vec::new(), + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: vec![face0, face1], + } + } + + fn terrain_face( + flags: FullSurfaceMask, + vertices: [u16; 3], + ) -> fparkan_terrain_format::TerrainFace28 { + use fparkan_terrain_format::TerrainFace28; + + TerrainFace28 { + flags, + material_tag: 0, + aux_tag: 0, + vertices, + neighbors: [None, None, None], + tail_raw: [0; 8], + raw: [0; 28], + } + } + + fn synthetic_land_map() -> LandMapDocument { + use fparkan_terrain_format::{ + Areal, ArealGrid, ArealGridCell, EdgeLink, TerrainStream, TerrainStreamAttributes, + }; + + LandMapDocument { + entry: TerrainStream { + type_id: 12, + attributes: TerrainStreamAttributes::default(), + size: 0, + }, + areal_count: 2, + areals: vec![ + Areal { + prefix_raw: [0; 56], + anchor: [0.5, 0.0, 0.5], + reserved_12: 0.0, + area_metric: 1.0, + normal: [0.0, 1.0, 0.0], + logic_flag: 0, + reserved_36: 0, + class_id: 0, + reserved_44: 0, + vertices: vec![ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [1.0, 0.0, 1.0], + [0.0, 0.0, 1.0], + ], + links: vec![ + EdgeLink { + raw_area_ref: -1, + raw_edge_ref: -1, + area_ref: None, + edge_ref: None, + }, + EdgeLink { + raw_area_ref: 1, + raw_edge_ref: 3, + area_ref: Some(1), + edge_ref: Some(3), + }, + EdgeLink { + raw_area_ref: -1, + raw_edge_ref: -1, + area_ref: None, + edge_ref: None, + }, + EdgeLink { + raw_area_ref: -1, + raw_edge_ref: -1, + area_ref: None, + edge_ref: None, + }, + ], + polygon_blocks: Vec::new(), + }, + Areal { + prefix_raw: [0; 56], + anchor: [1.5, 0.0, 0.5], + reserved_12: 0.0, + area_metric: 1.0, + normal: [0.0, 1.0, 0.0], + logic_flag: 0, + reserved_36: 0, + class_id: 0, + reserved_44: 0, + vertices: vec![ + [1.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [2.0, 0.0, 1.0], + [1.0, 0.0, 1.0], + ], + links: vec![ + EdgeLink { + raw_area_ref: -1, + raw_edge_ref: -1, + area_ref: None, + edge_ref: None, + }, + EdgeLink { + raw_area_ref: -1, + raw_edge_ref: -1, + area_ref: None, + edge_ref: None, + }, + EdgeLink { + raw_area_ref: -1, + raw_edge_ref: -1, + area_ref: None, + edge_ref: None, + }, + EdgeLink { + raw_area_ref: 0, + raw_edge_ref: 1, + area_ref: Some(0), + edge_ref: Some(1), + }, + ], + polygon_blocks: Vec::new(), + }, + ], + grid: ArealGrid { + cells_x: 2, + cells_y: 1, + cells: vec![ + ArealGridCell { area_ids: vec![0] }, + ArealGridCell { area_ids: vec![1] }, + ], + candidate_pool: vec![0, 1], + compact_cells: vec![0x0040_0000, 0x0040_0001], + }, + } + } + + fn polygon_probe_point(vertices: &[[f32; 3]]) -> Option<[f32; 3]> { + vertices.first().copied() + } + + fn corpus_root(name: &str) -> Option { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("testdata") + .join(name); + root.is_dir().then_some(root) + } + + fn files_under(root: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(path) = stack.pop() { + let Ok(read_dir) = std::fs::read_dir(path) else { + continue; + }; + for entry in read_dir.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + out.push(path); + } + } + } + out.sort(); + out + } +} diff --git a/crates/fparkan-test-support/Cargo.toml b/crates/fparkan-test-support/Cargo.toml new file mode 100644 index 0000000..56a079f --- /dev/null +++ b/crates/fparkan-test-support/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fparkan-test-support" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-render = { path = "../fparkan-render" } + +[lints] +workspace = true diff --git a/crates/fparkan-test-support/src/lib.rs b/crates/fparkan-test-support/src/lib.rs new file mode 100644 index 0000000..cb4f552 --- /dev/null +++ b/crates/fparkan-test-support/src/lib.rs @@ -0,0 +1,25 @@ +#![forbid(unsafe_code)] +//! Dev-only synthetic builders and fake ports. + +use fparkan_render::{FrameOutput, RenderBackend, RenderCommandList, RenderError}; + +/// Fake clock. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct FakeClock { + /// Current tick. + pub tick: u64, +} + +/// Recording backend. +#[derive(Clone, Debug, Default)] +pub struct RecordingRenderBackend { + /// Recorded command lists. + pub captures: Vec, +} + +impl RenderBackend for RecordingRenderBackend { + fn execute(&mut self, commands: &RenderCommandList) -> Result { + self.captures.push(commands.clone()); + Ok(FrameOutput) + } +} diff --git a/crates/fparkan-texm/Cargo.toml b/crates/fparkan-texm/Cargo.toml new file mode 100644 index 0000000..b3171f7 --- /dev/null +++ b/crates/fparkan-texm/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "fparkan-texm" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] + +[dev-dependencies] +fparkan-nres = { path = "../fparkan-nres" } + +[lints] +workspace = true diff --git a/crates/fparkan-texm/src/lib.rs b/crates/fparkan-texm/src/lib.rs new file mode 100644 index 0000000..6adc8b1 --- /dev/null +++ b/crates/fparkan-texm/src/lib.rs @@ -0,0 +1,1187 @@ +#![forbid(unsafe_code)] +//! Stage-3 Texm texture contract. + +use std::sync::Arc; + +const TEXM_MAGIC: u32 = 0x6D78_6554; +const PAGE_MAGIC: u32 = 0x6567_6150; + +/// Pixel format. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PixelFormat { + /// Indexed 8. + Indexed8, + /// RGB565. + Rgb565, + /// RGB556. + Rgb556, + /// ARGB4444. + Argb4444, + /// Luminance alpha 8:8. + L8A8, + /// RGB888 with preserved service byte in disk payload. + Rgb888x, + /// ARGB8888. + Argb8888, +} + +/// Texm disk document. +#[derive(Clone, Debug)] +pub struct TexmDocument { + bytes: Arc<[u8]>, + texture: Texture, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DiskPixelFormat { + Indexed8, + Rgb565, + Rgb556, + Argb4444, + L8A8, + Rgb888x, + Argb8888, +} + +impl DiskPixelFormat { + fn from_raw(raw: u32) -> Option { + match raw { + 0 => Some(Self::Indexed8), + 565 => Some(Self::Rgb565), + 556 => Some(Self::Rgb556), + 4444 => Some(Self::Argb4444), + 88 => Some(Self::L8A8), + 888 => Some(Self::Rgb888x), + 8888 => Some(Self::Argb8888), + _ => None, + } + } + + fn bytes_per_pixel(self) -> usize { + match self { + Self::Indexed8 => 1, + Self::Rgb565 | Self::Rgb556 | Self::Argb4444 | Self::L8A8 => 2, + Self::Rgb888x | Self::Argb8888 => 4, + } + } +} + +#[derive(Clone, Debug)] +struct Header { + width: u32, + height: u32, + format: DiskPixelFormat, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct MipLevel { + width: u32, + height: u32, + offset: usize, + size: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct DiskPageRect { + x: i16, + w: i16, + y: i16, + h: i16, +} + +#[derive(Clone, Debug)] +struct Texture { + header: Header, + palette: Option<[u8; 1024]>, + mip_levels: Vec, + page_rects: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct DecodedMip { + width: u32, + height: u32, + rgba8: Vec, +} + +/// Borrowed mip level view. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MipLevelView<'a> { + /// Mip level index. + pub level: u32, + /// Width. + pub width: u32, + /// Height. + pub height: u32, + /// Raw disk bytes for this level. + pub bytes: &'a [u8], +} + +/// Page rectangle. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PageRect { + /// X origin. + pub x: i16, + /// Width. + pub w: i16, + /// Y origin. + pub y: i16, + /// Height. + pub h: i16, +} + +/// Page rectangle scaling policy. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum PageScalePolicy { + /// Scale origin with floor and end with ceil, preserving coverage. + #[default] + FloorOriginCeilEnd, +} + +/// RGBA8 image. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RgbaImage { + /// Width. + pub width: u32, + /// Height. + pub height: u32, + /// Packed RGBA8 pixels. + pub rgba8: Vec, +} + +/// Texture upload plan. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TextureUploadPlan { + /// Pixel format. + pub format: PixelFormat, + /// Original texture width. + pub width: u32, + /// Original texture height. + pub height: u32, + /// Selected mip levels. + pub mips: Vec, + /// Page rectangles copied from disk metadata. + pub page_rects: Vec, +} + +/// Upload mip description. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UploadMip { + /// Original mip level index. + pub level: u32, + /// Width. + pub width: u32, + /// Height. + pub height: u32, + /// Byte offset in the original disk document. + pub offset: usize, + /// Byte size. + pub size: usize, +} + +/// Mip skip policy. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct MipSkipPolicy { + /// Number of top mip levels to skip. + pub skip_top_levels: u32, +} + +/// Texm decode error. +#[derive(Debug)] +pub enum TexmError { + /// Legacy parser error. + Format(String), + /// Requested mip level is absent. + MipLevelOutOfRange { + /// Requested level. + requested: u32, + /// Available mip count. + mip_count: usize, + }, + /// Mip payload range is outside the document. + MipDataOutOfBounds { + /// Byte offset. + offset: usize, + /// Byte size. + size: usize, + /// Document size. + document_size: usize, + }, + /// All mip levels were skipped. + EmptyUploadPlan, +} + +impl std::fmt::Display for TexmError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Format(message) => write!(f, "{message}"), + Self::MipLevelOutOfRange { + requested, + mip_count, + } => write!( + f, + "Texm mip level out of range: requested={requested}, mip_count={mip_count}" + ), + Self::MipDataOutOfBounds { + offset, + size, + document_size, + } => write!( + f, + "Texm mip bytes out of bounds: offset={offset}, size={size}, document_size={document_size}" + ), + Self::EmptyUploadPlan => write!(f, "Texm upload plan contains no mip levels"), + } + } +} + +impl std::error::Error for TexmError {} + +/// Decodes Texm disk bytes. +/// +/// # Errors +/// +/// Returns [`TexmError`] when the header, format, mip chain, palette, or Page +/// chunk is malformed. +pub fn decode_texm(bytes: Arc<[u8]>) -> Result { + let texture = parse_texm(&bytes)?; + Ok(TexmDocument { bytes, texture }) +} + +/// Decodes one mip level into RGBA8 using the CPU reference decoder. +/// +/// # Errors +/// +/// Returns [`TexmError`] when `level` is outside the mip chain or mip bytes are +/// malformed. +pub fn decode_mip_rgba8(document: &TexmDocument, level: u32) -> Result { + let decoded = decode_mip_rgba8_internal( + &document.texture, + &document.bytes, + usize::try_from(level).map_err(|_| TexmError::MipLevelOutOfRange { + requested: level, + mip_count: document.texture.mip_levels.len(), + })?, + )?; + Ok(RgbaImage { + width: decoded.width, + height: decoded.height, + rgba8: decoded.rgba8, + }) +} + +/// Builds an upload plan without mutating the disk document. +/// +/// # Errors +/// +/// Returns [`TexmError::EmptyUploadPlan`] when the policy skips every mip. +pub fn plan_upload( + document: &TexmDocument, + policy: MipSkipPolicy, +) -> Result { + let skip = usize::try_from(policy.skip_top_levels).map_err(|_| TexmError::EmptyUploadPlan)?; + let mips = document + .texture + .mip_levels + .iter() + .enumerate() + .skip(skip) + .map(|(level, mip)| { + Ok(UploadMip { + level: u32::try_from(level).map_err(|_| TexmError::EmptyUploadPlan)?, + width: mip.width, + height: mip.height, + offset: mip.offset, + size: mip.size, + }) + }) + .collect::, TexmError>>()?; + if mips.is_empty() { + return Err(TexmError::EmptyUploadPlan); + } + Ok(TextureUploadPlan { + format: map_format(document.texture.header.format), + width: document.texture.header.width, + height: document.texture.header.height, + mips, + page_rects: document + .texture + .page_rects + .iter() + .copied() + .map(map_page_rect) + .collect(), + }) +} + +/// Returns Page rectangles scaled to a selected mip level. +/// +/// # Errors +/// +/// Returns [`TexmError`] when `level` is outside the mip chain or scaled values +/// cannot be represented as `i16`. +pub fn scaled_page_rects( + document: &TexmDocument, + level: u32, + policy: PageScalePolicy, +) -> Result, TexmError> { + let mip = document.mip_level(level)?; + document + .texture + .page_rects + .iter() + .copied() + .map(|rect| { + scale_page_rect( + document.width(), + document.height(), + mip.width, + mip.height, + rect, + policy, + ) + }) + .collect() +} + +impl TexmDocument { + /// Width. + #[must_use] + pub fn width(&self) -> u32 { + self.texture.header.width + } + + /// Height. + #[must_use] + pub fn height(&self) -> u32 { + self.texture.header.height + } + + /// Pixel format. + #[must_use] + pub fn format(&self) -> PixelFormat { + map_format(self.texture.header.format) + } + + /// Mip count. + #[must_use] + pub fn mip_count(&self) -> usize { + self.texture.mip_levels.len() + } + + /// Returns a borrowed mip view. + /// + /// # Errors + /// + /// Returns [`TexmError`] when `level` is outside the mip chain or the stored + /// range is outside the document. + pub fn mip_level(&self, level: u32) -> Result, TexmError> { + let requested = usize::try_from(level).map_err(|_| TexmError::MipLevelOutOfRange { + requested: level, + mip_count: self.texture.mip_levels.len(), + })?; + let mip = self + .texture + .mip_levels + .get(requested) + .ok_or(TexmError::MipLevelOutOfRange { + requested: level, + mip_count: self.texture.mip_levels.len(), + })?; + let end = mip + .offset + .checked_add(mip.size) + .ok_or(TexmError::MipDataOutOfBounds { + offset: mip.offset, + size: mip.size, + document_size: self.bytes.len(), + })?; + let bytes = self + .bytes + .get(mip.offset..end) + .ok_or(TexmError::MipDataOutOfBounds { + offset: mip.offset, + size: mip.size, + document_size: self.bytes.len(), + })?; + Ok(MipLevelView { + level, + width: mip.width, + height: mip.height, + bytes, + }) + } + + /// Page rectangles. + #[must_use] + pub fn page_rects(&self) -> Vec { + self.texture + .page_rects + .iter() + .copied() + .map(map_page_rect) + .collect() + } +} + +fn map_format(format: DiskPixelFormat) -> PixelFormat { + match format { + DiskPixelFormat::Indexed8 => PixelFormat::Indexed8, + DiskPixelFormat::Rgb565 => PixelFormat::Rgb565, + DiskPixelFormat::Rgb556 => PixelFormat::Rgb556, + DiskPixelFormat::Argb4444 => PixelFormat::Argb4444, + DiskPixelFormat::L8A8 => PixelFormat::L8A8, + DiskPixelFormat::Rgb888x => PixelFormat::Rgb888x, + DiskPixelFormat::Argb8888 => PixelFormat::Argb8888, + } +} + +fn map_page_rect(rect: DiskPageRect) -> PageRect { + PageRect { + x: rect.x, + w: rect.w, + y: rect.y, + h: rect.h, + } +} + +fn scale_page_rect( + source_width: u32, + source_height: u32, + target_width: u32, + target_height: u32, + rect: DiskPageRect, + policy: PageScalePolicy, +) -> Result { + match policy { + PageScalePolicy::FloorOriginCeilEnd => { + let x0 = scale_floor(rect.x, target_width, source_width)?; + let y0 = scale_floor(rect.y, target_height, source_height)?; + let x1 = scale_ceil( + rect.x + .checked_add(rect.w) + .ok_or_else(integer_overflow_error)?, + target_width, + source_width, + )?; + let y1 = scale_ceil( + rect.y + .checked_add(rect.h) + .ok_or_else(integer_overflow_error)?, + target_height, + source_height, + )?; + Ok(PageRect { + x: x0, + w: checked_i16(i32::from(x1) - i32::from(x0))?, + y: y0, + h: checked_i16(i32::from(y1) - i32::from(y0))?, + }) + } + } +} + +fn scale_floor(value: i16, numerator: u32, denominator: u32) -> Result { + checked_i16(div_floor( + i64::from(value) * i64::from(numerator), + i64::from(denominator), + )?) +} + +fn scale_ceil(value: i16, numerator: u32, denominator: u32) -> Result { + checked_i16(div_ceil( + i64::from(value) * i64::from(numerator), + i64::from(denominator), + )?) +} + +fn div_floor(value: i64, divisor: i64) -> Result { + let result = if value >= 0 { + value / divisor + } else { + -((-value + divisor - 1) / divisor) + }; + i32::try_from(result).map_err(|_| integer_overflow_error()) +} + +fn div_ceil(value: i64, divisor: i64) -> Result { + let result = if value >= 0 { + (value + divisor - 1) / divisor + } else { + -((-value) / divisor) + }; + i32::try_from(result).map_err(|_| integer_overflow_error()) +} + +fn checked_i16(value: i32) -> Result { + i16::try_from(value) + .map_err(|_| TexmError::Format(format!("scaled Page rect value out of range: {value}"))) +} + +fn parse_texm(payload: &[u8]) -> Result { + if payload.len() < 32 { + return Err(TexmError::Format(format!( + "Texm payload too small for header: {}", + payload.len() + ))); + } + + let magic = read_u32(payload, 0)?; + if magic != TEXM_MAGIC { + return Err(TexmError::Format(format!( + "invalid Texm magic: 0x{magic:08X}" + ))); + } + + let width = read_u32(payload, 4)?; + let height = read_u32(payload, 8)?; + let mip_count = read_u32(payload, 12)?; + let format_raw = read_u32(payload, 28)?; + + if width == 0 || height == 0 { + return Err(TexmError::Format(format!( + "invalid Texm dimensions: {width}x{height}" + ))); + } + if mip_count == 0 { + return Err(TexmError::Format(format!( + "invalid Texm mip_count={mip_count}" + ))); + } + + let format = DiskPixelFormat::from_raw(format_raw) + .ok_or_else(|| TexmError::Format(format!("unknown Texm format={format_raw}")))?; + let bytes_per_pixel = format.bytes_per_pixel(); + + let mut offset = 32usize; + let palette = if format == DiskPixelFormat::Indexed8 { + let end = offset + .checked_add(1024) + .ok_or_else(integer_overflow_error)?; + if end > payload.len() { + return Err(TexmError::Format(format!( + "Texm core data out of bounds: expected_end={end}, actual_size={}", + payload.len() + ))); + } + let mut pal = [0u8; 1024]; + pal.copy_from_slice(&payload[offset..end]); + offset = end; + Some(pal) + } else { + None + }; + + let mut mip_levels = + Vec::with_capacity(usize::try_from(mip_count).map_err(|_| integer_overflow_error())?); + let mut w = width; + let mut h = height; + for _ in 0..mip_count { + let pixel_count = u64::from(w) + .checked_mul(u64::from(h)) + .ok_or_else(integer_overflow_error)?; + let level_size_u64 = pixel_count + .checked_mul(u64::try_from(bytes_per_pixel).map_err(|_| integer_overflow_error())?) + .ok_or_else(integer_overflow_error)?; + let level_size = usize::try_from(level_size_u64).map_err(|_| integer_overflow_error())?; + let level_offset = offset; + offset = offset + .checked_add(level_size) + .ok_or_else(integer_overflow_error)?; + if offset > payload.len() { + return Err(TexmError::Format(format!( + "Texm core data out of bounds: expected_end={offset}, actual_size={}", + payload.len() + ))); + } + mip_levels.push(MipLevel { + width: w, + height: h, + offset: level_offset, + size: level_size, + }); + w = (w >> 1).max(1); + h = (h >> 1).max(1); + } + + let page_rects = parse_page_tail(payload, offset)?; + + Ok(Texture { + header: Header { + width, + height, + format, + }, + palette, + mip_levels, + page_rects, + }) +} + +fn decode_mip_rgba8_internal( + texture: &Texture, + payload: &[u8], + mip_index: usize, +) -> Result { + let Some(level) = texture.mip_levels.get(mip_index).copied() else { + return Err(TexmError::MipLevelOutOfRange { + requested: u32::try_from(mip_index).unwrap_or(u32::MAX), + mip_count: texture.mip_levels.len(), + }); + }; + + let end = level + .offset + .checked_add(level.size) + .ok_or(TexmError::MipDataOutOfBounds { + offset: level.offset, + size: level.size, + document_size: payload.len(), + })?; + let Some(level_data) = payload.get(level.offset..end) else { + return Err(TexmError::MipDataOutOfBounds { + offset: level.offset, + size: level.size, + document_size: payload.len(), + }); + }; + + let width = usize::try_from(level.width).map_err(|_| integer_overflow_error())?; + let height = usize::try_from(level.height).map_err(|_| integer_overflow_error())?; + let pixel_count = width + .checked_mul(height) + .ok_or_else(integer_overflow_error)?; + let mut rgba = vec![0u8; pixel_count.saturating_mul(4)]; + + match texture.header.format { + DiskPixelFormat::Indexed8 => { + let palette = texture + .palette + .as_ref() + .ok_or_else(|| TexmError::Format("indexed Texm has no palette".to_string()))?; + for (index, palette_index) in level_data.iter().copied().enumerate().take(pixel_count) { + let palette_offset = usize::from(palette_index).saturating_mul(4); + if palette_offset + 4 > palette.len() { + continue; + } + let out = index.saturating_mul(4); + rgba[out] = palette[palette_offset]; + rgba[out + 1] = palette[palette_offset + 1]; + rgba[out + 2] = palette[palette_offset + 2]; + rgba[out + 3] = palette[palette_offset + 3]; + } + } + DiskPixelFormat::Rgb565 => decode_words(level_data, pixel_count, &mut rgba, decode_rgb565), + DiskPixelFormat::Rgb556 => decode_words(level_data, pixel_count, &mut rgba, decode_rgb556), + DiskPixelFormat::Argb4444 => { + decode_words(level_data, pixel_count, &mut rgba, decode_argb4444); + } + DiskPixelFormat::L8A8 => { + decode_words(level_data, pixel_count, &mut rgba, decode_luminance_alpha88); + } + DiskPixelFormat::Rgb888x => { + decode_dwords(level_data, pixel_count, &mut rgba, decode_rgb888x); + } + DiskPixelFormat::Argb8888 => { + decode_dwords(level_data, pixel_count, &mut rgba, decode_argb8888); + } + } + + Ok(DecodedMip { + width: level.width, + height: level.height, + rgba8: rgba, + }) +} + +fn parse_page_tail(payload: &[u8], core_end: usize) -> Result, TexmError> { + if core_end == payload.len() { + return Ok(Vec::new()); + } + if payload.len().saturating_sub(core_end) < 8 { + return Err(TexmError::Format(format!( + "invalid Page chunk size: expected=8, actual={}", + payload.len().saturating_sub(core_end) + ))); + } + let magic = read_u32(payload, core_end)?; + if magic != PAGE_MAGIC { + return Err(TexmError::Format( + "Texm tail exists but Page magic is missing".to_string(), + )); + } + let rect_count = read_u32(payload, core_end + 4)?; + let rect_count_usize = usize::try_from(rect_count).map_err(|_| integer_overflow_error())?; + let expected_size = 8usize + .checked_add( + rect_count_usize + .checked_mul(8) + .ok_or_else(integer_overflow_error)?, + ) + .ok_or_else(integer_overflow_error)?; + let actual = payload.len().saturating_sub(core_end); + if expected_size != actual { + return Err(TexmError::Format(format!( + "invalid Page chunk size: expected={expected_size}, actual={actual}" + ))); + } + + let mut rects = Vec::with_capacity(rect_count_usize); + for index in 0..rect_count_usize { + let offset = core_end + .checked_add(8) + .and_then(|value| value.checked_add(index * 8)) + .ok_or_else(integer_overflow_error)?; + rects.push(DiskPageRect { + x: read_i16(payload, offset)?, + w: read_i16(payload, offset + 2)?, + y: read_i16(payload, offset + 4)?, + h: read_i16(payload, offset + 6)?, + }); + } + Ok(rects) +} + +fn read_u32(data: &[u8], offset: usize) -> Result { + let bytes = data + .get(offset..offset + 4) + .ok_or_else(integer_overflow_error)?; + let arr: [u8; 4] = bytes.try_into().map_err(|_| integer_overflow_error())?; + Ok(u32::from_le_bytes(arr)) +} + +fn read_i16(data: &[u8], offset: usize) -> Result { + let bytes = data + .get(offset..offset + 2) + .ok_or_else(integer_overflow_error)?; + let arr: [u8; 2] = bytes.try_into().map_err(|_| integer_overflow_error())?; + Ok(i16::from_le_bytes(arr)) +} + +fn decode_words(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u16) -> [u8; 4]) { + for index in 0..pixel_count { + let offset = index.saturating_mul(2); + let Some(bytes) = data.get(offset..offset + 2) else { + break; + }; + let word = u16::from_le_bytes([bytes[0], bytes[1]]); + let pixel = decode(word); + let out = index.saturating_mul(4); + rgba[out..out + 4].copy_from_slice(&pixel); + } +} + +fn decode_dwords(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u32) -> [u8; 4]) { + for index in 0..pixel_count { + let offset = index.saturating_mul(4); + let Some(bytes) = data.get(offset..offset + 4) else { + break; + }; + let dword = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + let pixel = decode(dword); + let out = index.saturating_mul(4); + rgba[out..out + 4].copy_from_slice(&pixel); + } +} + +fn expand5(value: u16) -> u8 { + u8::try_from((u32::from(value) * 255 + 15) / 31).unwrap_or(u8::MAX) +} + +fn expand6(value: u16) -> u8 { + u8::try_from((u32::from(value) * 255 + 31) / 63).unwrap_or(u8::MAX) +} + +fn expand4(value: u16) -> u8 { + u8::try_from(u32::from(value) * 17).unwrap_or(u8::MAX) +} + +fn decode_rgb565(word: u16) -> [u8; 4] { + let red = expand5((word >> 11) & 0x1F); + let green = expand6((word >> 5) & 0x3F); + let blue = expand5(word & 0x1F); + [red, green, blue, 255] +} + +fn decode_rgb556(word: u16) -> [u8; 4] { + let red = expand5((word >> 11) & 0x1F); + let green = expand5((word >> 6) & 0x1F); + let blue = expand6(word & 0x3F); + [red, green, blue, 255] +} + +fn decode_argb4444(word: u16) -> [u8; 4] { + let alpha = expand4((word >> 12) & 0x0F); + let red = expand4((word >> 8) & 0x0F); + let green = expand4((word >> 4) & 0x0F); + let blue = expand4(word & 0x0F); + [red, green, blue, alpha] +} + +fn decode_luminance_alpha88(word: u16) -> [u8; 4] { + let luminance = u8::try_from((word >> 8) & 0xFF).unwrap_or(u8::MAX); + let alpha = u8::try_from(word & 0xFF).unwrap_or(u8::MAX); + [luminance, luminance, luminance, alpha] +} + +fn decode_rgb888x(dword: u32) -> [u8; 4] { + let red = u8::try_from(dword & 0xFF).unwrap_or(u8::MAX); + let green = u8::try_from((dword >> 8) & 0xFF).unwrap_or(u8::MAX); + let blue = u8::try_from((dword >> 16) & 0xFF).unwrap_or(u8::MAX); + [red, green, blue, 255] +} + +fn decode_argb8888(dword: u32) -> [u8; 4] { + let alpha = u8::try_from(dword & 0xFF).unwrap_or(u8::MAX); + let red = u8::try_from((dword >> 8) & 0xFF).unwrap_or(u8::MAX); + let green = u8::try_from((dword >> 16) & 0xFF).unwrap_or(u8::MAX); + let blue = u8::try_from((dword >> 24) & 0xFF).unwrap_or(u8::MAX); + [red, green, blue, alpha] +} + +fn integer_overflow_error() -> TexmError { + TexmError::Format("integer overflow".to_string()) +} + +/// Returns migration status. +#[must_use] +pub fn migration_facade_ready() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_nres::ReadProfile; + use std::path::{Path, PathBuf}; + + const TEXM_MAGIC: u32 = 0x6D78_6554; + + #[test] + fn decodes_all_synthetic_formats() { + let cases = [ + (0, PixelFormat::Indexed8, indexed_payload()), + ( + 565, + PixelFormat::Rgb565, + payload(1, 1, 565, &[&0xFFE0_u16.to_le_bytes()]), + ), + ( + 556, + PixelFormat::Rgb556, + payload(1, 1, 556, &[&0xF800_u16.to_le_bytes()]), + ), + ( + 4444, + PixelFormat::Argb4444, + payload(1, 1, 4444, &[&0xF12E_u16.to_le_bytes()]), + ), + ( + 88, + PixelFormat::L8A8, + payload(1, 1, 88, &[&0x7F40_u16.to_le_bytes()]), + ), + ( + 888, + PixelFormat::Rgb888x, + payload(1, 1, 888, &[&[0x11, 0x22, 0x33, 0x99]]), + ), + ( + 8888, + PixelFormat::Argb8888, + payload(1, 1, 8888, &[&[0x40, 0x11, 0x22, 0x33]]), + ), + ]; + + for (raw, expected, bytes) in cases { + let document = decode_texm(Arc::from(bytes.into_boxed_slice())) + .unwrap_or_else(|err| panic!("format {raw}: {err}")); + assert_eq!(document.format(), expected); + assert_eq!(document.mip_count(), 1); + let rgba = + decode_mip_rgba8(&document, 0).unwrap_or_else(|err| panic!("format {raw}: {err}")); + assert_eq!(rgba.width, 1); + assert_eq!(rgba.height, 1); + assert_eq!(rgba.rgba8.len(), 4); + } + } + + #[test] + fn rejects_zero_dimensions() { + let err = decode_texm(Arc::from( + payload(0, 1, 8888, &[&[0, 0, 0, 0]]).into_boxed_slice(), + )) + .expect_err("zero width"); + assert!(matches!(err, TexmError::Format(_))); + } + + #[test] + fn non_power_of_two_mip_chain_clamps_each_dimension() { + let bytes = payload(3, 2, 8888, &[&[0; 3 * 2 * 4], &[1, 2, 3, 4], &[5, 6, 7, 8]]); + let document = decode_texm(Arc::from(bytes.into_boxed_slice())).expect("document"); + + assert_eq!(document.mip_level(0).expect("mip 0").width, 3); + assert_eq!(document.mip_level(0).expect("mip 0").height, 2); + assert_eq!(document.mip_level(1).expect("mip 1").width, 1); + assert_eq!(document.mip_level(1).expect("mip 1").height, 1); + assert_eq!(document.mip_level(2).expect("mip 2").width, 1); + assert_eq!(document.mip_level(2).expect("mip 2").height, 1); + } + + #[test] + fn rejects_mip_size_arithmetic_overflow_or_oob() { + let err = decode_texm(Arc::from( + header(u32::MAX, u32::MAX, 1, 8888).into_boxed_slice(), + )) + .expect_err("huge mip"); + + assert!(matches!(err, TexmError::Format(_))); + } + + #[test] + fn indexed_palette_requires_exact_1024_bytes() { + let mut bytes = indexed_payload(); + bytes.remove(32 + 1023); + + let err = decode_texm(Arc::from(bytes.into_boxed_slice())).expect_err("short palette"); + + assert!(matches!(err, TexmError::Format(_))); + } + + #[test] + fn channel_expansion_boundary_values_are_stable() { + let document = decode_texm(Arc::from( + payload(2, 1, 565, &[&[0x00, 0x00, 0xFF, 0xFF]]).into_boxed_slice(), + )) + .expect("rgb565 document"); + let rgba = decode_mip_rgba8(&document, 0).expect("rgba"); + + assert_eq!(rgba.rgba8, vec![0, 0, 0, 255, 255, 255, 255, 255]); + } + + #[test] + fn rgb888x_preserves_fourth_disk_byte_but_outputs_opaque_alpha() { + let document = decode_texm(Arc::from( + payload(1, 1, 888, &[&[0x11, 0x22, 0x33, 0x99]]).into_boxed_slice(), + )) + .expect("rgb888x document"); + + assert_eq!( + document.mip_level(0).expect("mip").bytes, + &[0x11, 0x22, 0x33, 0x99] + ); + assert_eq!( + decode_mip_rgba8(&document, 0).expect("rgba").rgba8, + vec![0x11, 0x22, 0x33, 0xFF] + ); + } + + #[test] + fn page_tail_absent_and_exact_rect_framing() { + let absent = decode_texm(Arc::from( + payload(1, 1, 8888, &[&[0, 0, 0, 0]]).into_boxed_slice(), + )) + .expect("page absent"); + assert!(absent.page_rects().is_empty()); + + let mut bytes = payload(1, 1, 8888, &[&[0, 0, 0, 0]]); + push_page_tail(&mut bytes, &[(1, 2, 3, 4)]); + let document = decode_texm(Arc::from(bytes.into_boxed_slice())).expect("page rect"); + + assert_eq!( + document.page_rects(), + vec![PageRect { + x: 1, + w: 2, + y: 3, + h: 4, + }] + ); + } + + #[test] + fn invalid_page_magic_size_and_trailing_bytes_are_rejected() { + let mut missing_magic = payload(1, 1, 8888, &[&[0, 0, 0, 0]]); + missing_magic.extend_from_slice(b"tail"); + assert!(decode_texm(Arc::from(missing_magic.into_boxed_slice())).is_err()); + + let mut wrong_size = payload(1, 1, 8888, &[&[0, 0, 0, 0]]); + wrong_size.extend_from_slice(&PAGE_MAGIC.to_le_bytes()); + wrong_size.extend_from_slice(&2_u32.to_le_bytes()); + wrong_size.extend_from_slice(&[0; 8]); + assert!(decode_texm(Arc::from(wrong_size.into_boxed_slice())).is_err()); + } + + #[test] + fn exposes_mip_views_and_upload_plan_without_mutating_document() { + let bytes = payload(2, 1, 8888, &[&[1, 2, 3, 4, 5, 6, 7, 8], &[9, 10, 11, 12]]); + let original = bytes.clone(); + let document = decode_texm(Arc::from(bytes.into_boxed_slice())).expect("document"); + + let mip1 = document.mip_level(1).expect("mip 1"); + assert_eq!(mip1.width, 1); + assert_eq!(mip1.height, 1); + assert_eq!(mip1.bytes, &[9, 10, 11, 12]); + let plan = plan_upload(&document, MipSkipPolicy { skip_top_levels: 1 }).expect("plan"); + assert_eq!(plan.mips.len(), 1); + assert_eq!(plan.mips[0].level, 1); + assert_eq!(&document.bytes[..], &original[..]); + } + + #[test] + fn page_scaling_uses_floor_origin_and_ceil_end_policy() { + let mut bytes = payload(5, 3, 8888, &[&[0; 5 * 3 * 4], &[0; 2 * 1 * 4]]); + push_page_tail(&mut bytes, &[(1, 3, 1, 2)]); + let document = decode_texm(Arc::from(bytes.into_boxed_slice())).expect("document"); + + assert_eq!( + scaled_page_rects(&document, 1, PageScalePolicy::FloorOriginCeilEnd).expect("scaled"), + vec![PageRect { + x: 0, + w: 2, + y: 0, + h: 1, + }] + ); + assert_eq!( + plan_upload(&document, MipSkipPolicy { skip_top_levels: 1 }) + .expect("plan") + .page_rects, + vec![PageRect { + x: 1, + w: 3, + y: 1, + h: 2, + }] + ); + } + + #[test] + fn arbitrary_texm_payloads_do_not_panic() { + for len in 0..128usize { + let mut bytes = vec![0xCC; len]; + if len >= 4 { + bytes[0..4].copy_from_slice(&TEXM_MAGIC.to_le_bytes()); + } + let result = std::panic::catch_unwind(|| { + let _ = decode_texm(Arc::from(bytes.into_boxed_slice())); + }); + assert!(result.is_ok()); + } + } + + #[test] + fn licensed_corpus_texm_assets_validate_and_decode_mip0() { + for (corpus, expected) in [("IS", 518_usize), ("IS2", 631_usize)] { + let Some(root) = corpus_root(corpus) else { + continue; + }; + let mut count = 0usize; + for path in files_under(&root) { + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let Ok(archive) = fparkan_nres::decode( + Arc::from(bytes.into_boxed_slice()), + ReadProfile::Compatible, + ) else { + continue; + }; + for entry in archive + .entries() + .iter() + .filter(|entry| entry.meta().type_id == TEXM_MAGIC) + { + let payload = archive.payload(entry.id()).expect("payload"); + let document = decode_texm(Arc::from(payload.to_vec().into_boxed_slice())) + .unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) + }); + decode_mip_rgba8(&document, 0).unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) + }); + count += 1; + } + } + assert_eq!(count, expected, "{corpus} Texm count"); + } + } + + fn indexed_payload() -> Vec { + let mut palette = [0_u8; 1024]; + palette[4..8].copy_from_slice(&[10, 20, 30, 255]); + let mut out = header(1, 1, 1, 0); + out.extend_from_slice(&palette); + out.push(1); + out + } + + fn payload(width: u32, height: u32, format: u32, mip_levels: &[&[u8]]) -> Vec { + let mut out = header( + width, + height, + u32::try_from(mip_levels.len()).expect("mip count"), + format, + ); + for level in mip_levels { + out.extend_from_slice(level); + } + out + } + + fn header(width: u32, height: u32, mip_count: u32, format: u32) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&TEXM_MAGIC.to_le_bytes()); + out.extend_from_slice(&width.to_le_bytes()); + out.extend_from_slice(&height.to_le_bytes()); + out.extend_from_slice(&mip_count.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(&format.to_le_bytes()); + out + } + + fn push_page_tail(out: &mut Vec, rects: &[(i16, i16, i16, i16)]) { + out.extend_from_slice(&PAGE_MAGIC.to_le_bytes()); + out.extend_from_slice( + &u32::try_from(rects.len()) + .expect("rect count") + .to_le_bytes(), + ); + for (x, w, y, h) in rects { + out.extend_from_slice(&x.to_le_bytes()); + out.extend_from_slice(&w.to_le_bytes()); + out.extend_from_slice(&y.to_le_bytes()); + out.extend_from_slice(&h.to_le_bytes()); + } + } + + fn corpus_root(name: &str) -> Option { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("testdata") + .join(name); + root.is_dir().then_some(root) + } + + fn files_under(root: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(path) = stack.pop() { + let Ok(read_dir) = std::fs::read_dir(path) else { + continue; + }; + for entry in read_dir.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + out.push(path); + } + } + } + out.sort(); + out + } +} diff --git a/crates/fparkan-vfs/Cargo.toml b/crates/fparkan-vfs/Cargo.toml new file mode 100644 index 0000000..90239c2 --- /dev/null +++ b/crates/fparkan-vfs/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fparkan-vfs" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-path = { path = "../fparkan-path" } + +[lints] +workspace = true diff --git a/crates/fparkan-vfs/src/lib.rs b/crates/fparkan-vfs/src/lib.rs new file mode 100644 index 0000000..dd71670 --- /dev/null +++ b/crates/fparkan-vfs/src/lib.rs @@ -0,0 +1,456 @@ +#![forbid(unsafe_code)] +//! Virtual filesystem ports for resource loading. + +use fparkan_path::{join_under, NormalizedPath}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// VFS metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VfsMetadata { + /// Byte length. + pub len: u64, + /// Stable-enough source fingerprint for cache invalidation. + pub fingerprint: u64, +} + +/// VFS entry. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VfsEntry { + /// Path. + pub path: NormalizedPath, + /// Metadata. + pub metadata: VfsMetadata, +} + +/// VFS error. +#[derive(Debug)] +pub enum VfsError { + /// Missing entry. + NotFound(String), + /// Ambiguous host path. + Ambiguous(String), + /// I/O error. + Io(std::io::Error), + /// Invalid path. + Path, +} + +impl std::fmt::Display for VfsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotFound(path) => write!(f, "not found: {path}"), + Self::Ambiguous(path) => write!(f, "ambiguous host path: {path}"), + Self::Io(err) => write!(f, "{err}"), + Self::Path => write!(f, "invalid path"), + } + } +} + +impl std::error::Error for VfsError {} + +/// Resource VFS. +pub trait Vfs: Send + Sync { + /// Reads metadata. + /// + /// # Errors + /// + /// Returns [`VfsError`] when the path is invalid, missing, or cannot be + /// inspected by the backing store. + fn metadata(&self, path: &NormalizedPath) -> Result; + /// Reads bytes. + /// + /// # Errors + /// + /// Returns [`VfsError`] when the path is invalid, missing, or cannot be + /// read by the backing store. + fn read(&self, path: &NormalizedPath) -> Result, VfsError>; + /// Lists entries below prefix. + /// + /// # Errors + /// + /// Returns [`VfsError`] when the prefix is invalid, missing, or cannot be + /// traversed by the backing store. + fn list(&self, prefix: &NormalizedPath) -> Result, VfsError>; +} + +/// Host directory VFS. +#[derive(Clone, Debug)] +pub struct DirectoryVfs { + root: PathBuf, +} + +impl DirectoryVfs { + /// Creates a directory VFS. + #[must_use] + pub fn new(root: impl AsRef) -> Self { + Self { + root: root.as_ref().to_path_buf(), + } + } + + fn host_path(&self, path: &NormalizedPath) -> Result { + let exact = join_under(&self.root, path).map_err(|_| VfsError::Path)?; + if exact.exists() { + return Ok(exact); + } + resolve_casefolded(&self.root, path.as_str()) + } +} + +impl Vfs for DirectoryVfs { + fn metadata(&self, path: &NormalizedPath) -> Result { + let meta = fs::metadata(self.host_path(path)?).map_err(VfsError::Io)?; + Ok(metadata_from_fs(&meta)) + } + + fn read(&self, path: &NormalizedPath) -> Result, VfsError> { + let bytes = fs::read(self.host_path(path)?).map_err(VfsError::Io)?; + Ok(Arc::from(bytes.into_boxed_slice())) + } + + fn list(&self, prefix: &NormalizedPath) -> Result, VfsError> { + let base = self.host_path(prefix)?; + let mut entries = Vec::new(); + if base.is_file() { + let metadata = fs::metadata(&base).map_err(VfsError::Io)?; + entries.push(VfsEntry { + path: prefix.clone(), + metadata: metadata_from_fs(&metadata), + }); + return Ok(entries); + } + list_recursive(&self.root, &base, &mut entries)?; + entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str())); + Ok(entries) + } +} + +fn resolve_casefolded(root: &Path, normalized: &str) -> Result { + let mut current = root.to_path_buf(); + for segment in normalized.split('/') { + let read_dir = fs::read_dir(¤t).map_err(VfsError::Io)?; + let mut matches = Vec::new(); + for entry in read_dir { + let entry = entry.map_err(VfsError::Io)?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if name.eq_ignore_ascii_case(segment) { + matches.push(entry.path()); + } + } + current = select_casefolded_match(normalized, ¤t, segment, matches)?; + } + Ok(current) +} + +fn select_casefolded_match( + normalized: &str, + current: &Path, + segment: &str, + mut matches: Vec, +) -> Result { + matches.sort(); + match matches.len() { + 0 => Err(VfsError::NotFound(normalized.to_string())), + 1 => Ok(matches.remove(0)), + _ => Err(VfsError::Ambiguous(format!( + "{}/{}", + current.display(), + segment + ))), + } +} + +fn list_recursive(root: &Path, dir: &Path, out: &mut Vec) -> Result<(), VfsError> { + let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?; + let mut children = Vec::new(); + for entry in read_dir { + let entry = entry.map_err(VfsError::Io)?; + children.push(entry.path()); + } + children.sort(); + for child in children { + let metadata = fs::metadata(&child).map_err(VfsError::Io)?; + if metadata.is_dir() { + list_recursive(root, &child, out)?; + continue; + } + if !metadata.is_file() { + continue; + } + let rel = child.strip_prefix(root).map_err(|_| VfsError::Path)?; + let rel_text = rel.to_str().ok_or(VfsError::Path)?; + let path = fparkan_path::normalize_relative( + rel_text.as_bytes(), + fparkan_path::PathPolicy::HostCompatible, + ) + .map_err(|_| VfsError::Path)?; + out.push(VfsEntry { + path, + metadata: metadata_from_fs(&metadata), + }); + } + Ok(()) +} + +fn metadata_from_fs(metadata: &fs::Metadata) -> VfsMetadata { + let mut fingerprint = 0xcbf2_9ce4_8422_2325; + hash_u64(&mut fingerprint, metadata.len()); + if let Ok(modified) = metadata.modified() { + if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) { + hash_u64(&mut fingerprint, duration.as_secs()); + hash_u64(&mut fingerprint, u64::from(duration.subsec_nanos())); + } + } + VfsMetadata { + len: metadata.len(), + fingerprint, + } +} + +/// In-memory VFS. +#[derive(Clone, Debug, Default)] +pub struct MemoryVfs { + files: BTreeMap>, +} + +impl MemoryVfs { + /// Inserts a file. + #[allow(clippy::needless_pass_by_value)] + pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) { + self.files.insert(path.as_str().to_string(), bytes); + } +} + +impl Vfs for MemoryVfs { + fn metadata(&self, path: &NormalizedPath) -> Result { + let bytes = self + .files + .get(path.as_str()) + .ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?; + Ok(VfsMetadata { + len: bytes.len() as u64, + fingerprint: stable_hash(bytes), + }) + } + + fn read(&self, path: &NormalizedPath) -> Result, VfsError> { + self.files + .get(path.as_str()) + .cloned() + .ok_or_else(|| VfsError::NotFound(path.as_str().to_string())) + } + + fn list(&self, prefix: &NormalizedPath) -> Result, VfsError> { + let mut out = Vec::new(); + for (path, bytes) in &self.files { + if path + .as_bytes() + .get(..prefix.as_str().len()) + .is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_str().as_bytes())) + { + let normalized = fparkan_path::normalize_relative( + path.as_bytes(), + fparkan_path::PathPolicy::StrictLegacy, + ) + .map_err(|_| VfsError::Path)?; + out.push(VfsEntry { + path: normalized, + metadata: VfsMetadata { + len: bytes.len() as u64, + fingerprint: stable_hash(bytes), + }, + }); + } + } + Ok(out) + } +} + +fn stable_hash(bytes: &[u8]) -> u64 { + let mut state = 0xcbf2_9ce4_8422_2325; + for byte in bytes { + state ^= u64::from(*byte); + state = state.wrapping_mul(0x0000_0100_0000_01b3); + } + state +} + +fn hash_u64(state: &mut u64, value: u64) { + for byte in value.to_le_bytes() { + *state ^= u64::from(byte); + *state = state.wrapping_mul(0x0000_0100_0000_01b3); + } +} + +/// Layered VFS with deterministic first-layer precedence. +#[derive(Clone, Default)] +pub struct OverlayVfs { + layers: Vec>, +} + +impl std::fmt::Debug for OverlayVfs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OverlayVfs") + .field("layers", &self.layers.len()) + .finish() + } +} + +impl OverlayVfs { + /// Creates an empty overlay. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Creates an overlay from ordered layers. + #[must_use] + pub fn from_layers(layers: Vec>) -> Self { + Self { layers } + } + + /// Appends a lower-priority layer. + pub fn push_layer(&mut self, layer: Arc) { + self.layers.push(layer); + } +} + +impl Vfs for OverlayVfs { + fn metadata(&self, path: &NormalizedPath) -> Result { + for layer in &self.layers { + match layer.metadata(path) { + Ok(metadata) => return Ok(metadata), + Err(VfsError::NotFound(_)) => {} + Err(err) => return Err(err), + } + } + Err(VfsError::NotFound(path.as_str().to_string())) + } + + fn read(&self, path: &NormalizedPath) -> Result, VfsError> { + for layer in &self.layers { + match layer.read(path) { + Ok(bytes) => return Ok(bytes), + Err(VfsError::NotFound(_)) => {} + Err(err) => return Err(err), + } + } + Err(VfsError::NotFound(path.as_str().to_string())) + } + + fn list(&self, prefix: &NormalizedPath) -> Result, VfsError> { + let mut by_key = BTreeMap::new(); + for layer in &self.layers { + match layer.list(prefix) { + Ok(entries) => { + for entry in entries { + let key = entry.path.as_str().to_ascii_uppercase(); + by_key.entry(key).or_insert(entry); + } + } + Err(VfsError::NotFound(_)) => {} + Err(err) => return Err(err), + } + } + let mut entries: Vec<_> = by_key.into_values().collect(); + entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str())); + Ok(entries) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_path::{normalize_relative, PathPolicy}; + + #[test] + fn directory_vfs_resolves_ascii_casefolded_segments() { + let root = unique_test_dir("casefold"); + let dir = root.join("data").join("MAPS").join("Tut_1"); + std::fs::create_dir_all(&dir).expect("mkdir"); + std::fs::write(dir.join("Land.msh"), b"mesh").expect("write"); + + let vfs = DirectoryVfs::new(&root); + let path = normalize_relative(b"DATA/maps/tut_1/land.MSH", PathPolicy::StrictLegacy) + .expect("path"); + assert_eq!(vfs.read(&path).expect("read").as_ref(), b"mesh"); + + std::fs::remove_dir_all(root).expect("cleanup"); + } + + #[test] + fn directory_vfs_lists_files_below_prefix() { + let root = unique_test_dir("list"); + std::fs::create_dir_all(root.join("DATA").join("MAPS")).expect("mkdir"); + std::fs::write(root.join("DATA").join("MAPS").join("Land.map"), b"map").expect("write"); + std::fs::write(root.join("BuildDat.lst"), b"build").expect("write"); + + let vfs = DirectoryVfs::new(&root); + let prefix = normalize_relative(b"data", PathPolicy::StrictLegacy).expect("prefix"); + let entries = vfs.list(&prefix).expect("list"); + assert_eq!(entries.len(), 1); + assert!(entries[0] + .path + .as_str() + .eq_ignore_ascii_case("DATA/MAPS/Land.map")); + + std::fs::remove_dir_all(root).expect("cleanup"); + } + + #[test] + fn casefold_selector_reports_ambiguous_segments() { + let err = select_casefolded_match( + "data/file.bin", + Path::new("/game"), + "data", + vec![PathBuf::from("/game/Data"), PathBuf::from("/game/DATA")], + ) + .expect_err("ambiguous path"); + + assert!(matches!(err, VfsError::Ambiguous(_))); + } + + #[test] + fn memory_vfs_uses_exact_lookup() { + let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path"); + let mut vfs = MemoryVfs::default(); + vfs.insert(path.clone(), Arc::from(b"payload".as_slice())); + + assert_eq!(vfs.metadata(&path).expect("metadata").len, 7); + assert_eq!(vfs.read(&path).expect("read").as_ref(), b"payload"); + + let other_case = + normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("path"); + assert!(matches!(vfs.read(&other_case), Err(VfsError::NotFound(_)))); + } + + #[test] + fn overlay_vfs_uses_first_matching_layer() { + let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path"); + let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix"); + let mut high = MemoryVfs::default(); + let mut low = MemoryVfs::default(); + high.insert(path.clone(), Arc::from(b"high".as_slice())); + low.insert(path.clone(), Arc::from(b"low".as_slice())); + + let overlay = OverlayVfs::from_layers(vec![Arc::new(high), Arc::new(low)]); + + assert_eq!(overlay.read(&path).expect("read").as_ref(), b"high"); + let entries = overlay.list(&prefix).expect("list"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].metadata.len, 4); + } + + fn unique_test_dir(name: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("fparkan-vfs-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + path + } +} diff --git a/crates/fparkan-world/Cargo.toml b/crates/fparkan-world/Cargo.toml new file mode 100644 index 0000000..e336d07 --- /dev/null +++ b/crates/fparkan-world/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "fparkan-world" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] + +[lints] +workspace = true diff --git a/crates/fparkan-world/src/lib.rs b/crates/fparkan-world/src/lib.rs new file mode 100644 index 0000000..58412d9 --- /dev/null +++ b/crates/fparkan-world/src/lib.rs @@ -0,0 +1,840 @@ +#![forbid(unsafe_code)] +//! Deterministic world identity, queue, lifecycle, and snapshots. + +use std::collections::VecDeque; + +/// Object handle with generation. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct ObjectHandle { + /// Generation. + pub generation: u32, + /// Slot. + pub slot: u32, +} + +/// Original mission object id. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct OriginalObjectId(pub u32); + +/// Owner id. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct OwnerId(pub u16); + +/// Tick. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct Tick(pub u64); + +/// State hash. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct StateHash(pub [u8; 32]); + +/// World phase. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WorldPhase { + /// Idle. + Idle, + /// Calculating. + Calculating, + /// Applying deferred operations. + ApplyingDeferred, + /// Publishing snapshot. + PublishingSnapshot, +} + +/// Object draft. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ObjectDraft { + /// Original id. + pub original_id: Option, +} + +/// Distinct object identity metadata. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct IdentityMetadata { + /// Original mission object id. + pub original_id: Option, + /// Mirrored original id. + pub mirror_id: Option, + /// Local owner id. + pub owner_id: Option, +} + +/// World command. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WorldCommand { + /// Sequence. + pub sequence: u64, + /// Target. + pub target: Option, +} + +/// World event. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WorldEvent { + /// Sequence. + pub sequence: u64, + /// Target object, if any. + pub target: Option, +} + +/// Input snapshot. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct InputSnapshot; + +/// World snapshot. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WorldSnapshot { + /// Tick. + pub tick: Tick, + /// Live object handles. + pub objects: Vec, + /// Commands processed during this step. + pub events: Vec, + /// State hash. + pub hash: StateHash, +} + +/// World configuration. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct WorldConfig; + +/// Fixed-step clock state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FixedStepClock { + accumulated_millis: u64, + tick: Tick, + paused: bool, + platform_event_collections: u64, +} + +/// Fixed-step configuration. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FixedStepConfig { + /// Milliseconds per simulation tick. + pub step_millis: u32, +} + +impl Default for FixedStepConfig { + fn default() -> Self { + Self { step_millis: 16 } + } +} + +/// Shutdown ordering report. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ShutdownReport { + /// Object handles released before managers. + pub released_objects: Vec, + /// Whether managers were released after objects. + pub managers_released: bool, +} + +#[derive(Clone, Debug)] +struct Slot { + generation: u32, + live: bool, + registered: bool, + original_id: Option, + owner_id: Option, + mirror_id: Option, + registration_sequence: Option, +} + +/// World. +#[derive(Clone, Debug)] +pub struct World { + slots: Vec, + queue: VecDeque, + deferred_delete: Vec, + phase: WorldPhase, + tick: Tick, + next_sequence: u64, + next_registration_sequence: u64, +} + +/// World error. +#[derive(Debug, Eq, PartialEq)] +pub enum WorldError { + /// Invalid handle. + InvalidHandle, + /// Stale handle. + StaleHandle, + /// Object already deleted. + Deleted, + /// Duplicate original object id. + DuplicateOriginalObjectId(OriginalObjectId), + /// Invalid fixed-step configuration. + InvalidFixedStep, +} + +impl std::fmt::Display for WorldError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +impl std::error::Error for WorldError {} + +/// Creates a world. +#[must_use] +pub fn new(_config: WorldConfig) -> World { + World { + slots: Vec::new(), + queue: VecDeque::new(), + deferred_delete: Vec::new(), + phase: WorldPhase::Idle, + tick: Tick(0), + next_sequence: 0, + next_registration_sequence: 0, + } +} + +/// Constructs an object without registering it. +/// +/// # Errors +/// +/// Returns [`WorldError::InvalidHandle`] if the slot index cannot be +/// represented by an [`ObjectHandle`]. +pub fn construct_object(world: &mut World, draft: ObjectDraft) -> Result { + let slot = u32::try_from(world.slots.len()).map_err(|_| WorldError::InvalidHandle)?; + let handle = ObjectHandle { + generation: 1, + slot, + }; + world.slots.push(Slot { + generation: 1, + live: true, + registered: false, + original_id: draft.original_id, + owner_id: None, + mirror_id: None, + registration_sequence: None, + }); + Ok(handle) +} + +/// Registers a constructed object. +/// +/// # Errors +/// +/// Returns [`WorldError`] if the handle is stale, deleted, or out of range. +pub fn register_object(world: &mut World, handle: ObjectHandle) -> Result<(), WorldError> { + let original_id = checked_slot(world, handle)?.original_id; + if let Some(original_id) = original_id { + let duplicate = world.slots.iter().enumerate().any(|(idx, slot)| { + u32::try_from(idx).is_ok_and(|slot_index| slot_index != handle.slot) + && slot.live + && slot.registered + && slot.original_id == Some(original_id) + }); + if duplicate { + return Err(WorldError::DuplicateOriginalObjectId(original_id)); + } + } + let sequence = world.next_registration_sequence; + world.next_registration_sequence = world.next_registration_sequence.saturating_add(1); + let slot = checked_slot_mut(world, handle)?; + slot.registered = true; + slot.registration_sequence = Some(sequence); + Ok(()) +} + +/// Attaches local ownership metadata to an object. +/// +/// # Errors +/// +/// Returns [`WorldError`] if the handle is stale, deleted, or out of range. +pub fn set_owner( + world: &mut World, + handle: ObjectHandle, + owner_id: Option, +) -> Result<(), WorldError> { + checked_slot_mut(world, handle)?.owner_id = owner_id; + Ok(()) +} + +/// Attaches mirror metadata to an object without changing its original id. +/// +/// # Errors +/// +/// Returns [`WorldError`] if the handle is stale, deleted, or out of range. +pub fn set_mirror_original( + world: &mut World, + handle: ObjectHandle, + mirror_id: Option, +) -> Result<(), WorldError> { + checked_slot_mut(world, handle)?.mirror_id = mirror_id; + Ok(()) +} + +/// Returns registration sequence for a live object. +/// +/// # Errors +/// +/// Returns [`WorldError`] if the handle is stale, deleted, or out of range. +pub fn registration_sequence( + world: &World, + handle: ObjectHandle, +) -> Result, WorldError> { + Ok(checked_slot(world, handle)?.registration_sequence) +} + +/// Returns object identity metadata. +/// +/// # Errors +/// +/// Returns [`WorldError`] if the handle is stale, deleted, or out of range. +pub fn identity_metadata( + world: &World, + handle: ObjectHandle, +) -> Result { + let slot = checked_slot(world, handle)?; + Ok(IdentityMetadata { + original_id: slot.original_id, + mirror_id: slot.mirror_id, + owner_id: slot.owner_id, + }) +} + +/// Requests deletion. +/// +/// # Errors +/// +/// Returns [`WorldError`] if the handle is stale, deleted, or out of range. +pub fn request_delete(world: &mut World, handle: ObjectHandle) -> Result<(), WorldError> { + checked_slot(world, handle)?; + if world.phase == WorldPhase::Calculating { + if !world.deferred_delete.contains(&handle) { + world.deferred_delete.push(handle); + } + Ok(()) + } else { + delete_now(world, handle) + } +} + +/// Enqueues a command. +/// +/// # Errors +/// +/// Returns [`WorldError`] when a targeted command references an invalid +/// handle. +pub fn enqueue(world: &mut World, mut command: WorldCommand) -> Result<(), WorldError> { + if let Some(handle) = command.target { + checked_slot(world, handle)?; + } + command.sequence = world.next_sequence; + world.next_sequence = world.next_sequence.saturating_add(1); + world.queue.push_back(command); + Ok(()) +} + +/// Advances one deterministic step. +/// +/// # Errors +/// +/// Returns [`WorldError`] if a queued command references a stale, deleted, or +/// out-of-range handle. +pub fn step(world: &mut World, input: &InputSnapshot) -> Result { + step_with_handler(world, input, |_, _| Ok(())) +} + +/// Advances one deterministic step with a command callback. +/// +/// The callback runs while the world is in the calculating phase, which allows +/// tests and adapters to exercise deferred deletion semantics without exposing +/// mutable slot internals. +/// +/// # Errors +/// +/// Returns [`WorldError`] if a queued command references a stale, deleted, or +/// out-of-range handle, or if the callback reports a world error. +pub fn step_with_handler( + world: &mut World, + _input: &InputSnapshot, + mut handler: F, +) -> Result +where + F: FnMut(&mut World, &WorldCommand) -> Result<(), WorldError>, +{ + world.phase = WorldPhase::Calculating; + let mut events = Vec::new(); + while let Some(command) = world.queue.pop_front() { + if let Some(handle) = command.target { + if world.deferred_delete.contains(&handle) { + continue; + } + checked_slot(world, handle)?; + } + handler(world, &command)?; + events.push(WorldEvent { + sequence: command.sequence, + target: command.target, + }); + } + world.phase = WorldPhase::ApplyingDeferred; + 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. +#[must_use] +pub fn canonical_state_hash(world: &World) -> StateHash { + let mut state = 0xcbf2_9ce4_8422_2325_u64; + hash_u64(&mut state, world.tick.0); + for (idx, slot) in world.slots.iter().enumerate() { + hash_u64(&mut state, idx as u64); + hash_u64(&mut state, u64::from(slot.generation)); + hash_u64(&mut state, u64::from(u8::from(slot.live))); + hash_u64(&mut state, u64::from(u8::from(slot.registered))); + hash_u64(&mut state, slot.original_id.map_or(0, |id| u64::from(id.0))); + hash_u64(&mut state, slot.mirror_id.map_or(0, |id| u64::from(id.0))); + hash_u64(&mut state, slot.owner_id.map_or(0, |id| u64::from(id.0))); + hash_u64(&mut state, slot.registration_sequence.unwrap_or(u64::MAX)); + } + 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) +} + +/// Creates a fixed-step clock. +/// +/// # Errors +/// +/// Returns [`WorldError::InvalidFixedStep`] when the configured step is zero. +pub fn fixed_step_clock(config: FixedStepConfig) -> Result { + if config.step_millis == 0 { + return Err(WorldError::InvalidFixedStep); + } + Ok(FixedStepClock { + accumulated_millis: 0, + tick: Tick(0), + paused: false, + platform_event_collections: 0, + }) +} + +/// Records platform event collection independently of game time. +pub fn collect_platform_events(clock: &mut FixedStepClock) { + clock.platform_event_collections = clock.platform_event_collections.saturating_add(1); +} + +/// Sets pause state. +pub fn set_paused(clock: &mut FixedStepClock, paused: bool) { + clock.paused = paused; +} + +/// Advances fixed-step game time. +/// +/// Returns the number of simulation ticks that should be executed. +/// +/// # Errors +/// +/// Returns [`WorldError::InvalidFixedStep`] when the configured step is zero. +pub fn advance_fixed_step( + clock: &mut FixedStepClock, + config: FixedStepConfig, + elapsed_millis: u64, +) -> Result { + if config.step_millis == 0 { + return Err(WorldError::InvalidFixedStep); + } + if clock.paused { + return Ok(0); + } + clock.accumulated_millis = clock.accumulated_millis.saturating_add(elapsed_millis); + let step = u64::from(config.step_millis); + let mut ticks = 0_u32; + while clock.accumulated_millis >= step { + clock.accumulated_millis -= step; + clock.tick.0 = clock.tick.0.saturating_add(1); + ticks = ticks.saturating_add(1); + } + Ok(ticks) +} + +/// Returns fixed-step clock tick. +#[must_use] +pub fn fixed_step_tick(clock: &FixedStepClock) -> Tick { + clock.tick +} + +/// Returns platform event collection count. +#[must_use] +pub fn platform_event_collections(clock: &FixedStepClock) -> u64 { + clock.platform_event_collections +} + +/// Runs end-frame callbacks in stable sequence order. +#[must_use] +pub fn end_frame_callback_order(mut callbacks: Vec) -> Vec { + callbacks.sort_by_key(|event| event.sequence); + callbacks.into_iter().map(|event| event.sequence).collect() +} + +/// Releases live objects before managers. +#[must_use] +pub fn shutdown(mut world: World) -> ShutdownReport { + let released_objects = live_registered(&world); + for slot in &mut world.slots { + slot.live = false; + slot.registered = false; + slot.generation = slot.generation.saturating_add(1); + } + ShutdownReport { + released_objects, + managers_released: true, + } +} + +fn hash_u64(state: &mut u64, value: u64) { + for byte in value.to_le_bytes() { + *state ^= u64::from(byte); + *state = state.wrapping_mul(0x0000_0100_0000_01b3); + } +} + +fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> { + let slot = world + .slots + .get(handle.slot as usize) + .ok_or(WorldError::InvalidHandle)?; + if slot.generation != handle.generation { + return Err(WorldError::StaleHandle); + } + if !slot.live { + return Err(WorldError::Deleted); + } + Ok(slot) +} + +fn checked_slot_mut(world: &mut World, handle: ObjectHandle) -> Result<&mut Slot, WorldError> { + let slot = world + .slots + .get_mut(handle.slot as usize) + .ok_or(WorldError::InvalidHandle)?; + if slot.generation != handle.generation { + return Err(WorldError::StaleHandle); + } + if !slot.live { + return Err(WorldError::Deleted); + } + Ok(slot) +} + +fn delete_now(world: &mut World, handle: ObjectHandle) -> Result<(), WorldError> { + let slot = checked_slot_mut(world, handle)?; + slot.live = false; + slot.generation = slot.generation.saturating_add(1); + Ok(()) +} + +fn live_registered(world: &World) -> Vec { + world + .slots + .iter() + .enumerate() + .filter_map(|(idx, slot)| { + let slot_index = u32::try_from(idx).ok()?; + (slot.live && slot.registered).then_some(ObjectHandle { + generation: slot.generation, + slot: slot_index, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn construct_register_and_hash_are_stable() { + let mut world = new(WorldConfig); + let handle = construct_object(&mut world, ObjectDraft { original_id: None }).expect("obj"); + let before = step(&mut world, &InputSnapshot).expect("step"); + assert!(before.objects.is_empty()); + register_object(&mut world, handle).expect("register"); + let after = step(&mut world, &InputSnapshot).expect("step"); + assert_eq!(after.objects, vec![handle]); + } + + #[test] + fn registration_sequence_stale_and_duplicate_original_contracts() { + let mut world = new(WorldConfig); + let first = construct_object( + &mut world, + ObjectDraft { + original_id: Some(OriginalObjectId(7)), + }, + ) + .expect("first"); + let second = construct_object( + &mut world, + ObjectDraft { + original_id: Some(OriginalObjectId(8)), + }, + ) + .expect("second"); + register_object(&mut world, first).expect("register first"); + register_object(&mut world, second).expect("register second"); + assert_eq!(registration_sequence(&world, first), Ok(Some(0))); + assert_eq!(registration_sequence(&world, second), Ok(Some(1))); + + request_delete(&mut world, first).expect("delete"); + assert_eq!( + register_object(&mut world, first), + Err(WorldError::StaleHandle) + ); + let recycled = ObjectHandle { + generation: first.generation, + slot: first.slot, + }; + assert_eq!( + register_object(&mut world, recycled), + Err(WorldError::StaleHandle) + ); + + let duplicate = construct_object( + &mut world, + ObjectDraft { + original_id: Some(OriginalObjectId(8)), + }, + ) + .expect("duplicate"); + assert_eq!( + register_object(&mut world, duplicate), + Err(WorldError::DuplicateOriginalObjectId(OriginalObjectId(8))) + ); + } + + #[test] + fn identity_metadata_keeps_original_mirror_and_owner_distinct() { + let mut world = new(WorldConfig); + let handle = construct_object( + &mut world, + ObjectDraft { + original_id: Some(OriginalObjectId(10)), + }, + ) + .expect("object"); + set_mirror_original(&mut world, handle, Some(OriginalObjectId(20))).expect("mirror"); + set_owner(&mut world, handle, Some(OwnerId(3))).expect("owner"); + assert_eq!( + identity_metadata(&world, handle), + Ok(IdentityMetadata { + original_id: Some(OriginalObjectId(10)), + mirror_id: Some(OriginalObjectId(20)), + owner_id: Some(OwnerId(3)) + }) + ); + } + + #[test] + fn command_fifo_and_deferred_delete_during_calculation() { + let mut world = new(WorldConfig); + let first = construct_object(&mut world, ObjectDraft { original_id: None }).expect("first"); + let second = + construct_object(&mut world, ObjectDraft { original_id: None }).expect("second"); + register_object(&mut world, first).expect("register first"); + register_object(&mut world, second).expect("register second"); + enqueue( + &mut world, + WorldCommand { + sequence: 99, + target: Some(first), + }, + ) + .expect("enqueue first"); + enqueue( + &mut world, + WorldCommand { + sequence: 99, + target: Some(second), + }, + ) + .expect("enqueue second"); + enqueue( + &mut world, + WorldCommand { + sequence: 99, + target: Some(first), + }, + ) + .expect("enqueue first again"); + + let snapshot = step_with_handler(&mut world, &InputSnapshot, |world, command| { + if command.target == Some(first) { + request_delete(world, first)?; + request_delete(world, first)?; + } + Ok(()) + }) + .expect("step"); + + assert_eq!( + snapshot.events, + vec![ + WorldEvent { + sequence: 0, + target: Some(first) + }, + WorldEvent { + sequence: 1, + target: Some(second) + } + ] + ); + assert_eq!( + request_delete(&mut world, first), + Err(WorldError::StaleHandle) + ); + assert_eq!( + step(&mut world, &InputSnapshot).expect("step").objects, + vec![second] + ); + } + + #[test] + fn snapshot_hash_determinism_and_immutability() { + let mut left = new(WorldConfig); + let mut right = new(WorldConfig); + for world in [&mut left, &mut right] { + let handle = construct_object( + world, + ObjectDraft { + original_id: Some(OriginalObjectId(1)), + }, + ) + .expect("object"); + register_object(world, handle).expect("register"); + } + let snapshot = step(&mut left, &InputSnapshot).expect("snapshot"); + let clone = snapshot.clone(); + let extra = construct_object(&mut left, ObjectDraft { original_id: None }).expect("extra"); + register_object(&mut left, extra).expect("register extra"); + + assert_eq!(snapshot, clone); + assert_eq!( + clone.hash, + step(&mut right, &InputSnapshot).expect("right").hash + ); + } + + #[test] + fn fixed_step_pause_and_long_determinism_are_stable() { + let config = FixedStepConfig { step_millis: 20 }; + let mut clock = fixed_step_clock(config).expect("clock"); + collect_platform_events(&mut clock); + set_paused(&mut clock, true); + assert_eq!(advance_fixed_step(&mut clock, config, 100), Ok(0)); + collect_platform_events(&mut clock); + assert_eq!(fixed_step_tick(&clock), Tick(0)); + assert_eq!(platform_event_collections(&clock), 2); + + set_paused(&mut clock, false); + assert_eq!(advance_fixed_step(&mut clock, config, 45), Ok(2)); + assert_eq!(fixed_step_tick(&clock), Tick(2)); + + let mut first = new(WorldConfig); + let mut second = new(WorldConfig); + let mut first_hashes = Vec::new(); + let mut second_hashes = Vec::new(); + for _ in 0..10_000 { + first_hashes.push(step(&mut first, &InputSnapshot).expect("first").hash); + second_hashes.push(step(&mut second, &InputSnapshot).expect("second").hash); + } + assert_eq!(first_hashes, second_hashes); + } + + #[test] + fn render_disabled_does_not_change_hash_end_callbacks_and_shutdown_order() { + let callbacks = vec![ + WorldEvent { + sequence: 3, + target: None, + }, + WorldEvent { + sequence: 1, + target: None, + }, + WorldEvent { + sequence: 2, + target: None, + }, + ]; + assert_eq!(end_frame_callback_order(callbacks), vec![1, 2, 3]); + + let mut rendered = new(WorldConfig); + let mut headless = rendered.clone(); + assert_eq!( + step(&mut rendered, &InputSnapshot).expect("rendered").hash, + step(&mut headless, &InputSnapshot).expect("headless").hash + ); + + let handle = + construct_object(&mut rendered, ObjectDraft { original_id: None }).expect("object"); + register_object(&mut rendered, handle).expect("register"); + assert_eq!( + shutdown(rendered), + ShutdownReport { + released_objects: vec![handle], + managers_released: true + } + ); + } + + #[test] + fn generated_command_delete_sequences_preserve_registry_invariants() { + for seed in 0_u32..64 { + let mut world = new(WorldConfig); + let mut handles = Vec::new(); + for index in 0..8 { + let handle = construct_object( + &mut world, + ObjectDraft { + original_id: Some(OriginalObjectId(seed * 100 + index)), + }, + ) + .expect("object"); + register_object(&mut world, handle).expect("register"); + handles.push(handle); + } + for (index, handle) in handles.iter().copied().enumerate() { + if (seed as usize + index) % 3 == 0 { + request_delete(&mut world, handle).expect("delete"); + } else { + enqueue( + &mut world, + WorldCommand { + sequence: 0, + target: Some(handle), + }, + ) + .expect("enqueue"); + } + } + let snapshot = step(&mut world, &InputSnapshot).expect("step"); + for handle in snapshot.objects { + assert!(registration_sequence(&world, handle) + .expect("sequence") + .is_some()); + } + } + } +} diff --git a/crates/msh-core/Cargo.toml b/crates/msh-core/Cargo.toml deleted file mode 100644 index 86b0846..0000000 --- a/crates/msh-core/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "msh-core" -version = "0.1.0" -edition = "2021" - -[dependencies] -encoding_rs = "0.8" -nres = { path = "../nres" } - -[dev-dependencies] -common = { path = "../common" } -proptest = "1" diff --git a/crates/msh-core/README.md b/crates/msh-core/README.md deleted file mode 100644 index 016df7a..0000000 --- a/crates/msh-core/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# msh-core - -Парсер core-части формата `MSH`. - -Покрывает: - -- `Res1`, `Res2`, `Res3`, `Res6`, `Res13` (обязательные); -- `Res4`, `Res5`, `Res10` (опциональные); -- slot lookup по `node/lod/group`. - -Тесты: - -- прогон по всем `.msh` в `testdata`; -- синтетическая минимальная модель. diff --git a/crates/msh-core/src/error.rs b/crates/msh-core/src/error.rs deleted file mode 100644 index d46c7b1..0000000 --- a/crates/msh-core/src/error.rs +++ /dev/null @@ -1,75 +0,0 @@ -use core::fmt; - -#[derive(Debug)] -#[non_exhaustive] -pub enum Error { - Nres(nres::error::Error), - MissingResource { - kind: u32, - label: &'static str, - }, - InvalidResourceSize { - label: &'static str, - size: usize, - stride: usize, - }, - InvalidRes2Size { - size: usize, - }, - UnsupportedNodeStride { - stride: usize, - }, - IndexOutOfBounds { - label: &'static str, - index: usize, - limit: usize, - }, - IntegerOverflow, -} - -impl From for Error { - fn from(value: nres::error::Error) -> Self { - Self::Nres(value) - } -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Nres(err) => write!(f, "{err}"), - Self::MissingResource { kind, label } => { - write!(f, "missing required resource type={kind} ({label})") - } - Self::InvalidResourceSize { - label, - size, - stride, - } => { - write!( - f, - "invalid {label} size={size}, expected multiple of stride={stride}" - ) - } - Self::InvalidRes2Size { size } => { - write!(f, "invalid Res2 size={size}, expected >= 140") - } - Self::UnsupportedNodeStride { stride } => { - write!( - f, - "unsupported Res1 node stride={stride}, expected 38 or 24" - ) - } - Self::IndexOutOfBounds { - label, - index, - limit, - } => write!( - f, - "{label} index out of bounds: index={index}, limit={limit}" - ), - Self::IntegerOverflow => write!(f, "integer overflow"), - } - } -} - -impl std::error::Error for Error {} diff --git a/crates/msh-core/src/lib.rs b/crates/msh-core/src/lib.rs deleted file mode 100644 index bc51357..0000000 --- a/crates/msh-core/src/lib.rs +++ /dev/null @@ -1,434 +0,0 @@ -pub mod error; - -use crate::error::Error; -use encoding_rs::WINDOWS_1251; -use std::sync::Arc; - -pub type Result = core::result::Result; - -pub const RES1_NODE_TABLE: u32 = 1; -pub const RES2_SLOTS: u32 = 2; -pub const RES3_POSITIONS: u32 = 3; -pub const RES4_NORMALS: u32 = 4; -pub const RES5_UV0: u32 = 5; -pub const RES6_INDICES: u32 = 6; -pub const RES10_NAMES: u32 = 10; -pub const RES13_BATCHES: u32 = 13; - -#[derive(Clone, Debug)] -pub struct Slot { - pub tri_start: u16, - pub tri_count: u16, - pub batch_start: u16, - pub batch_count: u16, - pub aabb_min: [f32; 3], - pub aabb_max: [f32; 3], - pub sphere_center: [f32; 3], - pub sphere_radius: f32, - pub opaque: [u32; 5], -} - -#[derive(Clone, Debug)] -pub struct Batch { - pub batch_flags: u16, - pub material_index: u16, - pub opaque4: u16, - pub opaque6: u16, - pub index_count: u16, - pub index_start: u32, - pub opaque14: u16, - pub base_vertex: u32, -} - -#[derive(Clone, Debug)] -pub struct Model { - pub node_stride: usize, - pub node_count: usize, - pub nodes_raw: Vec, - pub slots: Vec, - pub positions: Vec<[f32; 3]>, - pub normals: Option>, - pub uv0: Option>, - pub indices: Vec, - pub batches: Vec, - pub node_names: Option>>, -} - -impl Model { - pub fn slot_index(&self, node_index: usize, lod: usize, group: usize) -> Option { - if node_index >= self.node_count || lod >= 3 || group >= 5 { - return None; - } - if self.node_stride != 38 { - return None; - } - let node_off = node_index.checked_mul(self.node_stride)?; - let matrix_off = node_off.checked_add(8)?; - let word_off = matrix_off.checked_add((lod * 5 + group) * 2)?; - let raw = read_u16(&self.nodes_raw, word_off).ok()?; - if raw == u16::MAX { - return None; - } - let idx = usize::from(raw); - if idx >= self.slots.len() { - return None; - } - Some(idx) - } -} - -pub fn parse_model_payload(payload: &[u8]) -> Result { - let archive = nres::Archive::open_bytes( - Arc::from(payload.to_vec().into_boxed_slice()), - nres::OpenOptions::default(), - )?; - - let res1 = read_required(&archive, RES1_NODE_TABLE, "Res1")?; - let res2 = read_required(&archive, RES2_SLOTS, "Res2")?; - let res3 = read_required(&archive, RES3_POSITIONS, "Res3")?; - let res6 = read_required(&archive, RES6_INDICES, "Res6")?; - let res13 = read_required(&archive, RES13_BATCHES, "Res13")?; - - let res4 = read_optional(&archive, RES4_NORMALS)?; - let res5 = read_optional(&archive, RES5_UV0)?; - let res10 = read_optional(&archive, RES10_NAMES)?; - - let node_stride = usize::try_from(res1.meta.attr3).map_err(|_| Error::IntegerOverflow)?; - if node_stride != 38 && node_stride != 24 { - return Err(Error::UnsupportedNodeStride { - stride: node_stride, - }); - } - if res1.bytes.len() % node_stride != 0 { - return Err(Error::InvalidResourceSize { - label: "Res1", - size: res1.bytes.len(), - stride: node_stride, - }); - } - let node_count = res1.bytes.len() / node_stride; - - if res2.bytes.len() < 0x8C { - return Err(Error::InvalidRes2Size { - size: res2.bytes.len(), - }); - } - let slot_blob = res2 - .bytes - .len() - .checked_sub(0x8C) - .ok_or(Error::IntegerOverflow)?; - if slot_blob % 68 != 0 { - return Err(Error::InvalidResourceSize { - label: "Res2.slots", - size: slot_blob, - stride: 68, - }); - } - let slot_count = slot_blob / 68; - let mut slots = Vec::with_capacity(slot_count); - for i in 0..slot_count { - let off = 0x8Cusize - .checked_add(i.checked_mul(68).ok_or(Error::IntegerOverflow)?) - .ok_or(Error::IntegerOverflow)?; - slots.push(Slot { - tri_start: read_u16(&res2.bytes, off)?, - tri_count: read_u16(&res2.bytes, off + 2)?, - batch_start: read_u16(&res2.bytes, off + 4)?, - batch_count: read_u16(&res2.bytes, off + 6)?, - aabb_min: [ - read_f32(&res2.bytes, off + 8)?, - read_f32(&res2.bytes, off + 12)?, - read_f32(&res2.bytes, off + 16)?, - ], - aabb_max: [ - read_f32(&res2.bytes, off + 20)?, - read_f32(&res2.bytes, off + 24)?, - read_f32(&res2.bytes, off + 28)?, - ], - sphere_center: [ - read_f32(&res2.bytes, off + 32)?, - read_f32(&res2.bytes, off + 36)?, - read_f32(&res2.bytes, off + 40)?, - ], - sphere_radius: read_f32(&res2.bytes, off + 44)?, - opaque: [ - read_u32(&res2.bytes, off + 48)?, - read_u32(&res2.bytes, off + 52)?, - read_u32(&res2.bytes, off + 56)?, - read_u32(&res2.bytes, off + 60)?, - read_u32(&res2.bytes, off + 64)?, - ], - }); - } - - let positions = parse_positions(&res3.bytes)?; - let indices = parse_u16_array(&res6.bytes, "Res6")?; - let batches = parse_batches(&res13.bytes)?; - validate_slot_batch_ranges(&slots, batches.len())?; - validate_batch_index_ranges(&batches, indices.len())?; - - let normals = match res4 { - Some(raw) => Some(parse_i8x4_array(&raw.bytes, "Res4")?), - None => None, - }; - let uv0 = match res5 { - Some(raw) => Some(parse_i16x2_array(&raw.bytes, "Res5")?), - None => None, - }; - let node_names = match res10 { - Some(raw) => Some(parse_res10_names(&raw.bytes, node_count)?), - None => None, - }; - - Ok(Model { - node_stride, - node_count, - nodes_raw: res1.bytes, - slots, - positions, - normals, - uv0, - indices, - batches, - node_names, - }) -} - -fn validate_slot_batch_ranges(slots: &[Slot], batch_count: usize) -> Result<()> { - for slot in slots { - let start = usize::from(slot.batch_start); - let end = start - .checked_add(usize::from(slot.batch_count)) - .ok_or(Error::IntegerOverflow)?; - if end > batch_count { - return Err(Error::IndexOutOfBounds { - label: "Res2.batch_range", - index: end, - limit: batch_count, - }); - } - } - Ok(()) -} - -fn validate_batch_index_ranges(batches: &[Batch], index_count: usize) -> Result<()> { - for batch in batches { - let start = usize::try_from(batch.index_start).map_err(|_| Error::IntegerOverflow)?; - let end = start - .checked_add(usize::from(batch.index_count)) - .ok_or(Error::IntegerOverflow)?; - if end > index_count { - return Err(Error::IndexOutOfBounds { - label: "Res13.index_range", - index: end, - limit: index_count, - }); - } - } - Ok(()) -} - -fn parse_positions(data: &[u8]) -> Result> { - if !data.len().is_multiple_of(12) { - return Err(Error::InvalidResourceSize { - label: "Res3", - size: data.len(), - stride: 12, - }); - } - let count = data.len() / 12; - let mut out = Vec::with_capacity(count); - for i in 0..count { - let off = i * 12; - out.push([ - read_f32(data, off)?, - read_f32(data, off + 4)?, - read_f32(data, off + 8)?, - ]); - } - Ok(out) -} - -fn parse_batches(data: &[u8]) -> Result> { - if !data.len().is_multiple_of(20) { - return Err(Error::InvalidResourceSize { - label: "Res13", - size: data.len(), - stride: 20, - }); - } - let count = data.len() / 20; - let mut out = Vec::with_capacity(count); - for i in 0..count { - let off = i * 20; - out.push(Batch { - batch_flags: read_u16(data, off)?, - material_index: read_u16(data, off + 2)?, - opaque4: read_u16(data, off + 4)?, - opaque6: read_u16(data, off + 6)?, - index_count: read_u16(data, off + 8)?, - index_start: read_u32(data, off + 10)?, - opaque14: read_u16(data, off + 14)?, - base_vertex: read_u32(data, off + 16)?, - }); - } - Ok(out) -} - -fn parse_u16_array(data: &[u8], label: &'static str) -> Result> { - if !data.len().is_multiple_of(2) { - return Err(Error::InvalidResourceSize { - label, - size: data.len(), - stride: 2, - }); - } - let mut out = Vec::with_capacity(data.len() / 2); - for i in (0..data.len()).step_by(2) { - out.push(read_u16(data, i)?); - } - Ok(out) -} - -fn parse_i8x4_array(data: &[u8], label: &'static str) -> Result> { - if !data.len().is_multiple_of(4) { - return Err(Error::InvalidResourceSize { - label, - size: data.len(), - stride: 4, - }); - } - let mut out = Vec::with_capacity(data.len() / 4); - for i in (0..data.len()).step_by(4) { - out.push([ - read_i8(data, i)?, - read_i8(data, i + 1)?, - read_i8(data, i + 2)?, - read_i8(data, i + 3)?, - ]); - } - Ok(out) -} - -fn parse_i16x2_array(data: &[u8], label: &'static str) -> Result> { - if !data.len().is_multiple_of(4) { - return Err(Error::InvalidResourceSize { - label, - size: data.len(), - stride: 4, - }); - } - let mut out = Vec::with_capacity(data.len() / 4); - for i in (0..data.len()).step_by(4) { - out.push([read_i16(data, i)?, read_i16(data, i + 2)?]); - } - Ok(out) -} - -fn parse_res10_names(data: &[u8], node_count: usize) -> Result>> { - let mut out = Vec::with_capacity(node_count); - let mut off = 0usize; - for _ in 0..node_count { - let len = usize::try_from(read_u32(data, off)?).map_err(|_| Error::IntegerOverflow)?; - off = off.checked_add(4).ok_or(Error::IntegerOverflow)?; - if len == 0 { - out.push(None); - continue; - } - let need = len.checked_add(1).ok_or(Error::IntegerOverflow)?; - let end = off.checked_add(need).ok_or(Error::IntegerOverflow)?; - let slice = data.get(off..end).ok_or(Error::InvalidResourceSize { - label: "Res10", - size: data.len(), - stride: 1, - })?; - let text = if slice.last().copied() == Some(0) { - &slice[..slice.len().saturating_sub(1)] - } else { - slice - }; - let decoded = decode_cp1251(text); - out.push(Some(decoded)); - off = end; - } - Ok(out) -} - -fn decode_cp1251(bytes: &[u8]) -> String { - let (decoded, _, _) = WINDOWS_1251.decode(bytes); - decoded.into_owned() -} - -struct RawResource { - meta: nres::EntryMeta, - bytes: Vec, -} - -fn read_required(archive: &nres::Archive, kind: u32, label: &'static str) -> Result { - let id = archive - .entries() - .find(|entry| entry.meta.kind == kind) - .map(|entry| entry.id) - .ok_or(Error::MissingResource { kind, label })?; - let entry = archive.get(id).ok_or(Error::IndexOutOfBounds { - label, - index: usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?, - limit: archive.entry_count(), - })?; - let data = archive.read(id)?.into_owned(); - Ok(RawResource { - meta: entry.meta.clone(), - bytes: data, - }) -} - -fn read_optional(archive: &nres::Archive, kind: u32) -> Result> { - let Some(id) = archive - .entries() - .find(|entry| entry.meta.kind == kind) - .map(|entry| entry.id) - else { - return Ok(None); - }; - let entry = archive.get(id).ok_or(Error::IndexOutOfBounds { - label: "optional", - index: usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?, - limit: archive.entry_count(), - })?; - let data = archive.read(id)?.into_owned(); - Ok(Some(RawResource { - meta: entry.meta.clone(), - bytes: data, - })) -} - -fn read_u16(data: &[u8], offset: usize) -> Result { - let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?; - let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?; - Ok(u16::from_le_bytes(arr)) -} - -fn read_i16(data: &[u8], offset: usize) -> Result { - let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?; - let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?; - Ok(i16::from_le_bytes(arr)) -} - -fn read_i8(data: &[u8], offset: usize) -> Result { - let byte = data.get(offset).copied().ok_or(Error::IntegerOverflow)?; - Ok(i8::from_le_bytes([byte])) -} - -fn read_u32(data: &[u8], offset: usize) -> Result { - let bytes = data.get(offset..offset + 4).ok_or(Error::IntegerOverflow)?; - let arr: [u8; 4] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?; - Ok(u32::from_le_bytes(arr)) -} - -fn read_f32(data: &[u8], offset: usize) -> Result { - Ok(f32::from_bits(read_u32(data, offset)?)) -} - -#[cfg(test)] -mod tests; diff --git a/crates/msh-core/src/tests.rs b/crates/msh-core/src/tests.rs deleted file mode 100644 index 90a7fdc..0000000 --- a/crates/msh-core/src/tests.rs +++ /dev/null @@ -1,438 +0,0 @@ -use super::*; -use common::collect_files_recursive; -use nres::Archive; -use proptest::prelude::*; -use std::fs; -use std::path::{Path, PathBuf}; - -fn nres_test_files() -> Vec { - let root = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("testdata"); - let mut files = Vec::new(); - collect_files_recursive(&root, &mut files); - files.sort(); - files - .into_iter() - .filter(|path| { - fs::read(path) - .map(|bytes| bytes.get(0..4) == Some(b"NRes")) - .unwrap_or(false) - }) - .collect() -} - -fn is_msh_name(name: &str) -> bool { - name.to_ascii_lowercase().ends_with(".msh") -} - -#[derive(Clone)] -struct SyntheticEntry { - kind: u32, - name: String, - attr1: u32, - attr2: u32, - attr3: u32, - data: Vec, -} - -fn build_nested_nres(entries: &[SyntheticEntry]) -> Vec { - let mut payload = Vec::new(); - payload.extend_from_slice(b"NRes"); - payload.extend_from_slice(&0x100u32.to_le_bytes()); - payload.extend_from_slice( - &u32::try_from(entries.len()) - .expect("entry count overflow in test") - .to_le_bytes(), - ); - payload.extend_from_slice(&0u32.to_le_bytes()); // total_size placeholder - - let mut resource_offsets = Vec::with_capacity(entries.len()); - for entry in entries { - resource_offsets.push(u32::try_from(payload.len()).expect("offset overflow in test")); - payload.extend_from_slice(&entry.data); - while !payload.len().is_multiple_of(8) { - payload.push(0); - } - } - - for (index, entry) in entries.iter().enumerate() { - payload.extend_from_slice(&entry.kind.to_le_bytes()); - payload.extend_from_slice(&entry.attr1.to_le_bytes()); - payload.extend_from_slice(&entry.attr2.to_le_bytes()); - payload.extend_from_slice( - &u32::try_from(entry.data.len()) - .expect("size overflow in test") - .to_le_bytes(), - ); - payload.extend_from_slice(&entry.attr3.to_le_bytes()); - - let mut name_raw = [0u8; 36]; - let name_bytes = entry.name.as_bytes(); - assert!(name_bytes.len() <= 35, "name too long for synthetic test"); - name_raw[..name_bytes.len()].copy_from_slice(name_bytes); - payload.extend_from_slice(&name_raw); - - payload.extend_from_slice(&resource_offsets[index].to_le_bytes()); - payload.extend_from_slice(&(index as u32).to_le_bytes()); - } - - let total_size = u32::try_from(payload.len()).expect("size overflow in test"); - payload[12..16].copy_from_slice(&total_size.to_le_bytes()); - payload -} - -fn synthetic_entry(kind: u32, name: &str, attr3: u32, data: Vec) -> SyntheticEntry { - SyntheticEntry { - kind, - name: name.to_string(), - attr1: 1, - attr2: 0, - attr3, - data, - } -} - -fn res1_stride38_nodes(node_count: usize, node0_slot00: Option) -> Vec { - let mut out = vec![0u8; node_count.saturating_mul(38)]; - for node in 0..node_count { - let node_off = node * 38; - for i in 0..15 { - let off = node_off + 8 + i * 2; - out[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes()); - } - } - if let Some(slot) = node0_slot00 { - out[8..10].copy_from_slice(&slot.to_le_bytes()); - } - out -} - -fn res1_stride24_nodes(node_count: usize) -> Vec { - vec![0u8; node_count.saturating_mul(24)] -} - -fn res2_single_slot(batch_start: u16, batch_count: u16) -> Vec { - let mut res2 = vec![0u8; 0x8C + 68]; - res2[0x8C..0x8C + 2].copy_from_slice(&0u16.to_le_bytes()); // tri_start - res2[0x8C + 2..0x8C + 4].copy_from_slice(&0u16.to_le_bytes()); // tri_count - res2[0x8C + 4..0x8C + 6].copy_from_slice(&batch_start.to_le_bytes()); // batch_start - res2[0x8C + 6..0x8C + 8].copy_from_slice(&batch_count.to_le_bytes()); // batch_count - res2 -} - -fn res3_triangle_positions() -> Vec { - [0f32, 0f32, 0f32, 1f32, 0f32, 0f32, 0f32, 1f32, 0f32] - .iter() - .flat_map(|v| v.to_le_bytes()) - .collect() -} - -fn res4_normals() -> Vec { - vec![127u8, 0u8, 128u8, 0u8] -} - -fn res5_uv0() -> Vec { - [1024i16, -1024i16] - .iter() - .flat_map(|v| v.to_le_bytes()) - .collect() -} - -fn res6_triangle_indices() -> Vec { - [0u16, 1u16, 2u16] - .iter() - .flat_map(|v| v.to_le_bytes()) - .collect() -} - -fn res13_single_batch(index_start: u32, index_count: u16) -> Vec { - let mut batch = vec![0u8; 20]; - batch[0..2].copy_from_slice(&0u16.to_le_bytes()); - batch[2..4].copy_from_slice(&0u16.to_le_bytes()); - batch[8..10].copy_from_slice(&index_count.to_le_bytes()); - batch[10..14].copy_from_slice(&index_start.to_le_bytes()); - batch[16..20].copy_from_slice(&0u32.to_le_bytes()); - batch -} - -fn res10_names_raw(names: &[Option<&[u8]>]) -> Vec { - let mut out = Vec::new(); - for name in names { - match name { - Some(name) => { - out.extend_from_slice( - &u32::try_from(name.len()) - .expect("name size overflow in test") - .to_le_bytes(), - ); - out.extend_from_slice(name); - out.push(0); - } - None => out.extend_from_slice(&0u32.to_le_bytes()), - } - } - out -} - -fn res10_names(names: &[Option<&str>]) -> Vec { - let raw: Vec> = names.iter().map(|name| name.map(str::as_bytes)).collect(); - res10_names_raw(&raw) -} - -fn base_synthetic_entries() -> Vec { - vec![ - synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0))), - synthetic_entry(RES2_SLOTS, "Res2", 68, res2_single_slot(0, 1)), - synthetic_entry(RES3_POSITIONS, "Res3", 12, res3_triangle_positions()), - synthetic_entry(RES6_INDICES, "Res6", 2, res6_triangle_indices()), - synthetic_entry(RES13_BATCHES, "Res13", 20, res13_single_batch(0, 3)), - ] -} - -#[test] -fn parse_all_game_msh_models() { - let archives = nres_test_files(); - if archives.is_empty() { - eprintln!("skipping parse_all_game_msh_models: no NRes files in testdata"); - return; - } - - let mut model_count = 0usize; - let mut renderable_count = 0usize; - let mut legacy_stride24_count = 0usize; - - for archive_path in archives { - let archive = Archive::open_path(&archive_path) - .unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display())); - - for entry in archive.entries() { - if !is_msh_name(&entry.meta.name) { - continue; - } - model_count += 1; - let payload = archive.read(entry.id).unwrap_or_else(|err| { - panic!( - "failed to read model '{}' in {}: {err}", - entry.meta.name, - archive_path.display() - ) - }); - let model = parse_model_payload(payload.as_slice()).unwrap_or_else(|err| { - panic!( - "failed to parse model '{}' in {}: {err}", - entry.meta.name, - archive_path.display() - ) - }); - - if model.node_stride == 24 { - legacy_stride24_count += 1; - } - - for node_index in 0..model.node_count { - for lod in 0..3 { - for group in 0..5 { - if let Some(slot_idx) = model.slot_index(node_index, lod, group) { - assert!( - slot_idx < model.slots.len(), - "slot index out of bounds in '{}' ({})", - entry.meta.name, - archive_path.display() - ); - } - } - } - } - - let mut has_renderable_batch = false; - for node_index in 0..model.node_count { - let Some(slot_idx) = model.slot_index(node_index, 0, 0) else { - continue; - }; - let slot = &model.slots[slot_idx]; - let batch_end = - usize::from(slot.batch_start).saturating_add(usize::from(slot.batch_count)); - if batch_end > model.batches.len() { - continue; - } - for batch in &model.batches[usize::from(slot.batch_start)..batch_end] { - let index_start = usize::try_from(batch.index_start).unwrap_or(usize::MAX); - let index_count = usize::from(batch.index_count); - let end = index_start.saturating_add(index_count); - if end <= model.indices.len() && index_count >= 3 { - has_renderable_batch = true; - break; - } - } - if has_renderable_batch { - break; - } - } - if has_renderable_batch { - renderable_count += 1; - } - } - } - - assert!(model_count > 0, "no .msh entries found"); - assert!( - renderable_count > 0, - "no renderable models (lod0/group0) were detected" - ); - assert!( - legacy_stride24_count <= model_count, - "internal test accounting error" - ); -} - -#[test] -fn parse_minimal_synthetic_model() { - let payload = build_nested_nres(&base_synthetic_entries()); - let model = parse_model_payload(&payload).expect("failed to parse synthetic model"); - assert_eq!(model.node_count, 1); - assert_eq!(model.positions.len(), 3); - assert_eq!(model.indices.len(), 3); - assert_eq!(model.batches.len(), 1); - assert_eq!(model.slot_index(0, 0, 0), Some(0)); -} - -#[test] -fn parse_synthetic_stride24_variant() { - let mut entries = base_synthetic_entries(); - entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 24, res1_stride24_nodes(1)); - let payload = build_nested_nres(&entries); - - let model = parse_model_payload(&payload).expect("failed to parse stride24 model"); - assert_eq!(model.node_stride, 24); - assert_eq!(model.node_count, 1); - assert_eq!(model.slot_index(0, 0, 0), None); -} - -#[test] -fn parse_synthetic_model_with_optional_res4_res5_res10() { - let mut entries = base_synthetic_entries(); - entries.push(synthetic_entry(RES4_NORMALS, "Res4", 4, res4_normals())); - entries.push(synthetic_entry(RES5_UV0, "Res5", 4, res5_uv0())); - entries.push(synthetic_entry( - RES10_NAMES, - "Res10", - 1, - res10_names(&[Some("Hull"), None]), - )); - entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(2, Some(0))); - let payload = build_nested_nres(&entries); - - let model = parse_model_payload(&payload).expect("failed to parse model with optional data"); - assert_eq!(model.node_count, 2); - assert_eq!(model.normals.as_ref().map(Vec::len), Some(1)); - assert_eq!(model.uv0.as_ref().map(Vec::len), Some(1)); - assert_eq!(model.node_names, Some(vec![Some("Hull".to_string()), None])); -} - -#[test] -fn parse_res10_names_decodes_cp1251() { - let mut entries = base_synthetic_entries(); - entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0))); - entries.push(synthetic_entry( - RES10_NAMES, - "Res10", - 1, - res10_names_raw(&[Some(&[0xC0])]), - )); - let payload = build_nested_nres(&entries); - - let model = parse_model_payload(&payload).expect("failed to parse model with cp1251 name"); - assert_eq!(model.node_names, Some(vec![Some("А".to_string())])); -} - -#[test] -fn parse_fails_when_required_resource_missing() { - let mut entries = base_synthetic_entries(); - entries.retain(|entry| entry.kind != RES13_BATCHES); - let payload = build_nested_nres(&entries); - - assert!(matches!( - parse_model_payload(&payload), - Err(Error::MissingResource { - kind: RES13_BATCHES, - label: "Res13" - }) - )); -} - -#[test] -fn parse_fails_for_invalid_res2_size() { - let mut entries = base_synthetic_entries(); - entries[1] = synthetic_entry(RES2_SLOTS, "Res2", 68, vec![0u8; 0x8B]); - let payload = build_nested_nres(&entries); - - assert!(matches!( - parse_model_payload(&payload), - Err(Error::InvalidRes2Size { .. }) - )); -} - -#[test] -fn parse_fails_for_unsupported_node_stride() { - let mut entries = base_synthetic_entries(); - entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 30, vec![0u8; 30]); - let payload = build_nested_nres(&entries); - - assert!(matches!( - parse_model_payload(&payload), - Err(Error::UnsupportedNodeStride { stride: 30 }) - )); -} - -#[test] -fn parse_fails_for_invalid_optional_resource_size() { - let mut entries = base_synthetic_entries(); - entries.push(synthetic_entry(RES4_NORMALS, "Res4", 4, vec![1, 2, 3])); - let payload = build_nested_nres(&entries); - - assert!(matches!( - parse_model_payload(&payload), - Err(Error::InvalidResourceSize { label: "Res4", .. }) - )); -} - -#[test] -fn parse_fails_for_slot_batch_range_out_of_bounds() { - let mut entries = base_synthetic_entries(); - entries[1] = synthetic_entry(RES2_SLOTS, "Res2", 68, res2_single_slot(0, 2)); - let payload = build_nested_nres(&entries); - - assert!(matches!( - parse_model_payload(&payload), - Err(Error::IndexOutOfBounds { - label: "Res2.batch_range", - .. - }) - )); -} - -#[test] -fn parse_fails_for_batch_index_range_out_of_bounds() { - let mut entries = base_synthetic_entries(); - entries[4] = synthetic_entry(RES13_BATCHES, "Res13", 20, res13_single_batch(1, 3)); - let payload = build_nested_nres(&entries); - - assert!(matches!( - parse_model_payload(&payload), - Err(Error::IndexOutOfBounds { - label: "Res13.index_range", - .. - }) - )); -} - -proptest! { - #![proptest_config(ProptestConfig::with_cases(64))] - - #[test] - fn parse_model_payload_never_panics_on_random_bytes(data in proptest::collection::vec(any::(), 0..8192)) { - let _ = parse_model_payload(&data); - } -} diff --git a/crates/nres/Cargo.toml b/crates/nres/Cargo.toml deleted file mode 100644 index 38b8822..0000000 --- a/crates/nres/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "nres" -version = "0.1.0" -edition = "2021" - -[dependencies] -common = { path = "../common" } - -[target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } diff --git a/crates/nres/README.md b/crates/nres/README.md deleted file mode 100644 index 8b9dfb5..0000000 --- a/crates/nres/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# nres - -Rust-библиотека для работы с архивами формата **NRes**. - -## Что умеет - -- Открытие архива из файла (`open_path`) и из памяти (`open_bytes`). -- Поддержка `raw_mode` (весь файл как единый ресурс). -- Чтение метаданных и итерация по записям. -- Поиск по имени без учёта регистра (`find`). -- Чтение данных ресурса (`read`, `read_into`, `raw_slice`). -- Редактирование архива через `Editor`: -- `add`, `replace_data`, `remove`. -- `commit` с пересчётом `sort_index`, выравниванием по 8 байт и атомарной записью файла. - -## Модель ошибок - -Библиотека возвращает типизированные ошибки (`InvalidMagic`, `UnsupportedVersion`, `TotalSizeMismatch`, `DirectoryOutOfBounds`, `EntryDataOutOfBounds`, и др.) без паник в production-коде. - -## Покрытие тестами - -### Реальные файлы - -- Рекурсивный прогон по `testdata/nres/**`. -- Сейчас в наборе: **120 архивов**. -- Для каждого архива проверяется: -- чтение всех записей; -- `read`/`read_into`/`raw_slice`; -- `find`; -- `unpack -> repack (Editor::commit)` с проверкой **byte-to-byte**. - -### Синтетические тесты - -- Проверка основных сценариев редактирования (`add/replace/remove/commit`). -- Проверка валидации и ошибок: -- `InvalidMagic`, `UnsupportedVersion`, `TotalSizeMismatch`, `InvalidEntryCount`, `DirectoryOutOfBounds`, `NameTooLong`, `EntryDataOutOfBounds`, `EntryIdOutOfRange`, `NameContainsNul`. - -## Быстрый запуск тестов - -```bash -cargo test -p nres -- --nocapture -``` diff --git a/crates/nres/src/error.rs b/crates/nres/src/error.rs deleted file mode 100644 index 9a3c651..0000000 --- a/crates/nres/src/error.rs +++ /dev/null @@ -1,110 +0,0 @@ -use core::fmt; - -#[derive(Debug)] -#[non_exhaustive] -pub enum Error { - Io(std::io::Error), - - InvalidMagic { - got: [u8; 4], - }, - UnsupportedVersion { - got: u32, - }, - TotalSizeMismatch { - header: u32, - actual: u64, - }, - - InvalidEntryCount { - got: i32, - }, - TooManyEntries { - got: usize, - }, - DirectoryOutOfBounds { - directory_offset: u64, - directory_len: u64, - file_len: u64, - }, - - EntryIdOutOfRange { - id: u32, - entry_count: u32, - }, - EntryDataOutOfBounds { - id: u32, - offset: u64, - size: u32, - directory_offset: u64, - }, - NameTooLong { - got: usize, - max: usize, - }, - NameContainsNul, - BadNameEncoding, - - IntegerOverflow, - - RawModeDisallowsOperation(&'static str), -} - -impl From for Error { - fn from(value: std::io::Error) -> Self { - Self::Io(value) - } -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Error::Io(e) => write!(f, "I/O error: {e}"), - Error::InvalidMagic { got } => write!(f, "invalid NRes magic: {got:02X?}"), - Error::UnsupportedVersion { got } => { - write!(f, "unsupported NRes version: {got:#x}") - } - Error::TotalSizeMismatch { header, actual } => { - write!(f, "NRes total_size mismatch: header={header}, actual={actual}") - } - Error::InvalidEntryCount { got } => write!(f, "invalid entry_count: {got}"), - Error::TooManyEntries { got } => write!(f, "too many entries: {got} exceeds u32::MAX"), - Error::DirectoryOutOfBounds { - directory_offset, - directory_len, - file_len, - } => write!( - f, - "directory out of bounds: off={directory_offset}, len={directory_len}, file={file_len}" - ), - Error::EntryIdOutOfRange { id, entry_count } => { - write!(f, "entry id out of range: id={id}, count={entry_count}") - } - Error::EntryDataOutOfBounds { - id, - offset, - size, - directory_offset, - } => write!( - f, - "entry data out of bounds: id={id}, off={offset}, size={size}, dir_off={directory_offset}" - ), - Error::NameTooLong { got, max } => write!(f, "name too long: {got} > {max}"), - Error::NameContainsNul => write!(f, "name contains NUL byte"), - Error::BadNameEncoding => write!(f, "bad name encoding"), - Error::IntegerOverflow => write!(f, "integer overflow"), - Error::RawModeDisallowsOperation(op) => { - write!(f, "operation not allowed in raw mode: {op}") - } - } - } -} - -impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io(err) => Some(err), - _ => None, - } - } -} diff --git a/crates/nres/src/lib.rs b/crates/nres/src/lib.rs deleted file mode 100644 index 571b395..0000000 --- a/crates/nres/src/lib.rs +++ /dev/null @@ -1,772 +0,0 @@ -pub mod error; - -use crate::error::Error; -use common::{OutputBuffer, ResourceData}; -use core::ops::Range; -use std::cmp::Ordering; -use std::fs::{self, OpenOptions as FsOpenOptions}; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; - -pub type Result = core::result::Result; - -#[derive(Clone, Debug, Default)] -pub struct OpenOptions { - pub raw_mode: bool, - pub sequential_hint: bool, - pub prefetch_pages: bool, -} - -#[derive(Clone, Debug, Default)] -pub enum OpenMode { - #[default] - ReadOnly, - ReadWrite, -} - -#[derive(Clone, Debug)] -pub struct ArchiveHeader { - pub magic: [u8; 4], - pub version: u32, - pub entry_count: u32, - pub total_size: u32, - pub directory_offset: u64, - pub directory_size: u64, -} - -#[derive(Clone, Debug)] -pub struct ArchiveInfo { - pub raw_mode: bool, - pub file_size: u64, - pub header: Option, -} - -#[derive(Debug)] -pub struct Archive { - bytes: Arc<[u8]>, - entries: Vec, - info: ArchiveInfo, - raw_mode: bool, -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] -pub struct EntryId(pub u32); - -#[derive(Clone, Debug)] -pub struct EntryMeta { - pub kind: u32, - pub attr1: u32, - pub attr2: u32, - pub attr3: u32, - pub name: String, - pub data_offset: u64, - pub data_size: u32, - pub sort_index: u32, -} - -#[derive(Copy, Clone, Debug)] -pub struct EntryRef<'a> { - pub id: EntryId, - pub meta: &'a EntryMeta, -} - -#[derive(Copy, Clone, Debug)] -pub struct EntryInspect<'a> { - pub id: EntryId, - pub meta: &'a EntryMeta, - pub name_raw: &'a [u8; 36], -} - -#[derive(Clone, Debug)] -struct EntryRecord { - meta: EntryMeta, - name_raw: [u8; 36], -} - -impl Archive { - pub fn open_path(path: impl AsRef) -> Result { - Self::open_path_with(path, OpenMode::ReadOnly, OpenOptions::default()) - } - - pub fn open_path_with( - path: impl AsRef, - _mode: OpenMode, - opts: OpenOptions, - ) -> Result { - let bytes = fs::read(path.as_ref())?; - let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice()); - Self::open_bytes(arc, opts) - } - - pub fn open_bytes(bytes: Arc<[u8]>, opts: OpenOptions) -> Result { - let file_size = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?; - let (entries, header) = parse_archive(&bytes, opts.raw_mode)?; - if opts.prefetch_pages { - prefetch_pages(&bytes); - } - Ok(Self { - bytes, - entries, - info: ArchiveInfo { - raw_mode: opts.raw_mode, - file_size, - header, - }, - raw_mode: opts.raw_mode, - }) - } - - pub fn info(&self) -> &ArchiveInfo { - &self.info - } - - pub fn entry_count(&self) -> usize { - self.entries.len() - } - - pub fn entries(&self) -> impl Iterator> { - self.entries.iter().enumerate().filter_map(|(idx, entry)| { - let id = u32::try_from(idx).ok()?; - Some(EntryRef { - id: EntryId(id), - meta: &entry.meta, - }) - }) - } - - pub fn entries_inspect(&self) -> impl Iterator> { - self.entries.iter().enumerate().filter_map(|(idx, entry)| { - let id = u32::try_from(idx).ok()?; - Some(EntryInspect { - id: EntryId(id), - meta: &entry.meta, - name_raw: &entry.name_raw, - }) - }) - } - - pub fn find(&self, name: &str) -> Option { - if self.entries.is_empty() { - return None; - } - - if !self.raw_mode { - let mut low = 0usize; - let mut high = self.entries.len(); - while low < high { - let mid = low + (high - low) / 2; - let Ok(target_idx) = usize::try_from(self.entries[mid].meta.sort_index) else { - break; - }; - if target_idx >= self.entries.len() { - break; - } - let cmp = cmp_name_case_insensitive( - name.as_bytes(), - entry_name_bytes(&self.entries[target_idx].name_raw), - ); - match cmp { - Ordering::Less => high = mid, - Ordering::Greater => low = mid + 1, - Ordering::Equal => { - let id = u32::try_from(target_idx).ok()?; - return Some(EntryId(id)); - } - } - } - } - - self.entries.iter().enumerate().find_map(|(idx, entry)| { - if cmp_name_case_insensitive(name.as_bytes(), entry_name_bytes(&entry.name_raw)) - == Ordering::Equal - { - let id = u32::try_from(idx).ok()?; - Some(EntryId(id)) - } else { - None - } - }) - } - - pub fn get(&self, id: EntryId) -> Option> { - let idx = usize::try_from(id.0).ok()?; - let entry = self.entries.get(idx)?; - Some(EntryRef { - id, - meta: &entry.meta, - }) - } - - pub fn inspect(&self, id: EntryId) -> Option> { - let idx = usize::try_from(id.0).ok()?; - let entry = self.entries.get(idx)?; - Some(EntryInspect { - id, - meta: &entry.meta, - name_raw: &entry.name_raw, - }) - } - - pub fn read(&self, id: EntryId) -> Result> { - let range = self.entry_range(id)?; - Ok(ResourceData::Borrowed(&self.bytes[range])) - } - - pub fn read_into(&self, id: EntryId, out: &mut dyn OutputBuffer) -> Result { - let range = self.entry_range(id)?; - out.write_exact(&self.bytes[range.clone()])?; - Ok(range.len()) - } - - pub fn raw_slice(&self, id: EntryId) -> Result> { - let range = self.entry_range(id)?; - Ok(Some(&self.bytes[range])) - } - - pub fn edit_path(path: impl AsRef) -> Result { - let path_buf = path.as_ref().to_path_buf(); - let bytes = fs::read(&path_buf)?; - let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice()); - let (entries, _) = parse_archive(&arc, false)?; - let mut editable = Vec::with_capacity(entries.len()); - for entry in &entries { - let range = checked_range(entry.meta.data_offset, entry.meta.data_size, arc.len())?; - editable.push(EditableEntry { - meta: entry.meta.clone(), - name_raw: entry.name_raw, - data: EntryData::Borrowed(range), // Copy-on-write: only store range - }); - } - Ok(Editor { - path: path_buf, - source: arc, - entries: editable, - }) - } - - fn entry_range(&self, id: EntryId) -> Result> { - let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?; - let Some(entry) = self.entries.get(idx) else { - return Err(Error::EntryIdOutOfRange { - id: id.0, - entry_count: saturating_u32_len(self.entries.len()), - }); - }; - checked_range( - entry.meta.data_offset, - entry.meta.data_size, - self.bytes.len(), - ) - } -} - -pub struct Editor { - path: PathBuf, - source: Arc<[u8]>, - entries: Vec, -} - -#[derive(Clone, Debug)] -enum EntryData { - Borrowed(Range), - Modified(Vec), -} - -#[derive(Clone, Debug)] -struct EditableEntry { - meta: EntryMeta, - name_raw: [u8; 36], - data: EntryData, -} - -impl EditableEntry { - fn data_slice<'a>(&'a self, source: &'a Arc<[u8]>) -> &'a [u8] { - match &self.data { - EntryData::Borrowed(range) => &source[range.clone()], - EntryData::Modified(vec) => vec.as_slice(), - } - } -} - -#[derive(Clone, Debug)] -pub struct NewEntry<'a> { - pub kind: u32, - pub attr1: u32, - pub attr2: u32, - pub attr3: u32, - pub name: &'a str, - pub data: &'a [u8], -} - -impl Editor { - pub fn entries(&self) -> impl Iterator> { - self.entries.iter().enumerate().filter_map(|(idx, entry)| { - let id = u32::try_from(idx).ok()?; - Some(EntryRef { - id: EntryId(id), - meta: &entry.meta, - }) - }) - } - - pub fn add(&mut self, entry: NewEntry<'_>) -> Result { - let name_raw = encode_name_field(entry.name)?; - let id_u32 = u32::try_from(self.entries.len()).map_err(|_| Error::IntegerOverflow)?; - let data_size = u32::try_from(entry.data.len()).map_err(|_| Error::IntegerOverflow)?; - self.entries.push(EditableEntry { - meta: EntryMeta { - kind: entry.kind, - attr1: entry.attr1, - attr2: entry.attr2, - attr3: entry.attr3, - name: decode_name(entry_name_bytes(&name_raw)), - data_offset: 0, - data_size, - sort_index: 0, - }, - name_raw, - data: EntryData::Modified(entry.data.to_vec()), - }); - Ok(EntryId(id_u32)) - } - - pub fn replace_data(&mut self, id: EntryId, data: &[u8]) -> Result<()> { - let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?; - let Some(entry) = self.entries.get_mut(idx) else { - return Err(Error::EntryIdOutOfRange { - id: id.0, - entry_count: saturating_u32_len(self.entries.len()), - }); - }; - entry.meta.data_size = u32::try_from(data.len()).map_err(|_| Error::IntegerOverflow)?; - // Replace with new data (triggers copy-on-write if borrowed) - entry.data = EntryData::Modified(data.to_vec()); - Ok(()) - } - - pub fn remove(&mut self, id: EntryId) -> Result<()> { - let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?; - if idx >= self.entries.len() { - return Err(Error::EntryIdOutOfRange { - id: id.0, - entry_count: saturating_u32_len(self.entries.len()), - }); - } - self.entries.remove(idx); - Ok(()) - } - - pub fn commit(mut self) -> Result<()> { - let count_u32 = u32::try_from(self.entries.len()).map_err(|_| Error::IntegerOverflow)?; - - // Pre-calculate capacity to avoid reallocations - let total_data_size: usize = self - .entries - .iter() - .map(|e| e.data_slice(&self.source).len()) - .sum(); - let padding_estimate = self.entries.len() * 8; // Max 8 bytes padding per entry - let directory_size = self.entries.len() * 64; // 64 bytes per entry - let capacity = 16 + total_data_size + padding_estimate + directory_size; - - let mut out = Vec::with_capacity(capacity); - out.resize(16, 0); // Header - - // Keep reference to source for copy-on-write - let source = &self.source; - - for entry in &mut self.entries { - entry.meta.data_offset = - u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?; - - // Calculate size and get slice separately to avoid borrow conflicts - let data_len = entry.data_slice(source).len(); - entry.meta.data_size = u32::try_from(data_len).map_err(|_| Error::IntegerOverflow)?; - - // Now get the slice again for writing - let data_slice = entry.data_slice(source); - out.extend_from_slice(data_slice); - - let padding = (8 - (out.len() % 8)) % 8; - if padding > 0 { - out.resize(out.len() + padding, 0); - } - } - - let mut sort_order: Vec = (0..self.entries.len()).collect(); - sort_order.sort_by(|a, b| { - cmp_name_case_insensitive( - entry_name_bytes(&self.entries[*a].name_raw), - entry_name_bytes(&self.entries[*b].name_raw), - ) - }); - - for (idx, entry) in self.entries.iter_mut().enumerate() { - // sort_index stores the original-entry index at sorted position `idx`. - // This mirrors the format emitted by the retail assets and test fixtures. - entry.meta.sort_index = - u32::try_from(sort_order[idx]).map_err(|_| Error::IntegerOverflow)?; - } - - for entry in &self.entries { - let data_offset_u32 = - u32::try_from(entry.meta.data_offset).map_err(|_| Error::IntegerOverflow)?; - push_u32(&mut out, entry.meta.kind); - push_u32(&mut out, entry.meta.attr1); - push_u32(&mut out, entry.meta.attr2); - push_u32(&mut out, entry.meta.data_size); - push_u32(&mut out, entry.meta.attr3); - out.extend_from_slice(&entry.name_raw); - push_u32(&mut out, data_offset_u32); - push_u32(&mut out, entry.meta.sort_index); - } - - let total_size_u32 = u32::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?; - 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(&count_u32.to_le_bytes()); - out[12..16].copy_from_slice(&total_size_u32.to_le_bytes()); - - write_atomic(&self.path, &out) - } -} - -fn parse_archive( - bytes: &[u8], - raw_mode: bool, -) -> Result<(Vec, Option)> { - if raw_mode { - let data_size = u32::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?; - let entry = EntryRecord { - meta: EntryMeta { - kind: 0, - attr1: 0, - attr2: 0, - attr3: 0, - name: String::from("RAW"), - data_offset: 0, - data_size, - sort_index: 0, - }, - name_raw: { - let mut name = [0u8; 36]; - let bytes_name = b"RAW"; - name[..bytes_name.len()].copy_from_slice(bytes_name); - name - }, - }; - return Ok((vec![entry], None)); - } - - if bytes.len() < 16 { - let mut got = [0u8; 4]; - let copy_len = bytes.len().min(4); - got[..copy_len].copy_from_slice(&bytes[..copy_len]); - return Err(Error::InvalidMagic { got }); - } - - let mut magic = [0u8; 4]; - magic.copy_from_slice(&bytes[0..4]); - if &magic != b"NRes" { - return Err(Error::InvalidMagic { got: magic }); - } - - let version = read_u32(bytes, 4)?; - if version != 0x100 { - return Err(Error::UnsupportedVersion { got: version }); - } - - let entry_count_i32 = i32::from_le_bytes( - bytes[8..12] - .try_into() - .map_err(|_| Error::IntegerOverflow)?, - ); - if entry_count_i32 < 0 { - return Err(Error::InvalidEntryCount { - got: entry_count_i32, - }); - } - let entry_count = usize::try_from(entry_count_i32).map_err(|_| Error::IntegerOverflow)?; - - // Validate entry_count fits in u32 (required for EntryId) - if entry_count > u32::MAX as usize { - return Err(Error::TooManyEntries { got: entry_count }); - } - - let total_size = read_u32(bytes, 12)?; - let actual_size = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?; - if u64::from(total_size) != actual_size { - return Err(Error::TotalSizeMismatch { - header: total_size, - actual: actual_size, - }); - } - - let directory_len = u64::try_from(entry_count) - .map_err(|_| Error::IntegerOverflow)? - .checked_mul(64) - .ok_or(Error::IntegerOverflow)?; - let directory_offset = - u64::from(total_size) - .checked_sub(directory_len) - .ok_or(Error::DirectoryOutOfBounds { - directory_offset: 0, - directory_len, - file_len: actual_size, - })?; - - if directory_offset < 16 || directory_offset + directory_len > actual_size { - return Err(Error::DirectoryOutOfBounds { - directory_offset, - directory_len, - file_len: actual_size, - }); - } - - let mut entries = Vec::with_capacity(entry_count); - for index in 0..entry_count { - let base = usize::try_from(directory_offset) - .map_err(|_| Error::IntegerOverflow)? - .checked_add(index.checked_mul(64).ok_or(Error::IntegerOverflow)?) - .ok_or(Error::IntegerOverflow)?; - - let kind = read_u32(bytes, base)?; - let attr1 = read_u32(bytes, base + 4)?; - let attr2 = read_u32(bytes, base + 8)?; - let data_size = read_u32(bytes, base + 12)?; - let attr3 = read_u32(bytes, base + 16)?; - - let mut name_raw = [0u8; 36]; - let name_slice = bytes - .get(base + 20..base + 56) - .ok_or(Error::IntegerOverflow)?; - name_raw.copy_from_slice(name_slice); - - let name_bytes = entry_name_bytes(&name_raw); - if name_bytes.len() > 35 { - return Err(Error::NameTooLong { - got: name_bytes.len(), - max: 35, - }); - } - - let data_offset = u64::from(read_u32(bytes, base + 56)?); - let sort_index = read_u32(bytes, base + 60)?; - - let end = data_offset - .checked_add(u64::from(data_size)) - .ok_or(Error::IntegerOverflow)?; - if data_offset < 16 || end > directory_offset { - return Err(Error::EntryDataOutOfBounds { - id: u32::try_from(index).map_err(|_| Error::IntegerOverflow)?, - offset: data_offset, - size: data_size, - directory_offset, - }); - } - - entries.push(EntryRecord { - meta: EntryMeta { - kind, - attr1, - attr2, - attr3, - name: decode_name(name_bytes), - data_offset, - data_size, - sort_index, - }, - name_raw, - }); - } - - Ok(( - entries, - Some(ArchiveHeader { - magic: *b"NRes", - version, - entry_count: u32::try_from(entry_count).map_err(|_| Error::IntegerOverflow)?, - total_size, - directory_offset, - directory_size: directory_len, - }), - )) -} - -fn checked_range(offset: u64, size: u32, bytes_len: usize) -> Result> { - let start = usize::try_from(offset).map_err(|_| Error::IntegerOverflow)?; - let len = usize::try_from(size).map_err(|_| Error::IntegerOverflow)?; - let end = start.checked_add(len).ok_or(Error::IntegerOverflow)?; - if end > bytes_len { - return Err(Error::IntegerOverflow); - } - Ok(start..end) -} - -fn read_u32(bytes: &[u8], offset: usize) -> Result { - let data = bytes - .get(offset..offset + 4) - .ok_or(Error::IntegerOverflow)?; - let arr: [u8; 4] = data.try_into().map_err(|_| Error::IntegerOverflow)?; - Ok(u32::from_le_bytes(arr)) -} - -fn push_u32(out: &mut Vec, value: u32) { - out.extend_from_slice(&value.to_le_bytes()); -} - -fn encode_name_field(name: &str) -> Result<[u8; 36]> { - let bytes = name.as_bytes(); - if bytes.contains(&0) { - return Err(Error::NameContainsNul); - } - if bytes.len() > 35 { - return Err(Error::NameTooLong { - got: bytes.len(), - max: 35, - }); - } - - let mut out = [0u8; 36]; - out[..bytes.len()].copy_from_slice(bytes); - Ok(out) -} - -fn entry_name_bytes(raw: &[u8; 36]) -> &[u8] { - let len = raw.iter().position(|&b| b == 0).unwrap_or(raw.len()); - &raw[..len] -} - -fn decode_name(name: &[u8]) -> String { - name.iter().map(|b| char::from(*b)).collect() -} - -fn cmp_name_case_insensitive(a: &[u8], b: &[u8]) -> Ordering { - let mut idx = 0usize; - let min_len = a.len().min(b.len()); - while idx < min_len { - let left = ascii_lower(a[idx]); - let right = ascii_lower(b[idx]); - if left != right { - return left.cmp(&right); - } - idx += 1; - } - a.len().cmp(&b.len()) -} - -fn ascii_lower(value: u8) -> u8 { - if value.is_ascii_uppercase() { - value + 32 - } else { - value - } -} - -fn saturating_u32_len(len: usize) -> u32 { - u32::try_from(len).unwrap_or(u32::MAX) -} - -fn prefetch_pages(bytes: &[u8]) { - use std::hint::black_box; - - let mut cursor = 0usize; - let mut sink = 0u8; - while cursor < bytes.len() { - sink ^= bytes[cursor]; - cursor = cursor.saturating_add(4096); - } - black_box(sink); -} - -fn write_atomic(path: &Path, content: &[u8]) -> Result<()> { - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("archive"); - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - - let mut temp_path = None; - for attempt in 0..128u32 { - let name = format!( - ".{}.tmp.{}.{}.{}", - file_name, - std::process::id(), - unix_time_nanos(), - attempt - ); - let candidate = parent.join(name); - let opened = FsOpenOptions::new() - .create_new(true) - .write(true) - .open(&candidate); - if let Ok(mut file) = opened { - file.write_all(content)?; - file.sync_all()?; - temp_path = Some((candidate, file)); - break; - } - } - - let Some((tmp_path, mut file)) = temp_path else { - return Err(Error::Io(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - "failed to create temporary file for atomic write", - ))); - }; - - file.flush()?; - drop(file); - - if let Err(err) = replace_file_atomically(&tmp_path, path) { - let _ = fs::remove_file(&tmp_path); - return Err(Error::Io(err)); - } - - Ok(()) -} - -#[cfg(not(windows))] -fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> { - fs::rename(src, dst) -} - -#[cfg(windows)] -fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> { - use std::iter; - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::Storage::FileSystem::{ - MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, - }; - - let src_wide: Vec = src.as_os_str().encode_wide().chain(iter::once(0)).collect(); - let dst_wide: Vec = dst.as_os_str().encode_wide().chain(iter::once(0)).collect(); - - // SAFETY: pointers reference NUL-terminated UTF-16 buffers that stay alive - // for the duration of the call; flags and argument contract match WinAPI. - let ok = unsafe { - MoveFileExW( - src_wide.as_ptr(), - dst_wide.as_ptr(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, - ) - }; - - if ok == 0 { - Err(std::io::Error::last_os_error()) - } else { - Ok(()) - } -} - -fn unix_time_nanos() -> u128 { - match SystemTime::now().duration_since(UNIX_EPOCH) { - Ok(duration) => duration.as_nanos(), - Err(_) => 0, - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/nres/src/tests.rs b/crates/nres/src/tests.rs deleted file mode 100644 index bfa75a8..0000000 --- a/crates/nres/src/tests.rs +++ /dev/null @@ -1,983 +0,0 @@ -use super::*; -use common::collect_files_recursive; -use std::any::Any; -use std::fs; -use std::panic::{catch_unwind, AssertUnwindSafe}; - -#[derive(Clone)] -struct SyntheticEntry<'a> { - kind: u32, - attr1: u32, - attr2: u32, - attr3: u32, - name: &'a str, - data: &'a [u8], -} - -fn nres_test_files() -> Vec { - let root = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("testdata") - .join("nres"); - let mut files = Vec::new(); - collect_files_recursive(&root, &mut files); - files.sort(); - files - .into_iter() - .filter(|path| { - fs::read(path) - .map(|data| data.get(0..4) == Some(b"NRes")) - .unwrap_or(false) - }) - .collect() -} - -fn make_temp_copy(original: &Path, bytes: &[u8]) -> PathBuf { - let mut path = std::env::temp_dir(); - let file_name = original - .file_name() - .and_then(|v| v.to_str()) - .unwrap_or("archive"); - path.push(format!( - "nres-test-{}-{}-{}", - std::process::id(), - unix_time_nanos(), - file_name - )); - fs::write(&path, bytes).expect("failed to create temp file"); - path -} - -fn panic_message(payload: Box) -> String { - let any = payload.as_ref(); - if let Some(message) = any.downcast_ref::() { - return message.clone(); - } - if let Some(message) = any.downcast_ref::<&str>() { - return (*message).to_string(); - } - String::from("panic without message") -} - -fn read_u32_le(bytes: &[u8], offset: usize) -> u32 { - let slice = bytes - .get(offset..offset + 4) - .expect("u32 read out of bounds in test"); - let arr: [u8; 4] = slice.try_into().expect("u32 conversion failed in test"); - u32::from_le_bytes(arr) -} - -fn read_i32_le(bytes: &[u8], offset: usize) -> i32 { - let slice = bytes - .get(offset..offset + 4) - .expect("i32 read out of bounds in test"); - let arr: [u8; 4] = slice.try_into().expect("i32 conversion failed in test"); - i32::from_le_bytes(arr) -} - -fn name_field_bytes(raw: &[u8; 36]) -> Option<&[u8]> { - let nul = raw.iter().position(|value| *value == 0)?; - Some(&raw[..nul]) -} - -fn build_nres_bytes(entries: &[SyntheticEntry<'_>]) -> Vec { - let mut out = vec![0u8; 16]; - let mut offsets = Vec::with_capacity(entries.len()); - - for entry in entries { - offsets.push(u32::try_from(out.len()).expect("offset overflow")); - out.extend_from_slice(entry.data); - let padding = (8 - (out.len() % 8)) % 8; - if padding > 0 { - out.resize(out.len() + padding, 0); - } - } - - let mut sort_order: Vec = (0..entries.len()).collect(); - sort_order.sort_by(|a, b| { - cmp_name_case_insensitive(entries[*a].name.as_bytes(), entries[*b].name.as_bytes()) - }); - - for (index, entry) in entries.iter().enumerate() { - let mut name_raw = [0u8; 36]; - let name_bytes = entry.name.as_bytes(); - assert!(name_bytes.len() <= 35, "name too long in fixture"); - name_raw[..name_bytes.len()].copy_from_slice(name_bytes); - - push_u32(&mut out, entry.kind); - push_u32(&mut out, entry.attr1); - push_u32(&mut out, entry.attr2); - push_u32( - &mut out, - u32::try_from(entry.data.len()).expect("data size overflow"), - ); - push_u32(&mut out, entry.attr3); - out.extend_from_slice(&name_raw); - push_u32(&mut out, offsets[index]); - push_u32( - &mut out, - u32::try_from(sort_order[index]).expect("sort index overflow"), - ); - } - - 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 overflow") - .to_le_bytes(), - ); - let total_size = u32::try_from(out.len()).expect("size overflow"); - out[12..16].copy_from_slice(&total_size.to_le_bytes()); - out -} - -#[test] -fn nres_docs_structural_invariants_all_files() { - let files = nres_test_files(); - if files.is_empty() { - eprintln!( - "skipping nres_docs_structural_invariants_all_files: no NRes archives in testdata/nres" - ); - return; - } - - for path in files { - let bytes = fs::read(&path).unwrap_or_else(|err| { - panic!("failed to read {}: {err}", path.display()); - }); - - assert!( - bytes.len() >= 16, - "NRes header too short in {}", - path.display() - ); - assert_eq!(&bytes[0..4], b"NRes", "bad magic in {}", path.display()); - assert_eq!( - read_u32_le(&bytes, 4), - 0x100, - "bad version in {}", - path.display() - ); - assert_eq!( - usize::try_from(read_u32_le(&bytes, 12)).expect("size overflow"), - bytes.len(), - "header.total_size mismatch in {}", - path.display() - ); - - let entry_count_i32 = read_i32_le(&bytes, 8); - assert!( - entry_count_i32 >= 0, - "negative entry_count={} in {}", - entry_count_i32, - path.display() - ); - let entry_count = usize::try_from(entry_count_i32).expect("entry_count overflow"); - let directory_len = entry_count.checked_mul(64).expect("directory_len overflow"); - let directory_offset = bytes - .len() - .checked_sub(directory_len) - .unwrap_or_else(|| panic!("directory underflow in {}", path.display())); - assert!( - directory_offset >= 16, - "directory offset before data area in {}", - path.display() - ); - assert_eq!( - directory_offset + directory_len, - bytes.len(), - "directory not at file end in {}", - path.display() - ); - - let mut sort_indices = Vec::with_capacity(entry_count); - let mut entries = Vec::with_capacity(entry_count); - for index in 0..entry_count { - let base = directory_offset + index * 64; - let size = usize::try_from(read_u32_le(&bytes, base + 12)).expect("size overflow"); - let data_offset = - usize::try_from(read_u32_le(&bytes, base + 56)).expect("offset overflow"); - let sort_index = - usize::try_from(read_u32_le(&bytes, base + 60)).expect("sort_index overflow"); - - let mut name_raw = [0u8; 36]; - name_raw.copy_from_slice( - bytes - .get(base + 20..base + 56) - .expect("name field out of bounds in test"), - ); - let name_bytes = name_field_bytes(&name_raw).unwrap_or_else(|| { - panic!( - "name field without NUL terminator in {} entry #{index}", - path.display() - ) - }); - assert!( - name_bytes.len() <= 35, - "name longer than 35 bytes in {} entry #{index}", - path.display() - ); - - sort_indices.push(sort_index); - entries.push((name_bytes.to_vec(), data_offset, size)); - } - - let mut expected_sort: Vec = (0..entry_count).collect(); - expected_sort.sort_by(|a, b| cmp_name_case_insensitive(&entries[*a].0, &entries[*b].0)); - assert_eq!( - sort_indices, - expected_sort, - "sort_index table mismatch in {}", - path.display() - ); - - let mut data_regions: Vec<(usize, usize)> = - entries.iter().map(|(_, off, size)| (*off, *size)).collect(); - data_regions.sort_by_key(|(off, _)| *off); - - for (idx, (data_offset, size)) in data_regions.iter().enumerate() { - assert_eq!( - data_offset % 8, - 0, - "data offset is not 8-byte aligned in {} (region #{idx})", - path.display() - ); - assert!( - *data_offset >= 16, - "data offset before header end in {} (region #{idx})", - path.display() - ); - assert!( - data_offset.checked_add(*size).unwrap_or(usize::MAX) <= directory_offset, - "data region overlaps directory in {} (region #{idx})", - path.display() - ); - } - - for pair in data_regions.windows(2) { - let (start, size) = pair[0]; - let (next_start, _) = pair[1]; - let end = start - .checked_add(size) - .unwrap_or_else(|| panic!("size overflow in {}", path.display())); - assert!( - end <= next_start, - "overlapping data regions in {}: [{start}, {end}) and next at {next_start}", - path.display() - ); - - for (offset, value) in bytes[end..next_start].iter().enumerate() { - assert_eq!( - *value, - 0, - "non-zero alignment padding in {} at offset {}", - path.display(), - end + offset - ); - } - } - } -} - -#[test] -fn nres_read_and_roundtrip_all_files() { - let files = nres_test_files(); - if files.is_empty() { - eprintln!("skipping nres_read_and_roundtrip_all_files: no NRes archives in testdata/nres"); - return; - } - - let checked = files.len(); - let mut success = 0usize; - let mut failures = Vec::new(); - - for path in files { - let display_path = path.display().to_string(); - let result = catch_unwind(AssertUnwindSafe(|| { - let original = fs::read(&path).expect("failed to read archive"); - let archive = Archive::open_path(&path) - .unwrap_or_else(|err| panic!("failed to open {}: {err}", path.display())); - - let count = archive.entry_count(); - assert_eq!( - count, - archive.entries().count(), - "entry count mismatch: {}", - path.display() - ); - - for idx in 0..count { - let id = EntryId(idx as u32); - let entry = archive - .get(id) - .unwrap_or_else(|| panic!("missing entry #{idx} in {}", path.display())); - - let payload = archive.read(id).unwrap_or_else(|err| { - panic!("read failed for {} entry #{idx}: {err}", path.display()) - }); - - let mut out = Vec::new(); - let written = archive.read_into(id, &mut out).unwrap_or_else(|err| { - panic!( - "read_into failed for {} entry #{idx}: {err}", - path.display() - ) - }); - assert_eq!( - written, - payload.as_slice().len(), - "size mismatch in {} entry #{idx}", - path.display() - ); - assert_eq!( - out.as_slice(), - payload.as_slice(), - "payload mismatch in {} entry #{idx}", - path.display() - ); - - let raw = archive - .raw_slice(id) - .unwrap_or_else(|err| { - panic!( - "raw_slice failed for {} entry #{idx}: {err}", - path.display() - ) - }) - .expect("raw_slice must return Some for file-backed archive"); - assert_eq!( - raw, - payload.as_slice(), - "raw slice mismatch in {} entry #{idx}", - path.display() - ); - - let found = archive.find(&entry.meta.name).unwrap_or_else(|| { - panic!( - "find failed for name '{}' in {}", - entry.meta.name, - path.display() - ) - }); - let found_meta = archive.get(found).expect("find returned invalid id"); - assert!( - found_meta.meta.name.eq_ignore_ascii_case(&entry.meta.name), - "find returned unrelated entry in {}", - path.display() - ); - } - - let temp_copy = make_temp_copy(&path, &original); - let mut editor = Archive::edit_path(&temp_copy) - .unwrap_or_else(|err| panic!("edit_path failed for {}: {err}", path.display())); - - for idx in 0..count { - let data = archive - .read(EntryId(idx as u32)) - .unwrap_or_else(|err| { - panic!( - "read before replace failed for {} entry #{idx}: {err}", - path.display() - ) - }) - .into_owned(); - editor - .replace_data(EntryId(idx as u32), &data) - .unwrap_or_else(|err| { - panic!( - "replace_data failed for {} entry #{idx}: {err}", - path.display() - ) - }); - } - - editor - .commit() - .unwrap_or_else(|err| panic!("commit failed for {}: {err}", path.display())); - let rebuilt = fs::read(&temp_copy).expect("failed to read rebuilt archive"); - let _ = fs::remove_file(&temp_copy); - - assert_eq!( - original, - rebuilt, - "byte-to-byte roundtrip mismatch for {}", - path.display() - ); - })); - - match result { - Ok(()) => success += 1, - Err(payload) => { - failures.push(format!("{}: {}", display_path, panic_message(payload))); - } - } - } - - let failed = failures.len(); - eprintln!( - "NRes summary: checked={}, success={}, failed={}", - checked, success, failed - ); - if !failures.is_empty() { - panic!( - "NRes validation failed.\nsummary: checked={}, success={}, failed={}\n{}", - checked, - success, - failed, - failures.join("\n") - ); - } -} - -#[test] -fn nres_raw_mode_exposes_whole_file() { - let files = nres_test_files(); - let Some(first) = files.first() else { - eprintln!("skipping nres_raw_mode_exposes_whole_file: no NRes archives in testdata/nres"); - return; - }; - let original = fs::read(first).expect("failed to read archive"); - let arc: Arc<[u8]> = Arc::from(original.clone().into_boxed_slice()); - - let archive = Archive::open_bytes( - arc, - OpenOptions { - raw_mode: true, - sequential_hint: false, - prefetch_pages: false, - }, - ) - .expect("raw mode open failed"); - - assert_eq!(archive.entry_count(), 1); - let data = archive.read(EntryId(0)).expect("raw read failed"); - assert_eq!(data.as_slice(), original.as_slice()); -} - -#[test] -fn nres_raw_mode_accepts_non_nres_bytes() { - let payload = b"not-an-nres-archive".to_vec(); - let bytes: Arc<[u8]> = Arc::from(payload.clone().into_boxed_slice()); - - match Archive::open_bytes(bytes.clone(), OpenOptions::default()) { - Err(Error::InvalidMagic { .. }) => {} - other => panic!("expected InvalidMagic without raw_mode, got {other:?}"), - } - - let archive = Archive::open_bytes( - bytes, - OpenOptions { - raw_mode: true, - sequential_hint: false, - prefetch_pages: false, - }, - ) - .expect("raw_mode should accept any bytes"); - - assert_eq!(archive.entry_count(), 1); - assert_eq!(archive.find("raw"), Some(EntryId(0))); - assert_eq!( - archive - .read(EntryId(0)) - .expect("raw read failed") - .as_slice(), - payload.as_slice() - ); -} - -#[test] -fn nres_open_options_hints_do_not_change_payload() { - let payload: Vec = (0..70_000u32).map(|v| (v % 251) as u8).collect(); - let src = build_nres_bytes(&[SyntheticEntry { - kind: 7, - attr1: 70, - attr2: 700, - attr3: 7000, - name: "big.bin", - data: &payload, - }]); - let arc: Arc<[u8]> = Arc::from(src.into_boxed_slice()); - - let baseline = Archive::open_bytes(arc.clone(), OpenOptions::default()) - .expect("baseline open should succeed"); - let hinted = Archive::open_bytes( - arc, - OpenOptions { - raw_mode: false, - sequential_hint: true, - prefetch_pages: true, - }, - ) - .expect("open with hints should succeed"); - - assert_eq!(baseline.entry_count(), 1); - assert_eq!(hinted.entry_count(), 1); - assert_eq!(baseline.find("BIG.BIN"), Some(EntryId(0))); - assert_eq!(hinted.find("big.bin"), Some(EntryId(0))); - assert_eq!( - baseline - .read(EntryId(0)) - .expect("baseline read failed") - .as_slice(), - hinted - .read(EntryId(0)) - .expect("hinted read failed") - .as_slice() - ); -} - -#[test] -fn nres_commit_empty_archive_has_minimal_layout() { - let mut path = std::env::temp_dir(); - path.push(format!( - "nres-empty-commit-{}-{}.lib", - std::process::id(), - unix_time_nanos() - )); - fs::write(&path, build_nres_bytes(&[])).expect("write empty archive failed"); - - Archive::edit_path(&path) - .expect("edit_path failed for empty archive") - .commit() - .expect("commit failed for empty archive"); - - let bytes = fs::read(&path).expect("failed to read committed archive"); - assert_eq!(bytes.len(), 16, "empty archive must contain only header"); - assert_eq!(&bytes[0..4], b"NRes"); - assert_eq!(read_u32_le(&bytes, 4), 0x100); - assert_eq!(read_u32_le(&bytes, 8), 0); - assert_eq!(read_u32_le(&bytes, 12), 16); - - let _ = fs::remove_file(&path); -} - -#[test] -fn nres_commit_recomputes_header_directory_and_sort_table() { - let mut path = std::env::temp_dir(); - path.push(format!( - "nres-commit-layout-{}-{}.lib", - std::process::id(), - unix_time_nanos() - )); - fs::write(&path, build_nres_bytes(&[])).expect("write empty archive failed"); - - let mut editor = Archive::edit_path(&path).expect("edit_path failed"); - editor - .add(NewEntry { - kind: 10, - attr1: 1, - attr2: 2, - attr3: 3, - name: "Zulu", - data: b"aaaaa", - }) - .expect("add #0 failed"); - editor - .add(NewEntry { - kind: 11, - attr1: 4, - attr2: 5, - attr3: 6, - name: "alpha", - data: b"bbbbbbbb", - }) - .expect("add #1 failed"); - editor - .add(NewEntry { - kind: 12, - attr1: 7, - attr2: 8, - attr3: 9, - name: "Beta", - data: b"cccc", - }) - .expect("add #2 failed"); - editor.commit().expect("commit failed"); - - let bytes = fs::read(&path).expect("failed to read committed archive"); - assert_eq!(&bytes[0..4], b"NRes"); - assert_eq!(read_u32_le(&bytes, 4), 0x100); - - let entry_count = usize::try_from(read_u32_le(&bytes, 8)).expect("entry_count overflow"); - let total_size = usize::try_from(read_u32_le(&bytes, 12)).expect("total_size overflow"); - assert_eq!(entry_count, 3); - assert_eq!(total_size, bytes.len()); - - let directory_offset = total_size - .checked_sub(entry_count * 64) - .expect("invalid directory offset"); - assert!(directory_offset >= 16); - - let mut sort_indices = Vec::new(); - let mut prev_data_end = 16usize; - for idx in 0..entry_count { - let base = directory_offset + idx * 64; - let data_size = usize::try_from(read_u32_le(&bytes, base + 12)).expect("size overflow"); - let data_offset = usize::try_from(read_u32_le(&bytes, base + 56)).expect("offset overflow"); - let sort_index = - usize::try_from(read_u32_le(&bytes, base + 60)).expect("sort index overflow"); - - assert_eq!( - data_offset % 8, - 0, - "entry #{idx} data offset must be 8-byte aligned" - ); - assert!( - data_offset >= prev_data_end, - "entry #{idx} offset regressed" - ); - assert!( - data_offset + data_size <= directory_offset, - "entry #{idx} overlaps directory" - ); - prev_data_end = data_offset + data_size; - sort_indices.push(sort_index); - } - - let names = ["Zulu", "alpha", "Beta"]; - let mut expected_sort: Vec = (0..names.len()).collect(); - expected_sort - .sort_by(|a, b| cmp_name_case_insensitive(names[*a].as_bytes(), names[*b].as_bytes())); - assert_eq!( - sort_indices, expected_sort, - "sort table must contain original indexes in case-insensitive alphabetical order" - ); - - let archive = Archive::open_path(&path).expect("re-open failed"); - assert_eq!(archive.find("zulu"), Some(EntryId(0))); - assert_eq!(archive.find("ALPHA"), Some(EntryId(1))); - assert_eq!(archive.find("beta"), Some(EntryId(2))); - - let _ = fs::remove_file(&path); -} - -#[test] -fn nres_synthetic_read_find_and_edit() { - let payload_a = b"alpha"; - let payload_b = b"B"; - let payload_c = b""; - let src = build_nres_bytes(&[ - SyntheticEntry { - kind: 1, - attr1: 10, - attr2: 20, - attr3: 30, - name: "Alpha.TXT", - data: payload_a, - }, - SyntheticEntry { - kind: 2, - attr1: 11, - attr2: 21, - attr3: 31, - name: "beta.bin", - data: payload_b, - }, - SyntheticEntry { - kind: 3, - attr1: 12, - attr2: 22, - attr3: 32, - name: "Gamma", - data: payload_c, - }, - ]); - - let archive = Archive::open_bytes( - Arc::from(src.clone().into_boxed_slice()), - OpenOptions::default(), - ) - .expect("open synthetic nres failed"); - - assert_eq!(archive.entry_count(), 3); - assert_eq!(archive.find("alpha.txt"), Some(EntryId(0))); - assert_eq!(archive.find("BETA.BIN"), Some(EntryId(1))); - assert_eq!(archive.find("gAmMa"), Some(EntryId(2))); - assert_eq!(archive.find("missing"), None); - - assert_eq!( - archive.read(EntryId(0)).expect("read #0 failed").as_slice(), - payload_a - ); - assert_eq!( - archive.read(EntryId(1)).expect("read #1 failed").as_slice(), - payload_b - ); - assert_eq!( - archive.read(EntryId(2)).expect("read #2 failed").as_slice(), - payload_c - ); - - let mut path = std::env::temp_dir(); - path.push(format!( - "nres-synth-edit-{}-{}.lib", - std::process::id(), - unix_time_nanos() - )); - fs::write(&path, &src).expect("write temp synthetic archive failed"); - - let mut editor = Archive::edit_path(&path).expect("edit_path on synthetic archive failed"); - editor - .replace_data(EntryId(1), b"replaced") - .expect("replace_data failed"); - let added = editor - .add(NewEntry { - kind: 4, - attr1: 13, - attr2: 23, - attr3: 33, - name: "delta", - data: b"new payload", - }) - .expect("add failed"); - assert_eq!(added, EntryId(3)); - editor.remove(EntryId(2)).expect("remove failed"); - editor.commit().expect("commit failed"); - - let edited = Archive::open_path(&path).expect("re-open edited archive failed"); - assert_eq!(edited.entry_count(), 3); - assert_eq!( - edited - .read(edited.find("beta.bin").expect("find beta.bin failed")) - .expect("read beta.bin failed") - .as_slice(), - b"replaced" - ); - assert_eq!( - edited - .read(edited.find("delta").expect("find delta failed")) - .expect("read delta failed") - .as_slice(), - b"new payload" - ); - assert_eq!(edited.find("gamma"), None); - - let _ = fs::remove_file(&path); -} - -#[test] -fn nres_max_name_length_roundtrip() { - let max_name = "12345678901234567890123456789012345"; - assert_eq!(max_name.len(), 35); - - let src = build_nres_bytes(&[SyntheticEntry { - kind: 9, - attr1: 1, - attr2: 2, - attr3: 3, - name: max_name, - data: b"payload", - }]); - - let archive = Archive::open_bytes(Arc::from(src.into_boxed_slice()), OpenOptions::default()) - .expect("open synthetic nres failed"); - - assert_eq!(archive.entry_count(), 1); - assert_eq!(archive.find(max_name), Some(EntryId(0))); - assert_eq!( - archive.find(&max_name.to_ascii_lowercase()), - Some(EntryId(0)) - ); - - let entry = archive.get(EntryId(0)).expect("missing entry 0"); - assert_eq!(entry.meta.name, max_name); - assert_eq!( - archive - .read(EntryId(0)) - .expect("read payload failed") - .as_slice(), - b"payload" - ); -} - -#[test] -fn nres_find_falls_back_when_sort_index_is_out_of_range() { - let mut bytes = build_nres_bytes(&[ - SyntheticEntry { - kind: 1, - attr1: 0, - attr2: 0, - attr3: 0, - name: "Alpha", - data: b"a", - }, - SyntheticEntry { - kind: 2, - attr1: 0, - attr2: 0, - attr3: 0, - name: "Beta", - data: b"b", - }, - SyntheticEntry { - kind: 3, - attr1: 0, - attr2: 0, - attr3: 0, - name: "Gamma", - data: b"c", - }, - ]); - - let entry_count = 3usize; - let directory_offset = bytes - .len() - .checked_sub(entry_count * 64) - .expect("directory offset underflow"); - let mid_entry_sort_index = directory_offset + 64 + 60; - bytes[mid_entry_sort_index..mid_entry_sort_index + 4].copy_from_slice(&u32::MAX.to_le_bytes()); - - let archive = Archive::open_bytes(Arc::from(bytes.into_boxed_slice()), OpenOptions::default()) - .expect("open archive with corrupted sort index failed"); - - assert_eq!(archive.find("alpha"), Some(EntryId(0))); - assert_eq!(archive.find("BETA"), Some(EntryId(1))); - assert_eq!(archive.find("gamma"), Some(EntryId(2))); - assert_eq!(archive.find("missing"), None); -} - -#[test] -fn nres_validation_error_cases() { - let valid = build_nres_bytes(&[SyntheticEntry { - kind: 1, - attr1: 2, - attr2: 3, - attr3: 4, - name: "ok", - data: b"1234", - }]); - - let mut invalid_magic = valid.clone(); - invalid_magic[0..4].copy_from_slice(b"FAIL"); - match Archive::open_bytes( - Arc::from(invalid_magic.into_boxed_slice()), - OpenOptions::default(), - ) { - Err(Error::InvalidMagic { .. }) => {} - other => panic!("expected InvalidMagic, got {other:?}"), - } - - let mut invalid_version = valid.clone(); - invalid_version[4..8].copy_from_slice(&0x200_u32.to_le_bytes()); - match Archive::open_bytes( - Arc::from(invalid_version.into_boxed_slice()), - OpenOptions::default(), - ) { - Err(Error::UnsupportedVersion { got }) => assert_eq!(got, 0x200), - other => panic!("expected UnsupportedVersion, got {other:?}"), - } - - let mut bad_total = valid.clone(); - bad_total[12..16].copy_from_slice(&0_u32.to_le_bytes()); - match Archive::open_bytes( - Arc::from(bad_total.into_boxed_slice()), - OpenOptions::default(), - ) { - Err(Error::TotalSizeMismatch { .. }) => {} - other => panic!("expected TotalSizeMismatch, got {other:?}"), - } - - let mut bad_count = valid.clone(); - bad_count[8..12].copy_from_slice(&(-1_i32).to_le_bytes()); - match Archive::open_bytes( - Arc::from(bad_count.into_boxed_slice()), - OpenOptions::default(), - ) { - Err(Error::InvalidEntryCount { got }) => assert_eq!(got, -1), - other => panic!("expected InvalidEntryCount, got {other:?}"), - } - - let mut bad_dir = valid.clone(); - bad_dir[8..12].copy_from_slice(&1000_u32.to_le_bytes()); - match Archive::open_bytes( - Arc::from(bad_dir.into_boxed_slice()), - OpenOptions::default(), - ) { - Err(Error::DirectoryOutOfBounds { .. }) => {} - other => panic!("expected DirectoryOutOfBounds, got {other:?}"), - } - - let mut long_name = valid.clone(); - let entry_base = long_name.len() - 64; - for b in &mut long_name[entry_base + 20..entry_base + 56] { - *b = b'X'; - } - match Archive::open_bytes( - Arc::from(long_name.into_boxed_slice()), - OpenOptions::default(), - ) { - Err(Error::NameTooLong { .. }) => {} - other => panic!("expected NameTooLong, got {other:?}"), - } - - let mut bad_data = valid.clone(); - bad_data[entry_base + 56..entry_base + 60].copy_from_slice(&12_u32.to_le_bytes()); - bad_data[entry_base + 12..entry_base + 16].copy_from_slice(&32_u32.to_le_bytes()); - match Archive::open_bytes( - Arc::from(bad_data.into_boxed_slice()), - OpenOptions::default(), - ) { - Err(Error::EntryDataOutOfBounds { .. }) => {} - other => panic!("expected EntryDataOutOfBounds, got {other:?}"), - } - - let archive = Archive::open_bytes(Arc::from(valid.into_boxed_slice()), OpenOptions::default()) - .expect("open valid archive failed"); - match archive.read(EntryId(99)) { - Err(Error::EntryIdOutOfRange { .. }) => {} - other => panic!("expected EntryIdOutOfRange, got {other:?}"), - } -} - -#[test] -fn nres_editor_validation_error_cases() { - let mut path = std::env::temp_dir(); - path.push(format!( - "nres-editor-errors-{}-{}.lib", - std::process::id(), - unix_time_nanos() - )); - let src = build_nres_bytes(&[]); - fs::write(&path, src).expect("write empty archive failed"); - - let mut editor = Archive::edit_path(&path).expect("edit_path failed"); - - let long_name = "X".repeat(36); - match editor.add(NewEntry { - kind: 0, - attr1: 0, - attr2: 0, - attr3: 0, - name: &long_name, - data: b"", - }) { - Err(Error::NameTooLong { .. }) => {} - other => panic!("expected NameTooLong, got {other:?}"), - } - - match editor.add(NewEntry { - kind: 0, - attr1: 0, - attr2: 0, - attr3: 0, - name: "bad\0name", - data: b"", - }) { - Err(Error::NameContainsNul) => {} - other => panic!("expected NameContainsNul, got {other:?}"), - } - - match editor.replace_data(EntryId(0), b"x") { - Err(Error::EntryIdOutOfRange { .. }) => {} - other => panic!("expected EntryIdOutOfRange, got {other:?}"), - } - - match editor.remove(EntryId(0)) { - Err(Error::EntryIdOutOfRange { .. }) => {} - other => panic!("expected EntryIdOutOfRange, got {other:?}"), - } - - let _ = fs::remove_file(&path); -} diff --git a/crates/render-core/Cargo.toml b/crates/render-core/Cargo.toml deleted file mode 100644 index c93d624..0000000 --- a/crates/render-core/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "render-core" -version = "0.1.0" -edition = "2021" - -[dependencies] -msh-core = { path = "../msh-core" } - -[dev-dependencies] -common = { path = "../common" } -nres = { path = "../nres" } diff --git a/crates/render-core/README.md b/crates/render-core/README.md deleted file mode 100644 index a58f64f..0000000 --- a/crates/render-core/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# render-core - -CPU-подготовка draw-данных для моделей `MSH`. - -Покрывает: - -- обход `node -> slot -> batch`; -- раскрытие индексов в triangle-list (`position + uv0`); -- расчёт bounds по вершинам. - -Тесты: - -- построение рендер-сеток на реальных `.msh` из `testdata`; -- unit-test bounds. diff --git a/crates/render-core/src/lib.rs b/crates/render-core/src/lib.rs deleted file mode 100644 index c7a69d6..0000000 --- a/crates/render-core/src/lib.rs +++ /dev/null @@ -1,146 +0,0 @@ -use msh_core::Model; -use std::collections::HashMap; - -pub const DEFAULT_UV_SCALE: f32 = 1024.0; - -#[derive(Clone, Debug)] -pub struct RenderVertex { - pub position: [f32; 3], - pub uv0: [f32; 2], -} - -#[derive(Clone, Debug)] -pub struct RenderMesh { - pub vertices: Vec, - pub indices: Vec, - pub batch_count: usize, - pub index_overflow: bool, -} - -impl RenderMesh { - pub fn triangle_count(&self) -> usize { - self.indices.len() / 3 - } -} - -/// Builds an indexed triangle mesh for a specific LOD/group pair. -pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh { - let mut vertices = Vec::new(); - let mut indices = Vec::new(); - let mut index_remap: HashMap = HashMap::new(); - let mut batch_count = 0usize; - let mut index_overflow = false; - let uv0 = model.uv0.as_ref(); - - for node_index in 0..model.node_count { - let Some(slot_idx) = model.slot_index(node_index, lod, group) else { - continue; - }; - let Some(slot) = model.slots.get(slot_idx) else { - continue; - }; - let batch_start = usize::from(slot.batch_start); - let batch_end = batch_start.saturating_add(usize::from(slot.batch_count)); - if batch_end > model.batches.len() { - continue; - } - - for batch in &model.batches[batch_start..batch_end] { - let index_start = usize::try_from(batch.index_start).unwrap_or(usize::MAX); - let index_count = usize::from(batch.index_count); - let index_end = index_start.saturating_add(index_count); - if index_end > model.indices.len() || index_count < 3 { - continue; - } - - let batch_out_start = indices.len(); - let mut batch_valid = true; - for &idx in &model.indices[index_start..index_end] { - let final_idx_u64 = u64::from(batch.base_vertex).saturating_add(u64::from(idx)); - let Ok(final_idx) = usize::try_from(final_idx_u64) else { - batch_valid = false; - break; - }; - let Some(pos) = model.positions.get(final_idx) else { - batch_valid = false; - break; - }; - - let local_index = if let Some(&mapped) = index_remap.get(&final_idx) { - mapped - } else { - let Ok(mapped) = u16::try_from(vertices.len()) else { - index_overflow = true; - batch_valid = false; - break; - }; - let uv = uv0 - .and_then(|uvs| uvs.get(final_idx)) - .copied() - .map(|packed| { - [ - packed[0] as f32 / DEFAULT_UV_SCALE, - packed[1] as f32 / DEFAULT_UV_SCALE, - ] - }) - .unwrap_or([0.0, 0.0]); - vertices.push(RenderVertex { - position: *pos, - uv0: uv, - }); - index_remap.insert(final_idx, mapped); - mapped - }; - - indices.push(local_index); - } - - if !batch_valid { - indices.truncate(batch_out_start); - continue; - } - - batch_count += 1; - } - } - - RenderMesh { - vertices, - indices, - batch_count, - index_overflow, - } -} - -pub fn compute_bounds(vertices: &[[f32; 3]]) -> Option<([f32; 3], [f32; 3])> { - compute_bounds_impl(vertices.iter().copied()) -} - -pub fn compute_bounds_for_mesh(vertices: &[RenderVertex]) -> Option<([f32; 3], [f32; 3])> { - compute_bounds_impl(vertices.iter().map(|v| v.position)) -} - -fn compute_bounds_impl(mut positions: I) -> Option<([f32; 3], [f32; 3])> -where - I: Iterator, -{ - let first = positions.next()?; - let mut min_v = first; - let mut max_v = first; - - for pos in positions { - for i in 0..3 { - if pos[i] < min_v[i] { - min_v[i] = pos[i]; - } - if pos[i] > max_v[i] { - max_v[i] = pos[i]; - } - } - } - - Some((min_v, max_v)) -} - -#[cfg(test)] -mod tests; diff --git a/crates/render-core/src/tests.rs b/crates/render-core/src/tests.rs deleted file mode 100644 index 1c5285e..0000000 --- a/crates/render-core/src/tests.rs +++ /dev/null @@ -1,256 +0,0 @@ -use super::*; -use common::collect_files_recursive; -use msh_core::parse_model_payload; -use nres::Archive; -use std::fs; -use std::path::{Path, PathBuf}; - -fn nres_test_files() -> Vec { - let root = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("testdata"); - let mut files = Vec::new(); - collect_files_recursive(&root, &mut files); - files.sort(); - files - .into_iter() - .filter(|path| { - fs::read(path) - .map(|bytes| bytes.get(0..4) == Some(b"NRes")) - .unwrap_or(false) - }) - .collect() -} - -#[test] -fn build_render_mesh_for_real_models() { - let archives = nres_test_files(); - if archives.is_empty() { - eprintln!("skipping build_render_mesh_for_real_models: no NRes files in testdata"); - return; - } - - let mut models_checked = 0usize; - let mut meshes_non_empty = 0usize; - let mut bounds_non_empty = 0usize; - - for archive_path in archives { - let archive = Archive::open_path(&archive_path) - .unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display())); - for entry in archive.entries() { - if !entry.meta.name.to_ascii_lowercase().ends_with(".msh") { - continue; - } - models_checked += 1; - let payload = archive.read(entry.id).unwrap_or_else(|err| { - panic!( - "failed to read model '{}' from {}: {err}", - entry.meta.name, - archive_path.display() - ) - }); - let model = parse_model_payload(payload.as_slice()).unwrap_or_else(|err| { - panic!( - "failed to parse model '{}' from {}: {err}", - entry.meta.name, - archive_path.display() - ) - }); - let mesh = build_render_mesh(&model, 0, 0); - if !mesh.indices.is_empty() { - meshes_non_empty += 1; - } - if compute_bounds_for_mesh(&mesh.vertices).is_some() { - bounds_non_empty += 1; - } - for &index in &mesh.indices { - assert!( - usize::from(index) < mesh.vertices.len(), - "index out of bounds for '{}' in {}", - entry.meta.name, - archive_path.display() - ); - } - for vertex in &mesh.vertices { - assert!( - vertex.uv0[0].is_finite() && vertex.uv0[1].is_finite(), - "UV must be finite for '{}' in {}", - entry.meta.name, - archive_path.display() - ); - } - } - } - - assert!(models_checked > 0, "no MSH models found"); - assert!( - meshes_non_empty > 0, - "all generated render meshes are empty" - ); - assert_eq!( - meshes_non_empty, bounds_non_empty, - "bounds must be available for every non-empty mesh" - ); -} - -#[test] -fn compute_bounds_handles_empty_and_non_empty() { - assert!(compute_bounds(&[]).is_none()); - let bounds = compute_bounds(&[[1.0, 2.0, 3.0], [-2.0, 5.0, 0.5], [0.0, -1.0, 9.0]]) - .expect("bounds expected"); - assert_eq!(bounds.0, [-2.0, -1.0, 0.5]); - assert_eq!(bounds.1, [1.0, 5.0, 9.0]); -} - -#[test] -fn compute_bounds_for_mesh_handles_empty_and_non_empty() { - assert!(compute_bounds_for_mesh(&[]).is_none()); - let bounds = compute_bounds_for_mesh(&[ - RenderVertex { - position: [1.0, 2.0, 3.0], - uv0: [0.0, 0.0], - }, - RenderVertex { - position: [-2.0, 5.0, 0.5], - uv0: [0.2, 0.3], - }, - RenderVertex { - position: [0.0, -1.0, 9.0], - uv0: [1.0, 1.0], - }, - ]) - .expect("bounds expected"); - assert_eq!(bounds.0, [-2.0, -1.0, 0.5]); - assert_eq!(bounds.1, [1.0, 5.0, 9.0]); -} - -fn nodes_with_slot_refs(slot_ids: &[Option]) -> Vec { - let mut out = vec![0u8; slot_ids.len().saturating_mul(38)]; - for (node_index, slot_id) in slot_ids.iter().copied().enumerate() { - let node_off = node_index * 38; - for i in 0..15 { - let off = node_off + 8 + i * 2; - out[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes()); - } - if let Some(slot_id) = slot_id { - out[node_off + 8..node_off + 10].copy_from_slice(&slot_id.to_le_bytes()); - } - } - out -} - -fn slot(batch_start: u16, batch_count: u16) -> msh_core::Slot { - msh_core::Slot { - tri_start: 0, - tri_count: 0, - batch_start, - batch_count, - aabb_min: [0.0; 3], - aabb_max: [0.0; 3], - sphere_center: [0.0; 3], - sphere_radius: 0.0, - opaque: [0; 5], - } -} - -fn batch(index_start: u32, index_count: u16, base_vertex: u32) -> msh_core::Batch { - msh_core::Batch { - batch_flags: 0, - material_index: 0, - opaque4: 0, - opaque6: 0, - index_count, - index_start, - opaque14: 0, - base_vertex, - } -} - -#[test] -fn build_render_mesh_handles_empty_slot_model() { - let model = msh_core::Model { - node_stride: 38, - node_count: 1, - nodes_raw: nodes_with_slot_refs(&[None]), - slots: Vec::new(), - positions: vec![[0.0, 0.0, 0.0]], - normals: None, - uv0: None, - indices: Vec::new(), - batches: Vec::new(), - node_names: None, - }; - - let mesh = build_render_mesh(&model, 0, 0); - assert!(mesh.vertices.is_empty()); - assert!(mesh.indices.is_empty()); - assert_eq!(mesh.batch_count, 0); - assert_eq!(mesh.triangle_count(), 0); -} - -#[test] -fn build_render_mesh_supports_multi_node_and_uv_scaling() { - let model = msh_core::Model { - node_stride: 38, - node_count: 2, - nodes_raw: nodes_with_slot_refs(&[Some(0), Some(1)]), - slots: vec![slot(0, 1), slot(1, 1)], - positions: vec![ - [0.0, 0.0, 0.0], - [1.0, 0.0, 0.0], - [0.0, 1.0, 0.0], - [2.0, 0.0, 0.0], - [3.0, 0.0, 0.0], - [2.0, 1.0, 0.0], - ], - normals: None, - uv0: Some(vec![ - [1024, -1024], - [512, 256], - [0, 0], - [1024, 1024], - [2048, 1024], - [1024, 0], - ]), - indices: vec![0, 1, 2, 0, 1, 2], - batches: vec![batch(0, 3, 0), batch(3, 3, 3)], - node_names: None, - }; - - let mesh = build_render_mesh(&model, 0, 0); - assert_eq!(mesh.batch_count, 2); - assert_eq!(mesh.vertices.len(), 6); - assert_eq!(mesh.indices, vec![0, 1, 2, 3, 4, 5]); - assert_eq!(mesh.triangle_count(), 2); - assert_eq!(mesh.vertices[0].uv0, [1.0, -1.0]); - assert_eq!(mesh.vertices[1].uv0, [0.5, 0.25]); - assert_eq!(mesh.vertices[2].uv0, [0.0, 0.0]); - assert_eq!(mesh.vertices[3].uv0, [1.0, 1.0]); -} - -#[test] -fn build_render_mesh_deduplicates_shared_vertices() { - let model = msh_core::Model { - node_stride: 38, - node_count: 1, - nodes_raw: nodes_with_slot_refs(&[Some(0)]), - slots: vec![slot(0, 1)], - positions: vec![ - [0.0, 0.0, 0.0], - [1.0, 0.0, 0.0], - [0.0, 1.0, 0.0], - [1.0, 1.0, 0.0], - ], - normals: None, - uv0: None, - indices: vec![0, 1, 2, 2, 1, 3], - batches: vec![batch(0, 6, 0)], - node_names: None, - }; - - let mesh = build_render_mesh(&model, 0, 0); - assert_eq!(mesh.vertices.len(), 4); - assert_eq!(mesh.indices, vec![0, 1, 2, 2, 1, 3]); - assert_eq!(mesh.triangle_count(), 2); -} diff --git a/crates/render-demo/Cargo.toml b/crates/render-demo/Cargo.toml deleted file mode 100644 index a2161bb..0000000 --- a/crates/render-demo/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "render-demo" -version = "0.1.0" -edition = "2021" - -[features] -default = [] -demo = ["dep:sdl2", "dep:glow", "dep:image"] - -[dependencies] -encoding_rs = "0.8" -msh-core = { path = "../msh-core" } -nres = { path = "../nres" } -render-core = { path = "../render-core" } -texm = { path = "../texm" } -glow = { version = "0.17", optional = true } -image = { version = "0.25", optional = true, default-features = false, features = ["png"] } - -[dev-dependencies] -common = { path = "../common" } - -[target.'cfg(target_os = "macos")'.dependencies] -sdl2 = { version = "0.38", optional = true, default-features = false, features = ["use-pkgconfig"] } - -[target.'cfg(not(target_os = "macos"))'.dependencies] -sdl2 = { version = "0.38", optional = true, default-features = false, features = ["bundled", "static-link"] } - -[[bin]] -name = "parkan-render-demo" -path = "src/main.rs" -required-features = ["demo"] diff --git a/crates/render-demo/README.md b/crates/render-demo/README.md deleted file mode 100644 index e9d5950..0000000 --- a/crates/render-demo/README.md +++ /dev/null @@ -1,84 +0,0 @@ -# render-demo - -Тестовый рендерер Parkan-моделей на Rust (`SDL2 + OpenGL`: GLES2 с fallback на Core 3.3). - -## Назначение - -- Проверить, что `nres + msh-core + render-core` дают рабочий draw-path на реальных ассетах. -- Проверить текстурный path `WEAR -> MAT0 -> Texm` на реальных ассетах. -- Служить минимальным reference-приложением. - -## Запуск - -```bash -cargo run -p render-demo --features demo -- \ - --archive "testdata/Parkan - Iron Strategy/animals.rlb" \ - --model "A_L_01.msh" \ - --lod 0 \ - --group 0 -``` - -### macOS prerequisites - -Для macOS `render-demo` ожидает системный SDL2 через `pkg-config`: - -```bash -brew install sdl2 pkg-config -``` - -После этого запускайте той же командой `cargo run ... --features demo`. - -Параметры: - -- `--archive` (обязательный): NRes-архив с `.msh` entry. -- `--model` (опционально): имя модели; если не задано, берётся первая `.msh`. -- `--lod` (опционально, default `0`). -- `--group` (опционально, default `0`). -- `--width`, `--height` (опционально, default `1280x720`). -- `--angle` (опционально): фиксированный угол поворота вокруг Y (в радианах). -- `--spin-rate` (опционально, default `0.35`): скорость вращения в интерактивном режиме. -- В интерактивном режиме FPS выводится в заголовок окна и в stdout (обновление примерно каждые 0.5 сек). -- `--texture `: явное имя `Texm` (override авто-резолва). -- `--texture-archive `: путь к архиву текстур (по умолчанию `textures.lib` рядом с `--archive`). -- `--material-archive `: путь к `material.lib` (по умолчанию соседний `material.lib`). -- `--wear `: имя wear-entry внутри модельного архива (по умолчанию `.wea`). -- `--no-texture`: отключить текстуры и рендерить однотонным цветом. - -## Авто-резолв текстуры - -Если не передан `--texture`, демо пытается взять текстуру из игровых данных: - -1. `model.msh -> model.wea` (первый wear-материал), -2. `material.lib` (`MAT0`) по имени материала с fallback `DEFAULT`, -3. первая непустая `textureName` фаза материала, -4. загрузка `Texm` из `textures.lib` (или `lightmap.lib` как fallback). - -## Детерминированный снимок кадра - -Для parity-проверок используется headless-сценарий с фиксированными параметрами: - -```bash -cargo run -p render-demo --features demo -- \ - --archive "testdata/Parkan - Iron Strategy/animals.rlb" \ - --model "A_L_01.msh" \ - --lod 0 \ - --group 0 \ - --width 1280 \ - --height 720 \ - --angle 0.0 \ - --capture "target/render-parity/current/animals_a_l_01.png" -``` - -Явный выбор текстуры: - -```bash -cargo run -p render-demo --features demo -- \ - --archive "testdata/Parkan - Iron Strategy/animals.rlb" \ - --model "A_L_01.msh" \ - --texture "PG09.0" -``` - -## Ограничения - -- Используется только базовая texture-фаза (без полной material/fx анимации). -- Вывод через `glDrawElements(GL_TRIANGLES)` с index-buffer (позиции+UV). diff --git a/crates/render-demo/build.rs b/crates/render-demo/build.rs deleted file mode 100644 index 126d1d7..0000000 --- a/crates/render-demo/build.rs +++ /dev/null @@ -1,4 +0,0 @@ -fn main() { - #[cfg(windows)] - println!("cargo:rustc-link-lib=advapi32"); -} diff --git a/crates/render-demo/src/lib.rs b/crates/render-demo/src/lib.rs deleted file mode 100644 index 9555151..0000000 --- a/crates/render-demo/src/lib.rs +++ /dev/null @@ -1,591 +0,0 @@ -use encoding_rs::WINDOWS_1251; -use msh_core::{parse_model_payload, Model}; -use nres::{Archive, EntryRef}; -use std::fmt; -use std::path::{Path, PathBuf}; -use texm::{decode_mip_rgba8, parse_texm}; - -const WEAR_KIND: u32 = 0x5241_4557; -const MAT0_KIND: u32 = 0x3054_414D; - -#[derive(Debug)] -pub enum Error { - Nres(nres::error::Error), - Msh(msh_core::error::Error), - Texm(texm::error::Error), - Io(std::io::Error), - NoMshEntries, - ModelNotFound(String), - NoTexmEntries, - TextureNotFound(String), - MaterialNotFound(String), - WearNotFound(String), - InvalidWear(String), - InvalidMaterial(String), -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Nres(err) => write!(f, "{err}"), - Self::Msh(err) => write!(f, "{err}"), - Self::Texm(err) => write!(f, "{err}"), - Self::Io(err) => write!(f, "{err}"), - Self::NoMshEntries => write!(f, "archive does not contain .msh entries"), - Self::ModelNotFound(name) => write!(f, "model not found: {name}"), - Self::NoTexmEntries => write!(f, "archive does not contain Texm entries"), - Self::TextureNotFound(name) => write!(f, "texture not found: {name}"), - Self::MaterialNotFound(name) => write!(f, "material not found: {name}"), - Self::WearNotFound(name) => write!(f, "wear entry not found: {name}"), - Self::InvalidWear(reason) => write!(f, "invalid WEAR payload: {reason}"), - Self::InvalidMaterial(reason) => write!(f, "invalid MAT0 payload: {reason}"), - } - } -} - -impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Nres(err) => Some(err), - Self::Msh(err) => Some(err), - Self::Texm(err) => Some(err), - Self::Io(err) => Some(err), - _ => None, - } - } -} - -impl From for Error { - fn from(value: nres::error::Error) -> Self { - Self::Nres(value) - } -} - -impl From for Error { - fn from(value: msh_core::error::Error) -> Self { - Self::Msh(value) - } -} - -impl From for Error { - fn from(value: texm::error::Error) -> Self { - Self::Texm(value) - } -} - -impl From for Error { - fn from(value: std::io::Error) -> Self { - Self::Io(value) - } -} - -pub type Result = core::result::Result; - -#[derive(Clone, Debug)] -pub struct LoadedModel { - pub name: String, - pub model: Model, -} - -#[derive(Clone, Debug)] -pub struct LoadedTexture { - pub name: String, - pub width: u32, - pub height: u32, - pub rgba8: Vec, -} - -pub fn load_model_with_name_from_archive( - path: &Path, - model_name: Option<&str>, -) -> Result { - let archive = Archive::open_path(path)?; - let mut msh_entries = Vec::new(); - for entry in archive.entries() { - if entry.meta.name.to_ascii_lowercase().ends_with(".msh") { - msh_entries.push((entry.id, entry.meta.name.clone())); - } - } - if msh_entries.is_empty() { - return Err(Error::NoMshEntries); - } - - let target_id = if let Some(name) = model_name { - msh_entries - .iter() - .find(|(_, n)| n.eq_ignore_ascii_case(name)) - .map(|(id, _)| *id) - .ok_or_else(|| Error::ModelNotFound(name.to_string()))? - } else { - msh_entries[0].0 - }; - - let target_name = archive - .get(target_id) - .map(|entry| entry.meta.name.clone()) - .unwrap_or_else(|| String::from("")); - let payload = archive.read(target_id)?; - Ok(LoadedModel { - name: target_name, - model: parse_model_payload(payload.as_slice())?, - }) -} - -pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result { - Ok(load_model_with_name_from_archive(path, model_name)?.model) -} - -pub fn load_texture_from_archive(path: &Path, texture_name: Option<&str>) -> Result { - let archive = Archive::open_path(path)?; - if let Some(name) = texture_name { - return load_texture_from_archive_by_name(&archive, name); - } - - let mut texm_entries = archive - .entries() - .filter(|entry| entry.meta.kind == texm::TEXM_MAGIC) - .collect::>(); - if texm_entries.is_empty() { - return Err(Error::NoTexmEntries); - } - texm_entries.sort_by(|a, b| { - a.meta - .name - .to_ascii_lowercase() - .cmp(&b.meta.name.to_ascii_lowercase()) - }); - let first = texm_entries[0]; - decode_texture_entry(&archive, first) -} - -pub fn resolve_texture_for_model( - model_archive_path: &Path, - model_entry_name: &str, - texture_name_override: Option<&str>, - textures_archive_override: Option<&Path>, - material_archive_override: Option<&Path>, - wear_entry_override: Option<&str>, -) -> Result> { - if let Some(name) = texture_name_override { - return load_texture_by_name_from_candidate_archives( - name, - candidate_texture_archives(model_archive_path, textures_archive_override), - ) - .map(Some); - } - - let wear_entry_name = if let Some(name) = wear_entry_override { - name.to_string() - } else { - derive_wear_entry_name(model_entry_name).ok_or_else(|| { - Error::WearNotFound(format!( - "cannot derive WEAR name from model '{model_entry_name}'" - )) - })? - }; - - let model_archive = Archive::open_path(model_archive_path)?; - let wear_materials = parse_wear_material_names( - read_entry_by_name_kind(&model_archive, &wear_entry_name, WEAR_KIND)? - .0 - .as_slice(), - )?; - let Some(primary_material) = wear_materials.first() else { - return Ok(None); - }; - - let material_path = if let Some(path) = material_archive_override { - path.to_path_buf() - } else { - sibling_archive_path(model_archive_path, "material.lib") - .ok_or_else(|| Error::MaterialNotFound(String::from("material.lib")))? - }; - let material_archive = Archive::open_path(&material_path)?; - let material_entry = find_material_entry_with_fallback(&material_archive, primary_material)?; - let material_payload = material_archive.read(material_entry.id)?.into_owned(); - let texture_name = - parse_primary_texture_name_from_mat0(&material_payload, material_entry.meta.attr2)?; - let Some(texture_name) = texture_name else { - return Ok(None); - }; - - let texture = load_texture_by_name_from_candidate_archives( - &texture_name, - candidate_texture_archives(model_archive_path, textures_archive_override), - )?; - Ok(Some(texture)) -} - -fn load_texture_by_name_from_candidate_archives( - texture_name: &str, - archives: Vec, -) -> Result { - let mut last_not_found = None; - for archive_path in archives { - if !archive_path.is_file() { - continue; - } - let archive = Archive::open_path(&archive_path)?; - match load_texture_from_archive_by_name(&archive, texture_name) { - Ok(texture) => return Ok(texture), - Err(Error::TextureNotFound(name)) => { - last_not_found = Some(name); - } - Err(other) => return Err(other), - } - } - - Err(Error::TextureNotFound( - last_not_found.unwrap_or_else(|| texture_name.to_string()), - )) -} - -fn candidate_texture_archives( - model_archive_path: &Path, - textures_archive_override: Option<&Path>, -) -> Vec { - if let Some(path) = textures_archive_override { - return vec![path.to_path_buf()]; - } - - let mut out = Vec::new(); - if let Some(path) = sibling_archive_path(model_archive_path, "textures.lib") { - out.push(path); - } - if let Some(path) = sibling_archive_path(model_archive_path, "lightmap.lib") { - out.push(path); - } - out -} - -fn sibling_archive_path(model_archive_path: &Path, name: &str) -> Option { - let parent = model_archive_path.parent()?; - Some(parent.join(name)) -} - -fn derive_wear_entry_name(model_entry_name: &str) -> Option { - let stem = model_entry_name.rsplit_once('.').map(|(left, _)| left)?; - Some(format!("{stem}.wea")) -} - -fn read_entry_by_name_kind( - archive: &Archive, - name: &str, - expected_kind: u32, -) -> Result<(Vec, String)> { - let Some(id) = archive.find(name) else { - return Err(Error::WearNotFound(name.to_string())); - }; - let Some(entry) = archive.get(id) else { - return Err(Error::WearNotFound(name.to_string())); - }; - if entry.meta.kind != expected_kind { - return Err(Error::WearNotFound(name.to_string())); - } - let payload = archive.read(id)?.into_owned(); - Ok((payload, entry.meta.name.clone())) -} - -fn find_material_entry_with_fallback<'a>( - archive: &'a Archive, - requested_name: &str, -) -> Result> { - if let Some(id) = archive.find(requested_name) { - if let Some(entry) = archive.get(id) { - if entry.meta.kind == MAT0_KIND { - return Ok(entry); - } - } - } - - if let Some(id) = archive.find("DEFAULT") { - if let Some(entry) = archive.get(id) { - if entry.meta.kind == MAT0_KIND { - return Ok(entry); - } - } - } - - let Some(entry) = archive.entries().find(|entry| entry.meta.kind == MAT0_KIND) else { - return Err(Error::MaterialNotFound(requested_name.to_string())); - }; - Ok(entry) -} - -fn parse_wear_material_names(payload: &[u8]) -> Result> { - let text = decode_cp1251(payload).replace('\r', ""); - let mut lines = text.lines(); - let Some(first) = lines.next() else { - return Err(Error::InvalidWear(String::from("WEAR payload is empty"))); - }; - let count = first - .trim() - .parse::() - .map_err(|_| Error::InvalidWear(format!("invalid wearCount line: '{first}'")))?; - if count == 0 { - return Err(Error::InvalidWear(String::from("wearCount must be > 0"))); - } - - let mut materials = Vec::with_capacity(count); - for idx in 0..count { - let Some(line) = lines.next() else { - return Err(Error::InvalidWear(format!( - "missing material line {idx} of {count}" - ))); - }; - let mut parts = line.split_whitespace(); - let _legacy = parts - .next() - .ok_or_else(|| Error::InvalidWear(format!("invalid material line {idx}: '{line}'")))?; - let name = parts - .next() - .ok_or_else(|| Error::InvalidWear(format!("invalid material line {idx}: '{line}'")))?; - materials.push(name.to_string()); - } - - Ok(materials) -} - -fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result> { - if payload.len() < 4 { - return Err(Error::InvalidMaterial(String::from( - "MAT0 payload is too small for header", - ))); - } - let phase_count = u16::from_le_bytes([payload[0], payload[1]]) as usize; - if phase_count == 0 { - return Ok(None); - } - - let mut offset = 4usize; - if attr2 >= 2 { - offset = offset - .checked_add(2) - .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?; - } - if attr2 >= 3 { - offset = offset - .checked_add(4) - .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?; - } - if attr2 >= 4 { - offset = offset - .checked_add(4) - .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?; - } - - for phase in 0..phase_count { - let phase_off = offset - .checked_add(phase.checked_mul(34).ok_or_else(|| { - Error::InvalidMaterial(String::from("MAT0 phase offset overflow")) - })?) - .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 phase offset overflow")))?; - let phase_end = phase_off - .checked_add(34) - .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 phase offset overflow")))?; - let Some(rec) = payload.get(phase_off..phase_end) else { - return Err(Error::InvalidMaterial(format!( - "MAT0 phase {phase} is out of bounds" - ))); - }; - let name_raw = &rec[18..34]; - let name_end = name_raw - .iter() - .position(|&b| b == 0) - .unwrap_or(name_raw.len()); - let name = decode_cp1251(&name_raw[..name_end]).trim().to_string(); - if !name.is_empty() { - return Ok(Some(name)); - } - } - - Ok(None) -} - -fn decode_cp1251(bytes: &[u8]) -> String { - let (decoded, _, _) = WINDOWS_1251.decode(bytes); - decoded.into_owned() -} - -fn load_texture_from_archive_by_name(archive: &Archive, name: &str) -> Result { - let Some(id) = archive.find(name) else { - return Err(Error::TextureNotFound(name.to_string())); - }; - let Some(entry) = archive.get(id) else { - return Err(Error::TextureNotFound(name.to_string())); - }; - if entry.meta.kind != texm::TEXM_MAGIC { - return Err(Error::TextureNotFound(name.to_string())); - } - decode_texture_entry(archive, entry) -} - -fn decode_texture_entry(archive: &Archive, entry: EntryRef<'_>) -> Result { - let payload = archive.read(entry.id)?.into_owned(); - let parsed = parse_texm(&payload)?; - let decoded = decode_mip_rgba8(&parsed, &payload, 0)?; - Ok(LoadedTexture { - name: entry.meta.name.clone(), - width: decoded.width, - height: decoded.height, - rgba8: decoded.rgba8, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use common::collect_files_recursive; - use std::fs; - use std::path::{Path, PathBuf}; - - fn archive_with_msh() -> Option { - let root = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("testdata"); - let mut files = Vec::new(); - collect_files_recursive(&root, &mut files); - files.sort(); - for path in files { - let Ok(bytes) = fs::read(&path) else { - continue; - }; - if bytes.get(0..4) != Some(b"NRes") { - continue; - } - let Ok(archive) = Archive::open_path(&path) else { - continue; - }; - if archive - .entries() - .any(|entry| entry.meta.name.to_ascii_lowercase().ends_with(".msh")) - { - return Some(path); - } - } - None - } - - fn game_root() -> Option { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("testdata") - .join("Parkan - Iron Strategy"); - if path.is_dir() { - Some(path) - } else { - None - } - } - - #[test] - fn load_model_from_real_archive() { - let Some(path) = archive_with_msh() else { - eprintln!("skipping load_model_from_real_archive: no .msh archives in testdata"); - return; - }; - let model = load_model_from_archive(&path, None) - .unwrap_or_else(|err| panic!("failed to load model from {}: {err:?}", path.display())); - assert!(model.node_count > 0); - assert!(!model.positions.is_empty()); - assert!(!model.indices.is_empty()); - } - - #[test] - fn resolve_texture_for_real_model_via_wear_and_material() { - let Some(root) = game_root() else { - eprintln!( - "skipping resolve_texture_for_real_model_via_wear_and_material: no game root" - ); - return; - }; - let archive = root.join("animals.rlb"); - if !archive.is_file() { - eprintln!("skipping resolve_texture_for_real_model_via_wear_and_material: missing animals.rlb"); - return; - } - - let loaded = load_model_with_name_from_archive(&archive, Some("A_L_01.msh")) - .unwrap_or_else(|err| { - panic!( - "failed to load model A_L_01.msh from {}: {err:?}", - archive.display() - ) - }); - let texture = resolve_texture_for_model(&archive, &loaded.name, None, None, None, None) - .unwrap_or_else(|err| panic!("failed to resolve texture for {}: {err:?}", loaded.name)) - .expect("texture must be resolved for A_L_01.msh"); - assert!(texture.width > 0 && texture.height > 0); - assert_eq!( - texture.rgba8.len(), - usize::try_from(texture.width) - .ok() - .and_then(|w| usize::try_from(texture.height).ok().map(|h| w * h * 4)) - .unwrap_or(0) - ); - } - - #[test] - fn load_first_texture_from_real_archive() { - let Some(root) = game_root() else { - eprintln!("skipping load_first_texture_from_real_archive: no game root"); - return; - }; - let archive = root.join("textures.lib"); - if !archive.is_file() { - eprintln!("skipping load_first_texture_from_real_archive: missing textures.lib"); - return; - } - let texture = load_texture_from_archive(&archive, None).unwrap_or_else(|err| { - panic!( - "failed to load first texture from {}: {err:?}", - archive.display() - ) - }); - assert!(texture.width > 0 && texture.height > 0); - assert!(!texture.rgba8.is_empty()); - } - - #[test] - fn parse_wear_material_names_parses_counted_lines() { - let payload = b"2\r\n0 MAT_A\r\n1 MAT_B\r\n"; - let materials = - parse_wear_material_names(payload).expect("failed to parse valid WEAR payload"); - assert_eq!(materials, vec!["MAT_A".to_string(), "MAT_B".to_string()]); - } - - #[test] - fn parse_wear_material_names_rejects_invalid_payload() { - let payload = b"2\n0 ONLY_ONE\n"; - assert!(matches!( - parse_wear_material_names(payload), - Err(Error::InvalidWear(_)) - )); - } - - #[test] - fn parse_primary_texture_name_from_mat0_respects_attr2_layout() { - let mut payload = vec![0u8; 4 + 10 + 34]; - payload[0..2].copy_from_slice(&1u16.to_le_bytes()); // phase_count - // attr2=4 adds 10 bytes before phase table - let name = b"TEX_MAIN"; - payload[4 + 10 + 18..4 + 10 + 18 + name.len()].copy_from_slice(name); - - let parsed = parse_primary_texture_name_from_mat0(&payload, 4) - .expect("failed to parse MAT0 payload with attr2=4"); - assert_eq!(parsed, Some("TEX_MAIN".to_string())); - } - - #[test] - fn parse_primary_texture_name_from_mat0_decodes_cp1251_bytes() { - let mut payload = vec![0u8; 4 + 34]; - payload[0..2].copy_from_slice(&1u16.to_le_bytes()); // phase_count - payload[4 + 18] = 0xC0; // 'А' in CP1251 - - let parsed = - parse_primary_texture_name_from_mat0(&payload, 0).expect("failed to parse MAT0"); - assert_eq!(parsed, Some("А".to_string())); - } -} diff --git a/crates/render-demo/src/main.rs b/crates/render-demo/src/main.rs deleted file mode 100644 index 61f6bfa..0000000 --- a/crates/render-demo/src/main.rs +++ /dev/null @@ -1,997 +0,0 @@ -use glow::HasContext as _; -use render_core::{build_render_mesh, compute_bounds_for_mesh}; -use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture}; -use std::io::Write as _; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; - -struct Args { - archive: PathBuf, - model: Option, - lod: usize, - group: usize, - width: u32, - height: u32, - fov_deg: f32, - capture: Option, - angle: Option, - spin_rate: f32, - texture: Option, - texture_archive: Option, - material_archive: Option, - wear: Option, - no_texture: bool, -} - -struct GpuTexture { - handle: glow::NativeTexture, -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -enum GlBackend { - Gles2, - Core33, -} - -fn parse_args() -> Result { - let mut archive = None; - let mut model = None; - let mut lod = 0usize; - let mut group = 0usize; - let mut width = 1280u32; - let mut height = 720u32; - let mut fov_deg = 60.0f32; - let mut capture = None; - let mut angle = None; - let mut spin_rate = 0.35f32; - let mut texture = None; - let mut texture_archive = None; - let mut material_archive = None; - let mut wear = None; - let mut no_texture = false; - - let mut it = std::env::args().skip(1); - while let Some(arg) = it.next() { - match arg.as_str() { - "--archive" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --archive"))?; - archive = Some(PathBuf::from(value)); - } - "--model" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --model"))?; - model = Some(value); - } - "--lod" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --lod"))?; - lod = value - .parse::() - .map_err(|_| String::from("invalid --lod value"))?; - } - "--group" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --group"))?; - group = value - .parse::() - .map_err(|_| String::from("invalid --group value"))?; - } - "--width" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --width"))?; - width = value - .parse::() - .map_err(|_| String::from("invalid --width value"))?; - if width == 0 { - return Err(String::from("--width must be > 0")); - } - } - "--height" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --height"))?; - height = value - .parse::() - .map_err(|_| String::from("invalid --height value"))?; - if height == 0 { - return Err(String::from("--height must be > 0")); - } - } - "--fov" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --fov"))?; - fov_deg = value - .parse::() - .map_err(|_| String::from("invalid --fov value"))?; - if !(1.0..=179.0).contains(&fov_deg) { - return Err(String::from("--fov must be in range [1, 179]")); - } - } - "--capture" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --capture"))?; - capture = Some(PathBuf::from(value)); - } - "--angle" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --angle"))?; - angle = Some( - value - .parse::() - .map_err(|_| String::from("invalid --angle value"))?, - ); - } - "--spin-rate" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --spin-rate"))?; - spin_rate = value - .parse::() - .map_err(|_| String::from("invalid --spin-rate value"))?; - } - "--texture" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --texture"))?; - texture = Some(value); - } - "--texture-archive" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --texture-archive"))?; - texture_archive = Some(PathBuf::from(value)); - } - "--material-archive" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --material-archive"))?; - material_archive = Some(PathBuf::from(value)); - } - "--wear" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --wear"))?; - wear = Some(value); - } - "--no-texture" => { - no_texture = true; - } - "--help" | "-h" => { - print_help(); - std::process::exit(0); - } - other => { - return Err(format!("unknown argument: {other}")); - } - } - } - - let archive = archive.ok_or_else(|| String::from("missing required --archive"))?; - Ok(Args { - archive, - model, - lod, - group, - width, - height, - fov_deg, - capture, - angle, - spin_rate, - texture, - texture_archive, - material_archive, - wear, - no_texture, - }) -} - -fn print_help() { - eprintln!( - "parkan-render-demo --archive [--model ] [--lod N] [--group N] [--width W] [--height H] [--fov DEG]" - ); - eprintln!(" [--capture ] [--angle RAD] [--spin-rate RAD_PER_SEC]"); - eprintln!(" [--texture ] [--texture-archive ] [--material-archive ] [--wear ] [--no-texture]"); -} - -fn main() { - let args = match parse_args() { - Ok(v) => v, - Err(err) => { - eprintln!("{err}"); - print_help(); - std::process::exit(2); - } - }; - - if let Err(err) = run(args) { - eprintln!("{err}"); - std::process::exit(1); - } -} - -fn run(args: Args) -> Result<(), String> { - let loaded_model = load_model_with_name_from_archive(&args.archive, args.model.as_deref()) - .map_err(|err| { - format!( - "failed to load model from archive {}: {err}", - args.archive.display() - ) - })?; - let mesh = build_render_mesh(&loaded_model.model, args.lod, args.group); - if mesh.indices.is_empty() { - return Err(format!( - "model has no renderable triangles for lod={} group={}", - args.lod, args.group - )); - } - if mesh.index_overflow { - eprintln!( - "warning: mesh exceeds u16 index space and may be partially rendered on GLES2 targets" - ); - } - let Some((bounds_min, bounds_max)) = compute_bounds_for_mesh(&mesh.vertices) else { - return Err(String::from("failed to compute mesh bounds")); - }; - - let resolved_texture = resolve_texture(&args, &loaded_model.name)?; - if let Some(tex) = resolved_texture.as_ref() { - println!( - "resolved texture '{}' ({}x{})", - tex.name, tex.width, tex.height - ); - } else { - println!("texture path disabled or unresolved; rendering with fallback color"); - } - - let center = [ - 0.5 * (bounds_min[0] + bounds_max[0]), - 0.5 * (bounds_min[1] + bounds_max[1]), - 0.5 * (bounds_min[2] + bounds_max[2]), - ]; - let extent = [ - bounds_max[0] - bounds_min[0], - bounds_max[1] - bounds_min[1], - bounds_max[2] - bounds_min[2], - ]; - let radius = - (extent[0] * extent[0] + extent[1] * extent[1] + extent[2] * extent[2]).sqrt() * 0.5; - let camera_distance = (radius * 2.5).max(2.0); - - let sdl = sdl2::init().map_err(|err| format!("failed to init SDL2: {err}"))?; - let video = sdl - .video() - .map_err(|err| format!("failed to init SDL2 video: {err}"))?; - - let (mut window, _gl_ctx, gl_backend) = create_window_and_context(&video, &args)?; - let _ = if args.capture.is_some() { - video.gl_set_swap_interval(0) - } else { - video.gl_set_swap_interval(1) - }; - - let mut vertex_data = Vec::with_capacity(mesh.vertices.len() * 5); - for vertex in &mesh.vertices { - vertex_data.push(vertex.position[0]); - vertex_data.push(vertex.position[1]); - vertex_data.push(vertex.position[2]); - vertex_data.push(vertex.uv0[0]); - vertex_data.push(vertex.uv0[1]); - } - let vertex_bytes = f32_slice_to_ne_bytes(&vertex_data); - let index_bytes = u16_slice_to_ne_bytes(&mesh.indices); - - let gl = unsafe { - glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _) - }; - - let program = unsafe { create_program(&gl, gl_backend)? }; - let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") }; - let u_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") }; - let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") }; - let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") } - .ok_or_else(|| String::from("shader attribute a_pos is missing"))?; - let a_uv = unsafe { gl.get_attrib_location(program, "a_uv") } - .ok_or_else(|| String::from("shader attribute a_uv is missing"))?; - - let vbo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? }; - let ebo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? }; - unsafe { - gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); - gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW); - gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo)); - gl.buffer_data_u8_slice(glow::ELEMENT_ARRAY_BUFFER, &index_bytes, glow::STATIC_DRAW); - gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None); - gl.bind_buffer(glow::ARRAY_BUFFER, None); - } - let vao = unsafe { create_vertex_layout_if_needed(&gl, gl_backend, vbo, ebo, a_pos, a_uv)? }; - - let gpu_texture = if let Some(texture) = resolved_texture.as_ref() { - Some(unsafe { create_texture(&gl, texture)? }) - } else { - None - }; - - let result = if let Some(capture_path) = args.capture.as_ref() { - run_capture( - &gl, - program, - u_mvp.as_ref(), - u_use_tex.as_ref(), - u_tex.as_ref(), - a_pos, - a_uv, - vbo, - ebo, - vao, - gpu_texture.as_ref(), - mesh.indices.len(), - &args, - center, - camera_distance, - capture_path, - ) - } else { - run_interactive( - &sdl, - &mut window, - &gl, - program, - u_mvp.as_ref(), - u_use_tex.as_ref(), - u_tex.as_ref(), - a_pos, - a_uv, - vbo, - ebo, - vao, - gpu_texture.as_ref(), - mesh.indices.len(), - &args, - center, - camera_distance, - ) - }; - - unsafe { - if let Some(texture) = gpu_texture { - gl.delete_texture(texture.handle); - } - if let Some(vao) = vao { - gl.delete_vertex_array(vao); - } - gl.delete_buffer(ebo); - gl.delete_buffer(vbo); - gl.delete_program(program); - } - - result -} - -fn create_window_and_context( - video: &sdl2::VideoSubsystem, - args: &Args, -) -> Result<(sdl2::video::Window, sdl2::video::GLContext, GlBackend), String> { - let candidates = [ - (GlBackend::Gles2, sdl2::video::GLProfile::GLES, 2, 0), - (GlBackend::Core33, sdl2::video::GLProfile::Core, 3, 3), - ]; - let mut errors = Vec::new(); - - for (backend, profile, major, minor) in candidates { - { - let gl_attr = video.gl_attr(); - gl_attr.set_context_profile(profile); - gl_attr.set_context_version(major, minor); - gl_attr.set_depth_size(24); - gl_attr.set_double_buffer(true); - } - - let mut window_builder = video.window( - "Parkan Render Demo (SDL2 + OpenGL)", - args.width, - args.height, - ); - window_builder.opengl(); - if args.capture.is_some() { - window_builder.hidden(); - } else { - window_builder.resizable(); - } - - let window = match window_builder.build() { - Ok(window) => window, - Err(err) => { - errors.push(format!( - "{profile:?} {major}.{minor}: window build failed ({err})" - )); - continue; - } - }; - - let gl_ctx = match window.gl_create_context() { - Ok(ctx) => ctx, - Err(err) => { - errors.push(format!( - "{profile:?} {major}.{minor}: context create failed ({err})" - )); - continue; - } - }; - - if let Err(err) = window.gl_make_current(&gl_ctx) { - errors.push(format!( - "{profile:?} {major}.{minor}: make current failed ({err})" - )); - continue; - } - - return Ok((window, gl_ctx, backend)); - } - - Err(format!( - "failed to create OpenGL context. Attempts: {}", - errors.join(" | ") - )) -} - -unsafe fn create_vertex_layout_if_needed( - gl: &glow::Context, - backend: GlBackend, - vbo: glow::NativeBuffer, - ebo: glow::NativeBuffer, - a_pos: u32, - a_uv: u32, -) -> Result, String> { - if backend != GlBackend::Core33 { - return Ok(None); - } - - let vao = gl.create_vertex_array().map_err(|e| e.to_string())?; - gl.bind_vertex_array(Some(vao)); - gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); - gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo)); - gl.enable_vertex_attrib_array(a_pos); - gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0); - gl.enable_vertex_attrib_array(a_uv); - gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12); - gl.bind_vertex_array(None); - Ok(Some(vao)) -} - -fn resolve_texture(args: &Args, model_name: &str) -> Result, String> { - if args.no_texture { - return Ok(None); - } - - match resolve_texture_for_model( - &args.archive, - model_name, - args.texture.as_deref(), - args.texture_archive.as_deref(), - args.material_archive.as_deref(), - args.wear.as_deref(), - ) { - Ok(texture) => Ok(texture), - Err(err) => { - if args.texture.is_some() - || args.texture_archive.is_some() - || args.material_archive.is_some() - || args.wear.is_some() - { - Err(format!("failed to resolve texture: {err}")) - } else { - eprintln!("warning: auto texture resolve failed ({err}), fallback to solid color"); - Ok(None) - } - } - } -} - -unsafe fn create_texture( - gl: &glow::Context, - texture: &LoadedTexture, -) -> Result { - let handle = gl.create_texture().map_err(|e| e.to_string())?; - gl.bind_texture(glow::TEXTURE_2D, Some(handle)); - gl.tex_parameter_i32( - glow::TEXTURE_2D, - glow::TEXTURE_MIN_FILTER, - glow::LINEAR as i32, - ); - gl.tex_parameter_i32( - glow::TEXTURE_2D, - glow::TEXTURE_MAG_FILTER, - glow::LINEAR as i32, - ); - gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32); - gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32); - gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 1); - gl.tex_image_2d( - glow::TEXTURE_2D, - 0, - glow::RGBA as i32, - texture.width.min(i32::MAX as u32) as i32, - texture.height.min(i32::MAX as u32) as i32, - 0, - glow::RGBA, - glow::UNSIGNED_BYTE, - glow::PixelUnpackData::Slice(Some(texture.rgba8.as_slice())), - ); - gl.bind_texture(glow::TEXTURE_2D, None); - Ok(GpuTexture { handle }) -} - -#[allow(clippy::too_many_arguments)] -fn run_capture( - gl: &glow::Context, - program: glow::NativeProgram, - u_mvp: Option<&glow::NativeUniformLocation>, - u_use_tex: Option<&glow::NativeUniformLocation>, - u_tex: Option<&glow::NativeUniformLocation>, - a_pos: u32, - a_uv: u32, - vbo: glow::NativeBuffer, - ebo: glow::NativeBuffer, - vao: Option, - texture: Option<&GpuTexture>, - index_count: usize, - args: &Args, - center: [f32; 3], - camera_distance: f32, - capture_path: &Path, -) -> Result<(), String> { - let angle = args.angle.unwrap_or(0.0); - let mvp = compute_mvp( - args.width, - args.height, - args.fov_deg, - center, - camera_distance, - angle, - ); - unsafe { - draw_frame( - gl, - program, - u_mvp, - u_use_tex, - u_tex, - a_pos, - a_uv, - vbo, - ebo, - vao, - texture, - index_count, - args.width, - args.height, - &mvp, - ); - } - let mut rgba = unsafe { read_pixels_rgba(gl, args.width, args.height)? }; - flip_image_y_rgba(&mut rgba, args.width as usize, args.height as usize); - save_png(capture_path, args.width, args.height, rgba)?; - println!("captured frame to {}", capture_path.display()); - Ok(()) -} - -#[allow(clippy::too_many_arguments)] -fn run_interactive( - sdl: &sdl2::Sdl, - window: &mut sdl2::video::Window, - gl: &glow::Context, - program: glow::NativeProgram, - u_mvp: Option<&glow::NativeUniformLocation>, - u_use_tex: Option<&glow::NativeUniformLocation>, - u_tex: Option<&glow::NativeUniformLocation>, - a_pos: u32, - a_uv: u32, - vbo: glow::NativeBuffer, - ebo: glow::NativeBuffer, - vao: Option, - texture: Option<&GpuTexture>, - index_count: usize, - args: &Args, - center: [f32; 3], - camera_distance: f32, -) -> Result<(), String> { - let mut events = sdl - .event_pump() - .map_err(|err| format!("failed to get SDL event pump: {err}"))?; - let start = Instant::now(); - let mut fps_window_start = Instant::now(); - let mut fps_frames: u32 = 0; - let mut fps_printed = false; - let base_title = "Parkan Render Demo (SDL2 + OpenGL)"; - - 'main_loop: loop { - for event in events.poll_iter() { - match event { - sdl2::event::Event::Quit { .. } => break 'main_loop, - sdl2::event::Event::KeyDown { - keycode: Some(sdl2::keyboard::Keycode::Escape), - .. - } => break 'main_loop, - _ => {} - } - } - - let (w, h) = window.size(); - let angle = args - .angle - .unwrap_or(start.elapsed().as_secs_f32() * args.spin_rate); - let mvp = compute_mvp(w, h, args.fov_deg, center, camera_distance, angle); - - unsafe { - draw_frame( - gl, - program, - u_mvp, - u_use_tex, - u_tex, - a_pos, - a_uv, - vbo, - ebo, - vao, - texture, - index_count, - w, - h, - &mvp, - ); - } - window.gl_swap_window(); - - fps_frames = fps_frames.saturating_add(1); - let elapsed = fps_window_start.elapsed(); - if elapsed >= Duration::from_millis(500) { - let fps = fps_frames as f32 / elapsed.as_secs_f32().max(0.000_1); - let frame_time_ms = 1000.0 / fps.max(0.000_1); - let _ = window.set_title(&format!( - "{base_title} | FPS: {fps:.1} ({frame_time_ms:.2} ms)" - )); - print!("\rFPS: {fps:.1} ({frame_time_ms:.2} ms)"); - let _ = std::io::stdout().flush(); - fps_printed = true; - fps_frames = 0; - fps_window_start = Instant::now(); - } - } - - if fps_printed { - println!(); - } - - Ok(()) -} - -fn compute_mvp( - width: u32, - height: u32, - fov_deg: f32, - center: [f32; 3], - camera_distance: f32, - angle_rad: f32, -) -> [f32; 16] { - let aspect = (width as f32 / (height.max(1) as f32)).max(0.01); - let proj = mat4_perspective(fov_deg.to_radians(), aspect, 0.01, camera_distance * 10.0); - let view = mat4_translation(0.0, 0.0, -camera_distance); - let center_shift = mat4_translation(-center[0], -center[1], -center[2]); - let rot = mat4_rotation_y(angle_rad); - let model_m = mat4_mul(&rot, ¢er_shift); - let vp = mat4_mul(&view, &model_m); - mat4_mul(&proj, &vp) -} - -#[allow(clippy::too_many_arguments)] -unsafe fn draw_frame( - gl: &glow::Context, - program: glow::NativeProgram, - u_mvp: Option<&glow::NativeUniformLocation>, - u_use_tex: Option<&glow::NativeUniformLocation>, - u_tex: Option<&glow::NativeUniformLocation>, - a_pos: u32, - a_uv: u32, - vbo: glow::NativeBuffer, - ebo: glow::NativeBuffer, - vao: Option, - texture: Option<&GpuTexture>, - index_count: usize, - width: u32, - height: u32, - mvp: &[f32; 16], -) { - gl.viewport( - 0, - 0, - width.min(i32::MAX as u32) as i32, - height.min(i32::MAX as u32) as i32, - ); - gl.enable(glow::DEPTH_TEST); - gl.clear_color(0.06, 0.08, 0.12, 1.0); - gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT); - - gl.use_program(Some(program)); - gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp); - - let texture_enabled = texture.is_some(); - gl.uniform_1_f32(u_use_tex, if texture_enabled { 1.0 } else { 0.0 }); - if let Some(tex) = texture { - gl.active_texture(glow::TEXTURE0); - gl.bind_texture(glow::TEXTURE_2D, Some(tex.handle)); - gl.uniform_1_i32(u_tex, 0); - } else { - gl.bind_texture(glow::TEXTURE_2D, None); - } - - if let Some(vao) = vao { - gl.bind_vertex_array(Some(vao)); - gl.draw_elements( - glow::TRIANGLES, - index_count.min(i32::MAX as usize) as i32, - glow::UNSIGNED_SHORT, - 0, - ); - gl.bind_vertex_array(None); - } else { - gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); - gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo)); - gl.enable_vertex_attrib_array(a_pos); - gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0); - gl.enable_vertex_attrib_array(a_uv); - gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12); - gl.draw_elements( - glow::TRIANGLES, - index_count.min(i32::MAX as usize) as i32, - glow::UNSIGNED_SHORT, - 0, - ); - gl.disable_vertex_attrib_array(a_uv); - gl.disable_vertex_attrib_array(a_pos); - gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None); - gl.bind_buffer(glow::ARRAY_BUFFER, None); - } - gl.bind_texture(glow::TEXTURE_2D, None); - gl.use_program(None); -} - -unsafe fn read_pixels_rgba(gl: &glow::Context, width: u32, height: u32) -> Result, String> { - let pixel_count = usize::try_from(width) - .ok() - .and_then(|w| usize::try_from(height).ok().map(|h| w.saturating_mul(h))) - .ok_or_else(|| String::from("frame dimensions are too large"))?; - let mut pixels = vec![0u8; pixel_count.saturating_mul(4)]; - gl.read_pixels( - 0, - 0, - width.min(i32::MAX as u32) as i32, - height.min(i32::MAX as u32) as i32, - glow::RGBA, - glow::UNSIGNED_BYTE, - glow::PixelPackData::Slice(Some(pixels.as_mut_slice())), - ); - Ok(pixels) -} - -fn flip_image_y_rgba(rgba: &mut [u8], width: usize, height: usize) { - let stride = width.saturating_mul(4); - if stride == 0 { - return; - } - for y in 0..(height / 2) { - let top = y * stride; - let bottom = (height - 1 - y) * stride; - for i in 0..stride { - rgba.swap(top + i, bottom + i); - } - } -} - -fn save_png(path: &Path, width: u32, height: u32, rgba: Vec) -> Result<(), String> { - if let Some(parent) = path.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent).map_err(|err| { - format!( - "failed to create output directory {}: {err}", - parent.display() - ) - })?; - } - } - let image = image::RgbaImage::from_raw(width, height, rgba) - .ok_or_else(|| String::from("failed to build image from framebuffer bytes"))?; - image - .save(path) - .map_err(|err| format!("failed to save PNG {}: {err}", path.display())) -} - -unsafe fn create_program( - gl: &glow::Context, - backend: GlBackend, -) -> Result { - let (vs_src, fs_src) = match backend { - GlBackend::Gles2 => ( - r#" -attribute vec3 a_pos; -attribute vec2 a_uv; -uniform mat4 u_mvp; -varying vec2 v_uv; -void main() { - v_uv = a_uv; - gl_Position = u_mvp * vec4(a_pos, 1.0); -} -"#, - r#" -precision mediump float; -uniform sampler2D u_tex; -uniform float u_use_tex; -varying vec2 v_uv; -void main() { - vec4 base = vec4(0.85, 0.90, 1.00, 1.0); - vec4 texColor = texture2D(u_tex, v_uv); - gl_FragColor = mix(base, texColor, u_use_tex); -} -"#, - ), - GlBackend::Core33 => ( - r#"#version 330 core -in vec3 a_pos; -in vec2 a_uv; -uniform mat4 u_mvp; -out vec2 v_uv; -void main() { - v_uv = a_uv; - gl_Position = u_mvp * vec4(a_pos, 1.0); -} -"#, - r#"#version 330 core -uniform sampler2D u_tex; -uniform float u_use_tex; -in vec2 v_uv; -out vec4 fragColor; -void main() { - vec4 base = vec4(0.85, 0.90, 1.00, 1.0); - vec4 texColor = texture(u_tex, v_uv); - fragColor = mix(base, texColor, u_use_tex); -} -"#, - ), - }; - - let program = gl.create_program().map_err(|e| e.to_string())?; - let vs = gl - .create_shader(glow::VERTEX_SHADER) - .map_err(|e| e.to_string())?; - let fs = gl - .create_shader(glow::FRAGMENT_SHADER) - .map_err(|e| e.to_string())?; - - gl.shader_source(vs, vs_src); - gl.compile_shader(vs); - if !gl.get_shader_compile_status(vs) { - let log = gl.get_shader_info_log(vs); - gl.delete_shader(vs); - gl.delete_shader(fs); - gl.delete_program(program); - return Err(format!("vertex shader compile failed: {log}")); - } - - gl.shader_source(fs, fs_src); - gl.compile_shader(fs); - if !gl.get_shader_compile_status(fs) { - let log = gl.get_shader_info_log(fs); - gl.delete_shader(vs); - gl.delete_shader(fs); - gl.delete_program(program); - return Err(format!("fragment shader compile failed: {log}")); - } - - gl.attach_shader(program, vs); - gl.attach_shader(program, fs); - gl.link_program(program); - - gl.detach_shader(program, vs); - gl.detach_shader(program, fs); - gl.delete_shader(vs); - gl.delete_shader(fs); - - if !gl.get_program_link_status(program) { - let log = gl.get_program_info_log(program); - gl.delete_program(program); - return Err(format!("program link failed: {log}")); - } - - Ok(program) -} - -fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec { - let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::())); - for &value in slice { - out.extend_from_slice(&value.to_ne_bytes()); - } - out -} - -fn u16_slice_to_ne_bytes(slice: &[u16]) -> Vec { - let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::())); - for &value in slice { - out.extend_from_slice(&value.to_ne_bytes()); - } - out -} - -fn mat4_identity() -> [f32; 16] { - [ - 1.0, 0.0, 0.0, 0.0, // - 0.0, 1.0, 0.0, 0.0, // - 0.0, 0.0, 1.0, 0.0, // - 0.0, 0.0, 0.0, 1.0, // - ] -} - -fn mat4_translation(x: f32, y: f32, z: f32) -> [f32; 16] { - let mut m = mat4_identity(); - m[12] = x; - m[13] = y; - m[14] = z; - m -} - -fn mat4_rotation_y(rad: f32) -> [f32; 16] { - let c = rad.cos(); - let s = rad.sin(); - [ - c, 0.0, -s, 0.0, // - 0.0, 1.0, 0.0, 0.0, // - s, 0.0, c, 0.0, // - 0.0, 0.0, 0.0, 1.0, // - ] -} - -fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] { - let f = 1.0 / (0.5 * fovy).tan(); - let nf = 1.0 / (near - far); - [ - f / aspect, - 0.0, - 0.0, - 0.0, - 0.0, - f, - 0.0, - 0.0, - 0.0, - 0.0, - (far + near) * nf, - -1.0, - 0.0, - 0.0, - (2.0 * far * near) * nf, - 0.0, - ] -} - -fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] { - let mut out = [0.0f32; 16]; - for c in 0..4 { - for r in 0..4 { - let mut acc = 0.0f32; - for k in 0..4 { - acc += a[k * 4 + r] * b[c * 4 + k]; - } - out[c * 4 + r] = acc; - } - } - out -} diff --git a/crates/render-mission-demo/Cargo.toml b/crates/render-mission-demo/Cargo.toml deleted file mode 100644 index d658212..0000000 --- a/crates/render-mission-demo/Cargo.toml +++ /dev/null @@ -1,33 +0,0 @@ -[package] -name = "render-mission-demo" -version = "0.1.0" -edition = "2021" - -[features] -default = [] -demo = ["dep:sdl2", "dep:glow"] - -[dependencies] -encoding_rs = "0.8" -glow = { version = "0.16", optional = true } -nres = { path = "../nres" } -render-core = { path = "../render-core" } -render-demo = { path = "../render-demo" } -tma = { path = "../tma" } -terrain-core = { path = "../terrain-core" } -texm = { path = "../texm" } -unitdat = { path = "../unitdat" } - -[dev-dependencies] -common = { path = "../common" } - -[target.'cfg(target_os = "macos")'.dependencies] -sdl2 = { version = "0.37", optional = true, default-features = false, features = ["use-pkgconfig"] } - -[target.'cfg(not(target_os = "macos"))'.dependencies] -sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] } - -[[bin]] -name = "parkan-render-mission-demo" -path = "src/main.rs" -required-features = ["demo"] diff --git a/crates/render-mission-demo/src/lib.rs b/crates/render-mission-demo/src/lib.rs deleted file mode 100644 index 9732f39..0000000 --- a/crates/render-mission-demo/src/lib.rs +++ /dev/null @@ -1,881 +0,0 @@ -use encoding_rs::WINDOWS_1251; -use nres::Archive; -use render_core::{build_render_mesh, RenderMesh}; -use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture}; -use std::collections::HashMap; -use std::fmt; -use std::fs; -use std::path::{Path, PathBuf}; -use terrain_core::TerrainMesh; -use tma::MissionFile; - -const MAT0_KIND: u32 = 0x3054_414D; -const MESH_KIND: u32 = 0x4853_454D; -const OBJECT_REF_STRIDE: usize = 64; -const OBJECT_REF_ARCHIVE_BYTES: usize = 32; - -pub type Result = core::result::Result; - -#[derive(Debug)] -pub enum Error { - Io(std::io::Error), - Mission(tma::Error), - Terrain(terrain_core::Error), - UnitDat(unitdat::Error), - RenderDemo(render_demo::Error), - Nres(nres::error::Error), - Texm(texm::error::Error), - InvalidMapPath(String), - GameRootNotFound(PathBuf), -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Io(err) => write!(f, "{err}"), - Self::Mission(err) => write!(f, "{err}"), - Self::Terrain(err) => write!(f, "{err}"), - Self::UnitDat(err) => write!(f, "{err}"), - Self::RenderDemo(err) => write!(f, "{err}"), - Self::Nres(err) => write!(f, "{err}"), - Self::Texm(err) => write!(f, "{err}"), - Self::InvalidMapPath(path) => write!(f, "invalid mission map path: {path}"), - Self::GameRootNotFound(path) => { - write!( - f, - "failed to detect game root from mission path {}", - path.display() - ) - } - } - } -} - -impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io(err) => Some(err), - Self::Mission(err) => Some(err), - Self::Terrain(err) => Some(err), - Self::UnitDat(err) => Some(err), - Self::RenderDemo(err) => Some(err), - Self::Nres(err) => Some(err), - Self::Texm(err) => Some(err), - Self::InvalidMapPath(_) | Self::GameRootNotFound(_) => None, - } - } -} - -impl From for Error { - fn from(value: std::io::Error) -> Self { - Self::Io(value) - } -} - -impl From for Error { - fn from(value: tma::Error) -> Self { - Self::Mission(value) - } -} - -impl From for Error { - fn from(value: terrain_core::Error) -> Self { - Self::Terrain(value) - } -} - -impl From for Error { - fn from(value: unitdat::Error) -> Self { - Self::UnitDat(value) - } -} - -impl From for Error { - fn from(value: render_demo::Error) -> Self { - Self::RenderDemo(value) - } -} - -impl From for Error { - fn from(value: nres::error::Error) -> Self { - Self::Nres(value) - } -} - -impl From for Error { - fn from(value: texm::error::Error) -> Self { - Self::Texm(value) - } -} - -#[derive(Copy, Clone, Debug)] -pub struct LoadOptions { - pub load_model_textures: bool, - pub load_terrain_texture: bool, -} - -impl Default for LoadOptions { - fn default() -> Self { - Self { - load_model_textures: true, - load_terrain_texture: true, - } - } -} - -#[derive(Clone, Debug)] -pub struct MissionScene { - pub game_root: PathBuf, - pub mission_path: PathBuf, - pub mission: MissionFile, - pub map_folder_rel: PathBuf, - pub land_msh_path: PathBuf, - pub terrain: TerrainMesh, - pub terrain_texture: Option, - pub models: Vec, - pub skipped_objects: usize, -} - -#[derive(Clone, Debug)] -pub struct SceneModel { - pub archive_path: PathBuf, - pub model_name: String, - pub mesh: RenderMesh, - pub texture: Option, - pub instances: Vec, -} - -#[derive(Copy, Clone, Debug)] -pub struct ModelInstance { - pub position: [f32; 3], - pub yaw_rad: f32, - pub scale: [f32; 3], -} - -#[derive(Clone, Debug)] -struct ObjectPrototype { - archive_path: PathBuf, - model_name: String, -} - -#[derive(Clone, Debug)] -struct ObjectRef { - archive_name: String, - resource_name: String, -} - -#[derive(Clone, Debug, Hash, PartialEq, Eq)] -struct ModelKey { - archive_path: PathBuf, - model_name: String, -} - -pub fn detect_game_root_from_mission_path(mission_path: &Path) -> Option { - let mut cursor = mission_path.parent(); - while let Some(dir) = cursor { - if dir.join("DATA").is_dir() && dir.join("objects.rlb").is_file() { - return Some(dir.to_path_buf()); - } - cursor = dir.parent(); - } - None -} - -pub fn load_scene( - game_root: impl AsRef, - mission_path: impl AsRef, -) -> Result { - load_scene_with_options(game_root, mission_path, LoadOptions::default()) -} - -pub fn load_scene_with_options( - game_root: impl AsRef, - mission_path: impl AsRef, - options: LoadOptions, -) -> Result { - let game_root = game_root.as_ref().to_path_buf(); - let mission_path = mission_path.as_ref().to_path_buf(); - - let mission = tma::parse_path(&mission_path)?; - let map_folder_rel = map_folder_from_footer(&mission.footer.map_path)?; - let land_msh_path = game_root.join(&map_folder_rel).join("Land.msh"); - let terrain = terrain_core::load_land_mesh(&land_msh_path)?; - let terrain_texture = if options.load_terrain_texture { - resolve_terrain_texture(&game_root, &map_folder_rel)? - } else { - None - }; - - let mut grouped_instances: HashMap> = HashMap::new(); - let mut prototype_cache: HashMap> = HashMap::new(); - let mut skipped = 0usize; - - for object in &mission.objects { - let cache_key = object.resource_name.to_ascii_lowercase(); - let proto = if let Some(cached) = prototype_cache.get(&cache_key) { - cached.clone() - } else { - let resolved = resolve_object_prototype(&game_root, object)?; - prototype_cache.insert(cache_key, resolved.clone()); - resolved - }; - - let Some(proto) = proto else { - skipped += 1; - continue; - }; - - let instance = ModelInstance { - position: object.position, - yaw_rad: object.orientation[2], - scale: normalize_scale(object.scale), - }; - - grouped_instances - .entry(ModelKey { - archive_path: proto.archive_path, - model_name: proto.model_name, - }) - .or_default() - .push(instance); - } - - let mut models = Vec::new(); - for (key, instances) in grouped_instances { - let loaded = - match load_model_with_name_from_archive(&key.archive_path, Some(&key.model_name)) { - Ok(v) => v, - Err(_) => { - skipped += instances.len(); - continue; - } - }; - - let mesh = build_render_mesh(&loaded.model, 0, 0); - if mesh.indices.is_empty() { - skipped += instances.len(); - continue; - } - - let texture = if options.load_model_textures { - resolve_texture_for_model(&key.archive_path, &loaded.name, None, None, None, None) - .ok() - .flatten() - } else { - None - }; - - models.push(SceneModel { - archive_path: key.archive_path, - model_name: loaded.name, - mesh, - texture, - instances, - }); - } - - models.sort_by(|a, b| a.model_name.cmp(&b.model_name)); - - Ok(MissionScene { - game_root, - mission_path, - mission, - map_folder_rel, - land_msh_path, - terrain, - terrain_texture, - models, - skipped_objects: skipped, - }) -} - -pub fn compute_scene_bounds(scene: &MissionScene) -> Option<([f32; 3], [f32; 3])> { - let mut min_v = [f32::INFINITY; 3]; - let mut max_v = [f32::NEG_INFINITY; 3]; - let mut any = false; - - for pos in &scene.terrain.positions { - merge_bounds(&mut min_v, &mut max_v, *pos); - any = true; - } - - for model in &scene.models { - for instance in &model.instances { - merge_bounds(&mut min_v, &mut max_v, instance.position); - any = true; - } - } - - any.then_some((min_v, max_v)) -} - -fn merge_bounds(min_v: &mut [f32; 3], max_v: &mut [f32; 3], p: [f32; 3]) { - for i in 0..3 { - if p[i] < min_v[i] { - min_v[i] = p[i]; - } - if p[i] > max_v[i] { - max_v[i] = p[i]; - } - } -} - -fn normalize_scale(scale: [f32; 3]) -> [f32; 3] { - let mut out = scale; - for item in &mut out { - if !item.is_finite() || item.abs() < 0.000_1 { - *item = 1.0; - } - } - out -} - -fn map_folder_from_footer(map_path: &str) -> Result { - let mut parts = split_relative_path(map_path); - if parts.len() < 2 { - return Err(Error::InvalidMapPath(map_path.to_string())); - } - parts.pop(); // remove 'land' - - let mut out = PathBuf::new(); - for part in parts { - out.push(part); - } - Ok(out) -} - -fn resolve_object_prototype( - game_root: &Path, - object: &tma::MissionObject, -) -> Result> { - if object.resource_name.to_ascii_lowercase().ends_with(".dat") { - let dat_path = game_root.join(pathbuf_from_rel(&object.resource_name)); - if !dat_path.is_file() { - return Ok(None); - } - - let parsed = unitdat::parse_path(&dat_path)?; - let archive_path = game_root.join(pathbuf_from_rel(&parsed.archive_name)); - if !archive_path.is_file() { - return Ok(None); - } - return resolve_archive_model(game_root, &archive_path, &parsed.model_key); - } - - let archive_path = game_root.join("objects.rlb"); - if !archive_path.is_file() { - return Ok(None); - } - resolve_archive_model(game_root, &archive_path, &object.resource_name) -} - -fn resolve_archive_model( - game_root: &Path, - archive_path: &Path, - model_key: &str, -) -> Result> { - if !archive_path.is_file() { - return Ok(None); - } - - if is_objects_registry_archive(archive_path) { - if let Some(proto) = resolve_objects_registry_model(game_root, archive_path, model_key)? { - return Ok(Some(proto)); - } - } - - let model_name = ensure_msh_suffix(model_key); - if !archive_has_mesh_entry(archive_path, &model_name)? { - return Ok(None); - } - - Ok(Some(ObjectPrototype { - archive_path: archive_path.to_path_buf(), - model_name: model_name.to_ascii_lowercase(), - })) -} - -fn is_objects_registry_archive(archive_path: &Path) -> bool { - archive_path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.eq_ignore_ascii_case("objects.rlb")) -} - -fn resolve_objects_registry_model( - game_root: &Path, - registry_archive_path: &Path, - object_key: &str, -) -> Result> { - let archive = Archive::open_path(registry_archive_path)?; - let Some(entry_id) = find_registry_entry_id(&archive, object_key) else { - return Ok(None); - }; - - let payload = archive.read(entry_id)?.into_owned(); - let refs = parse_object_refs(&payload); - if refs.is_empty() { - return Ok(None); - } - - for item in refs - .iter() - .filter(|item| has_extension(&item.resource_name, "msh")) - { - if let Some(proto) = resolve_object_ref_model(game_root, item, &item.resource_name)? { - return Ok(Some(proto)); - } - } - - for item in refs - .iter() - .filter(|item| has_extension(&item.resource_name, "bas")) - { - let Some(stem) = Path::new(&item.resource_name) - .file_stem() - .and_then(|stem| stem.to_str()) - else { - continue; - }; - if stem.is_empty() { - continue; - } - let candidate = format!("{stem}.msh"); - if let Some(proto) = resolve_object_ref_model(game_root, item, &candidate)? { - return Ok(Some(proto)); - } - } - - Ok(None) -} - -fn find_registry_entry_id(archive: &Archive, object_key: &str) -> Option { - mesh_name_candidates(object_key) - .into_iter() - .find_map(|candidate| archive.find(&candidate)) -} - -fn resolve_object_ref_model( - game_root: &Path, - item: &ObjectRef, - model_name: &str, -) -> Result> { - let archive_path = game_root.join(pathbuf_from_rel(&item.archive_name)); - if !archive_path.is_file() { - return Ok(None); - } - if !archive_has_mesh_entry(&archive_path, model_name)? { - return Ok(None); - } - - Ok(Some(ObjectPrototype { - archive_path, - model_name: model_name.to_ascii_lowercase(), - })) -} - -fn parse_object_refs(payload: &[u8]) -> Vec { - if !payload.len().is_multiple_of(OBJECT_REF_STRIDE) { - return Vec::new(); - } - - let mut refs = Vec::with_capacity(payload.len() / OBJECT_REF_STRIDE); - for chunk in payload.chunks_exact(OBJECT_REF_STRIDE) { - let archive_name = decode_cp1251_cstr(&chunk[..OBJECT_REF_ARCHIVE_BYTES]); - let resource_name = decode_cp1251_cstr(&chunk[OBJECT_REF_ARCHIVE_BYTES..]); - if archive_name.is_empty() || resource_name.is_empty() { - continue; - } - refs.push(ObjectRef { - archive_name, - resource_name, - }); - } - refs -} - -fn archive_has_mesh_entry(archive_path: &Path, requested_name: &str) -> Result { - let archive = Archive::open_path(archive_path)?; - Ok(find_mesh_entry_id(&archive, requested_name).is_some()) -} - -fn find_mesh_entry_id(archive: &Archive, requested_name: &str) -> Option { - for candidate in mesh_name_candidates(requested_name) { - let Some(id) = archive.find(&candidate) else { - continue; - }; - let Some(entry) = archive.get(id) else { - continue; - }; - if entry.meta.kind == MESH_KIND || has_extension(&entry.meta.name, "msh") { - return Some(id); - } - } - None -} - -fn mesh_name_candidates(name: &str) -> Vec { - let mut out = Vec::new(); - let trimmed = name.trim(); - if trimmed.is_empty() { - return out; - } - - push_unique_string(&mut out, trimmed.to_string()); - if let Some(stem) = trimmed - .strip_suffix(".msh") - .or_else(|| trimmed.strip_suffix(".MSH")) - { - if !stem.is_empty() { - push_unique_string(&mut out, stem.to_string()); - } - } else { - push_unique_string(&mut out, format!("{trimmed}.msh")); - } - - out -} - -fn push_unique_string(items: &mut Vec, value: String) { - if !items.iter().any(|item| item.eq_ignore_ascii_case(&value)) { - items.push(value); - } -} - -fn ensure_msh_suffix(name: &str) -> String { - let trimmed = name.trim(); - if trimmed.to_ascii_lowercase().ends_with(".msh") { - trimmed.to_string() - } else { - format!("{trimmed}.msh") - } -} - -fn has_extension(name: &str, ext: &str) -> bool { - Path::new(name) - .extension() - .and_then(|value| value.to_str()) - .is_some_and(|value| value.eq_ignore_ascii_case(ext)) -} - -fn resolve_terrain_texture( - game_root: &Path, - map_folder_rel: &Path, -) -> Result> { - let material_archive_path = game_root.join("material.lib"); - let texture_archive_path = game_root.join("textures.lib"); - if !material_archive_path.is_file() || !texture_archive_path.is_file() { - return Ok(None); - } - - for wear_name in ["Land1.wea", "Land2.wea"] { - let wear_path = game_root.join(map_folder_rel).join(wear_name); - if !wear_path.is_file() { - continue; - } - let wear_payload = fs::read(&wear_path)?; - let Some(material_name) = parse_primary_material_from_wear(&wear_payload) else { - continue; - }; - let Some(texture_name) = - resolve_texture_name_from_material_archive(&material_archive_path, &material_name)? - else { - continue; - }; - if let Some(texture) = load_texm_by_name(&texture_archive_path, &texture_name)? { - return Ok(Some(texture)); - } - } - - Ok(None) -} - -fn parse_primary_material_from_wear(bytes: &[u8]) -> Option { - let text = decode_cp1251(bytes).replace('\r', ""); - let mut lines = text.lines(); - let count = lines.next()?.trim().parse::().ok()?; - if count == 0 { - return None; - } - - for line in lines.take(count) { - let mut parts = line.split_whitespace(); - let _legacy = parts.next()?; - let name = parts.next()?; - if !name.is_empty() { - return Some(name.to_string()); - } - } - None -} - -fn resolve_texture_name_from_material_archive( - archive_path: &Path, - material_name: &str, -) -> Result> { - let archive = Archive::open_path(archive_path)?; - - let entry = if let Some(id) = archive.find(material_name) { - archive - .get(id) - .filter(|entry| entry.meta.kind == MAT0_KIND) - .or_else(|| { - archive - .find("DEFAULT") - .and_then(|id| archive.get(id)) - .filter(|entry| entry.meta.kind == MAT0_KIND) - }) - } else { - archive - .find("DEFAULT") - .and_then(|id| archive.get(id)) - .filter(|entry| entry.meta.kind == MAT0_KIND) - } - .or_else(|| archive.entries().find(|entry| entry.meta.kind == MAT0_KIND)); - - let Some(entry) = entry else { - return Ok(None); - }; - - let payload = archive.read(entry.id)?.into_owned(); - parse_primary_texture_name_from_mat0(&payload, entry.meta.attr2) -} - -fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result> { - if payload.len() < 4 { - return Ok(None); - } - - let phase_count = u16::from_le_bytes([payload[0], payload[1]]) as usize; - if phase_count == 0 { - return Ok(None); - } - - let mut offset = 4usize; - if attr2 >= 2 { - offset = offset.saturating_add(2); - } - if attr2 >= 3 { - offset = offset.saturating_add(4); - } - if attr2 >= 4 { - offset = offset.saturating_add(4); - } - - for phase in 0..phase_count { - let phase_off = offset.saturating_add(phase.saturating_mul(34)); - let Some(rec) = payload.get(phase_off..phase_off + 34) else { - break; - }; - let name_raw = &rec[18..34]; - let end = name_raw - .iter() - .position(|&b| b == 0) - .unwrap_or(name_raw.len()); - let name = decode_cp1251(&name_raw[..end]).trim().to_string(); - if !name.is_empty() { - return Ok(Some(name)); - } - } - - Ok(None) -} - -fn load_texm_by_name(archive_path: &Path, texture_name: &str) -> Result> { - let archive = Archive::open_path(archive_path)?; - let Some(id) = archive.find(texture_name) else { - return Ok(None); - }; - let Some(entry) = archive.get(id) else { - return Ok(None); - }; - if entry.meta.kind != texm::TEXM_MAGIC { - return Ok(None); - } - - let payload = archive.read(id)?.into_owned(); - let parsed = texm::parse_texm(&payload)?; - let decoded = texm::decode_mip_rgba8(&parsed, &payload, 0)?; - - Ok(Some(LoadedTexture { - name: entry.meta.name.clone(), - width: decoded.width, - height: decoded.height, - rgba8: decoded.rgba8, - })) -} - -fn split_relative_path(path: &str) -> Vec<&str> { - path.split(['\\', '/']) - .filter(|part| !part.is_empty()) - .collect() -} - -fn pathbuf_from_rel(path: &str) -> PathBuf { - let mut out = PathBuf::new(); - for part in split_relative_path(path) { - out.push(part); - } - out -} - -fn decode_cp1251_cstr(bytes: &[u8]) -> String { - let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); - let (decoded, _, _) = WINDOWS_1251.decode(&bytes[..end]); - decoded.trim().to_string() -} - -fn decode_cp1251(bytes: &[u8]) -> String { - let (decoded, _, _) = WINDOWS_1251.decode(bytes); - decoded.into_owned() -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::Path; - - fn game_root() -> Option { - let root = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("testdata") - .join("Parkan - Iron Strategy"); - root.is_dir().then_some(root) - } - - #[test] - fn detects_game_root_from_mission_path() { - let Some(root) = game_root() else { - eprintln!("skipping: game root missing"); - return; - }; - - let mission = root - .join("MISSIONS") - .join("CAMPAIGN") - .join("CAMPAIGN.00") - .join("Mission.01") - .join("data.tma"); - if !mission.is_file() { - eprintln!("skipping missing mission sample"); - return; - } - - let detected = detect_game_root_from_mission_path(&mission) - .expect("failed to detect game root from mission path"); - assert_eq!(detected, root); - } - - #[test] - fn loads_scene_cpu_without_textures() { - let Some(root) = game_root() else { - eprintln!("skipping: game root missing"); - return; - }; - - let mission = root - .join("MISSIONS") - .join("CAMPAIGN") - .join("CAMPAIGN.00") - .join("Mission.01") - .join("data.tma"); - if !mission.is_file() { - eprintln!("skipping missing mission sample"); - return; - } - - let scene = load_scene_with_options( - &root, - &mission, - LoadOptions { - load_model_textures: false, - load_terrain_texture: false, - }, - ) - .unwrap_or_else(|err| panic!("failed to load scene {}: {err}", mission.display())); - - assert!(!scene.terrain.positions.is_empty()); - assert!(!scene.terrain.faces.is_empty()); - assert!(!scene.models.is_empty()); - - let instance_count = scene - .models - .iter() - .map(|model| model.instances.len()) - .sum::(); - assert!(instance_count >= 10); - - let bounds = compute_scene_bounds(&scene).expect("scene bounds should exist"); - assert!(bounds.0[0] <= bounds.1[0]); - assert!(bounds.0[1] <= bounds.1[1]); - assert!(bounds.0[2] <= bounds.1[2]); - } - - #[test] - fn loads_scene_with_textures() { - let Some(root) = game_root() else { - eprintln!("skipping: game root missing"); - return; - }; - - let mission = root - .join("MISSIONS") - .join("CAMPAIGN") - .join("CAMPAIGN.00") - .join("Mission.01") - .join("data.tma"); - if !mission.is_file() { - eprintln!("skipping missing mission sample"); - return; - } - - let scene = load_scene_with_options(&root, &mission, LoadOptions::default()) - .unwrap_or_else(|err| panic!("failed to load textured scene {}: {err}", mission.display())); - - assert!(!scene.models.is_empty()); - let textured_models = scene.models.iter().filter(|model| model.texture.is_some()).count(); - assert!(textured_models > 0, "no model textures resolved"); - assert!(scene.terrain_texture.is_some(), "terrain texture was not resolved"); - } - - #[test] - fn resolves_objects_registry_models() { - let Some(root) = game_root() else { - eprintln!("skipping: game root missing"); - return; - }; - - let registry = root.join("objects.rlb"); - if !registry.is_file() { - eprintln!("skipping missing objects.rlb"); - return; - } - - let cases = [ - ("r_h_01", "bases.rlb", "r_h_01.msh"), - ("s_tree_04", "static.rlb", "s_tree_0_04.msh"), - ("fr_m_brige", "fortif.rlb", "fr_m_brige.msh"), - ]; - - for (key, archive_name, model_name) in cases { - let proto = resolve_objects_registry_model(&root, ®istry, key) - .unwrap_or_else(|err| panic!("failed to resolve '{key}' from objects.rlb: {err}")) - .unwrap_or_else(|| panic!("missing model resolution for '{key}'")); - - let got_archive = proto - .archive_path - .file_name() - .and_then(|name| name.to_str()) - .map(|name| name.to_ascii_lowercase()) - .unwrap_or_default(); - assert_eq!(got_archive, archive_name.to_ascii_lowercase()); - assert!( - proto.model_name.eq_ignore_ascii_case(model_name), - "unexpected model for key '{key}': got '{}', expected '{}'", - proto.model_name, - model_name - ); - } - } -} diff --git a/crates/render-mission-demo/src/main.rs b/crates/render-mission-demo/src/main.rs deleted file mode 100644 index 01b6e06..0000000 --- a/crates/render-mission-demo/src/main.rs +++ /dev/null @@ -1,924 +0,0 @@ -use glow::HasContext as _; -use render_mission_demo::{ - compute_scene_bounds, detect_game_root_from_mission_path, load_scene_with_options, LoadOptions, - MissionScene, ModelInstance, -}; -use std::io::Write as _; -use std::path::PathBuf; -use std::time::{Duration, Instant}; - -struct Args { - mission: PathBuf, - game_root: Option, - width: u32, - height: u32, - fov_deg: f32, - no_model_texture: bool, - no_terrain_texture: bool, -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -enum GlBackend { - Gles2, - Core33, -} - -struct GpuTexture { - handle: glow::NativeTexture, -} - -struct GpuRenderable { - vbo: glow::NativeBuffer, - ebo: glow::NativeBuffer, - index_count: usize, - texture: Option, -} - -struct ModelRenderable { - gpu: GpuRenderable, - instances: Vec, -} - -#[derive(Copy, Clone, Debug)] -struct Camera { - position: [f32; 3], - yaw: f32, - pitch: f32, - move_speed: f32, - mouse_sensitivity: f32, -} - -fn parse_args() -> Result { - let mut mission = None; - let mut game_root = None; - let mut width = 1600u32; - let mut height = 900u32; - let mut fov_deg = 60.0f32; - let mut no_model_texture = false; - let mut no_terrain_texture = false; - - let mut it = std::env::args().skip(1); - while let Some(arg) = it.next() { - match arg.as_str() { - "--mission" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --mission"))?; - mission = Some(PathBuf::from(value)); - } - "--game-root" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --game-root"))?; - game_root = Some(PathBuf::from(value)); - } - "--width" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --width"))?; - width = value - .parse::() - .map_err(|_| String::from("invalid --width value"))?; - if width == 0 { - return Err(String::from("--width must be > 0")); - } - } - "--height" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --height"))?; - height = value - .parse::() - .map_err(|_| String::from("invalid --height value"))?; - if height == 0 { - return Err(String::from("--height must be > 0")); - } - } - "--fov" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --fov"))?; - fov_deg = value - .parse::() - .map_err(|_| String::from("invalid --fov value"))?; - if !(1.0..=179.0).contains(&fov_deg) { - return Err(String::from("--fov must be in range [1, 179]")); - } - } - "--no-model-texture" => { - no_model_texture = true; - } - "--no-terrain-texture" => { - no_terrain_texture = true; - } - "--help" | "-h" => { - print_help(); - std::process::exit(0); - } - other => { - return Err(format!("unknown argument: {other}")); - } - } - } - - let mission = mission.ok_or_else(|| String::from("missing required --mission"))?; - Ok(Args { - mission, - game_root, - width, - height, - fov_deg, - no_model_texture, - no_terrain_texture, - }) -} - -fn print_help() { - eprintln!("parkan-render-mission-demo --mission [--game-root ] [--width W] [--height H] [--fov DEG]"); - eprintln!(" [--no-model-texture] [--no-terrain-texture]"); - eprintln!("controls: arrows/WASD move, PageUp/PageDown vertical move, Right Mouse drag look, Shift speed-up, Esc exit"); -} - -fn main() { - let args = match parse_args() { - Ok(v) => v, - Err(err) => { - eprintln!("{err}"); - print_help(); - std::process::exit(2); - } - }; - - if let Err(err) = run(args) { - eprintln!("{err}"); - std::process::exit(1); - } -} - -fn run(args: Args) -> Result<(), String> { - let game_root = if let Some(path) = args.game_root.clone() { - path - } else { - detect_game_root_from_mission_path(&args.mission).ok_or_else(|| { - format!( - "failed to detect game root from mission path {} (use --game-root)", - args.mission.display() - ) - })? - }; - - let scene = load_scene_with_options( - &game_root, - &args.mission, - LoadOptions { - load_model_textures: !args.no_model_texture, - load_terrain_texture: !args.no_terrain_texture, - }, - ) - .map_err(|err| format!("failed to load mission scene: {err}"))?; - - let terrain_mesh = terrain_core::build_render_mesh(&scene.terrain) - .map_err(|err| format!("failed to build terrain render mesh: {err}"))?; - - let instance_count = scene - .models - .iter() - .map(|model| model.instances.len()) - .sum::(); - println!( - "mission loaded: map='{}', terrain_vertices={}, terrain_faces={}, models={}, instances={}, skipped={}", - scene.mission.footer.map_path, - scene.terrain.positions.len(), - scene.terrain.faces.len(), - scene.models.len(), - instance_count, - scene.skipped_objects - ); - - let sdl = sdl2::init().map_err(|err| format!("failed to init SDL2: {err}"))?; - let video = sdl - .video() - .map_err(|err| format!("failed to init SDL2 video: {err}"))?; - - let (mut window, _gl_ctx, gl_backend) = - create_window_and_context(&video, args.width, args.height)?; - let _ = video.gl_set_swap_interval(1); - - let gl = unsafe { - glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _) - }; - - let program = unsafe { create_program(&gl, gl_backend)? }; - let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") }; - let u_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") }; - let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") }; - let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") } - .ok_or_else(|| String::from("shader attribute a_pos is missing"))?; - let a_uv = unsafe { gl.get_attrib_location(program, "a_uv") } - .ok_or_else(|| String::from("shader attribute a_uv is missing"))?; - - let terrain_gpu = - unsafe { upload_terrain_renderable(&gl, &terrain_mesh, scene.terrain_texture.as_ref())? }; - - let mut model_gpus = Vec::new(); - for model in &scene.models { - let renderable = unsafe { upload_model_renderable(&gl, model)? }; - model_gpus.push(renderable); - } - - let (scene_center, scene_radius) = initial_scene_sphere(&scene); - let mut camera = Camera { - position: [ - scene_center[0], - scene_center[1] + scene_radius * 0.6, - scene_center[2] + scene_radius * 1.4, - ], - yaw: std::f32::consts::PI, - pitch: -0.28, - move_speed: (scene_radius * 0.55).max(60.0), - mouse_sensitivity: 0.005, - }; - - let mut events = sdl - .event_pump() - .map_err(|err| format!("failed to get SDL event pump: {err}"))?; - let mut last = Instant::now(); - let mut fps_window_start = Instant::now(); - let mut fps_frames = 0u32; - let mut fps_printed = false; - let mut mouse_look = false; - - 'main_loop: loop { - for event in events.poll_iter() { - match event { - sdl2::event::Event::Quit { .. } => break 'main_loop, - sdl2::event::Event::KeyDown { - keycode: Some(sdl2::keyboard::Keycode::Escape), - .. - } => break 'main_loop, - sdl2::event::Event::MouseButtonDown { - mouse_btn: sdl2::mouse::MouseButton::Right, - .. - } => { - mouse_look = true; - sdl.mouse().set_relative_mouse_mode(true); - } - sdl2::event::Event::MouseButtonUp { - mouse_btn: sdl2::mouse::MouseButton::Right, - .. - } => { - mouse_look = false; - sdl.mouse().set_relative_mouse_mode(false); - } - sdl2::event::Event::MouseMotion { xrel, yrel, .. } if mouse_look => { - camera.yaw += xrel as f32 * camera.mouse_sensitivity; - camera.pitch -= yrel as f32 * camera.mouse_sensitivity; - camera.pitch = camera.pitch.clamp(-1.54, 1.54); - } - _ => {} - } - } - - let now = Instant::now(); - let dt = (now - last).as_secs_f32().clamp(0.0, 0.05); - last = now; - - update_camera(&events, &mut camera, dt); - - let (w, h) = window.size(); - let proj = mat4_perspective( - args.fov_deg.to_radians(), - (w as f32 / h.max(1) as f32).max(0.01), - 0.1, - (scene_radius * 25.0).max(5000.0), - ); - let forward = camera_forward(camera.yaw, camera.pitch); - let view = mat4_look_at( - camera.position, - [ - camera.position[0] + forward[0], - camera.position[1] + forward[1], - camera.position[2] + forward[2], - ], - [0.0, 1.0, 0.0], - ); - - unsafe { - draw_frame_begin(&gl, w, h); - - let terrain_mvp = mat4_mul(&proj, &view); - draw_gpu_renderable( - &gl, - program, - u_mvp.as_ref(), - u_use_tex.as_ref(), - u_tex.as_ref(), - a_pos, - a_uv, - &terrain_gpu, - &terrain_mvp, - ); - - for model in &model_gpus { - for instance in &model.instances { - let model_m = model_matrix(instance.position, instance.yaw_rad, instance.scale); - let view_model = mat4_mul(&view, &model_m); - let mvp = mat4_mul(&proj, &view_model); - draw_gpu_renderable( - &gl, - program, - u_mvp.as_ref(), - u_use_tex.as_ref(), - u_tex.as_ref(), - a_pos, - a_uv, - &model.gpu, - &mvp, - ); - } - } - } - - window.gl_swap_window(); - - fps_frames = fps_frames.saturating_add(1); - let elapsed = fps_window_start.elapsed(); - if elapsed >= Duration::from_millis(500) { - let fps = fps_frames as f32 / elapsed.as_secs_f32().max(0.000_1); - let frame_time_ms = 1000.0 / fps.max(0.000_1); - let _ = window.set_title(&format!( - "Parkan Mission Demo | FPS: {fps:.1} ({frame_time_ms:.2} ms) | objects: {instance_count}" - )); - print!("\rFPS: {fps:.1} ({frame_time_ms:.2} ms)"); - let _ = std::io::stdout().flush(); - fps_printed = true; - fps_frames = 0; - fps_window_start = Instant::now(); - } - } - - if fps_printed { - println!(); - } - - unsafe { - cleanup_renderable(&gl, terrain_gpu); - for model in model_gpus { - cleanup_renderable(&gl, model.gpu); - } - gl.delete_program(program); - } - - Ok(()) -} - -fn initial_scene_sphere(scene: &MissionScene) -> ([f32; 3], f32) { - if let Some((min_v, max_v)) = compute_scene_bounds(scene) { - let center = [ - 0.5 * (min_v[0] + max_v[0]), - 0.5 * (min_v[1] + max_v[1]), - 0.5 * (min_v[2] + max_v[2]), - ]; - let extent = [ - max_v[0] - min_v[0], - max_v[1] - min_v[1], - max_v[2] - min_v[2], - ]; - let radius = ((extent[0] * extent[0]) + (extent[1] * extent[1]) + (extent[2] * extent[2])) - .sqrt() - .max(10.0) - * 0.5; - return (center, radius); - } - ([0.0, 0.0, 0.0], 100.0) -} - -fn update_camera(events: &sdl2::EventPump, camera: &mut Camera, dt: f32) { - use sdl2::keyboard::Scancode; - - let keys = events.keyboard_state(); - let mut move_dir = [0.0f32, 0.0f32, 0.0f32]; - - let forward = camera_forward(camera.yaw, camera.pitch); - let right = normalize3(cross3(forward, [0.0, 1.0, 0.0])); - - if keys.is_scancode_pressed(Scancode::Up) || keys.is_scancode_pressed(Scancode::W) { - move_dir[0] += forward[0]; - move_dir[1] += forward[1]; - move_dir[2] += forward[2]; - } - if keys.is_scancode_pressed(Scancode::Down) || keys.is_scancode_pressed(Scancode::S) { - move_dir[0] -= forward[0]; - move_dir[1] -= forward[1]; - move_dir[2] -= forward[2]; - } - if keys.is_scancode_pressed(Scancode::Left) || keys.is_scancode_pressed(Scancode::A) { - move_dir[0] -= right[0]; - move_dir[1] -= right[1]; - move_dir[2] -= right[2]; - } - if keys.is_scancode_pressed(Scancode::Right) || keys.is_scancode_pressed(Scancode::D) { - move_dir[0] += right[0]; - move_dir[1] += right[1]; - move_dir[2] += right[2]; - } - if keys.is_scancode_pressed(Scancode::PageUp) || keys.is_scancode_pressed(Scancode::E) { - move_dir[1] += 1.0; - } - if keys.is_scancode_pressed(Scancode::PageDown) || keys.is_scancode_pressed(Scancode::Q) { - move_dir[1] -= 1.0; - } - - let shift = - keys.is_scancode_pressed(Scancode::LShift) || keys.is_scancode_pressed(Scancode::RShift); - let speed_mul = if shift { 3.0 } else { 1.0 }; - - let norm = normalize3(move_dir); - camera.position[0] += norm[0] * camera.move_speed * speed_mul * dt; - camera.position[1] += norm[1] * camera.move_speed * speed_mul * dt; - camera.position[2] += norm[2] * camera.move_speed * speed_mul * dt; -} - -unsafe fn upload_model_renderable( - gl: &glow::Context, - model: &render_mission_demo::SceneModel, -) -> Result { - let mut vertex_data = Vec::with_capacity(model.mesh.vertices.len() * 5); - for vertex in &model.mesh.vertices { - vertex_data.push(vertex.position[0]); - vertex_data.push(vertex.position[1]); - vertex_data.push(vertex.position[2]); - vertex_data.push(vertex.uv0[0]); - vertex_data.push(vertex.uv0[1]); - } - - let gpu = upload_gpu_renderable( - gl, - &vertex_data, - &model.mesh.indices, - model.texture.as_ref(), - )?; - - Ok(ModelRenderable { - gpu, - instances: model.instances.clone(), - }) -} - -unsafe fn upload_terrain_renderable( - gl: &glow::Context, - mesh: &terrain_core::TerrainRenderMesh, - texture: Option<&render_demo::LoadedTexture>, -) -> Result { - let mut vertex_data = Vec::with_capacity(mesh.vertices.len() * 5); - for vertex in &mesh.vertices { - vertex_data.push(vertex.position[0]); - vertex_data.push(vertex.position[1]); - vertex_data.push(vertex.position[2]); - vertex_data.push(vertex.uv0[0]); - vertex_data.push(vertex.uv0[1]); - } - - upload_gpu_renderable(gl, &vertex_data, &mesh.indices, texture) -} - -unsafe fn upload_gpu_renderable( - gl: &glow::Context, - vertices: &[f32], - indices: &[u16], - texture: Option<&render_demo::LoadedTexture>, -) -> Result { - let vbo = gl.create_buffer().map_err(|e| e.to_string())?; - let ebo = gl.create_buffer().map_err(|e| e.to_string())?; - - let vertex_bytes = f32_slice_to_ne_bytes(vertices); - let index_bytes = u16_slice_to_ne_bytes(indices); - - gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); - gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW); - gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo)); - gl.buffer_data_u8_slice(glow::ELEMENT_ARRAY_BUFFER, &index_bytes, glow::STATIC_DRAW); - gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None); - gl.bind_buffer(glow::ARRAY_BUFFER, None); - - let gpu_texture = if let Some(texture) = texture { - Some(create_texture(gl, texture)?) - } else { - None - }; - - Ok(GpuRenderable { - vbo, - ebo, - index_count: indices.len(), - texture: gpu_texture, - }) -} - -unsafe fn cleanup_renderable(gl: &glow::Context, renderable: GpuRenderable) { - if let Some(tex) = renderable.texture { - gl.delete_texture(tex.handle); - } - gl.delete_buffer(renderable.ebo); - gl.delete_buffer(renderable.vbo); -} - -unsafe fn draw_frame_begin(gl: &glow::Context, width: u32, height: u32) { - gl.viewport( - 0, - 0, - width.min(i32::MAX as u32) as i32, - height.min(i32::MAX as u32) as i32, - ); - gl.enable(glow::DEPTH_TEST); - gl.clear_color(0.06, 0.08, 0.12, 1.0); - gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT); -} - -unsafe fn draw_gpu_renderable( - gl: &glow::Context, - program: glow::NativeProgram, - u_mvp: Option<&glow::NativeUniformLocation>, - u_use_tex: Option<&glow::NativeUniformLocation>, - u_tex: Option<&glow::NativeUniformLocation>, - a_pos: u32, - a_uv: u32, - renderable: &GpuRenderable, - mvp: &[f32; 16], -) { - gl.use_program(Some(program)); - gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp); - - let texture_enabled = renderable.texture.is_some(); - gl.uniform_1_f32(u_use_tex, if texture_enabled { 1.0 } else { 0.0 }); - - if let Some(tex) = &renderable.texture { - gl.active_texture(glow::TEXTURE0); - gl.bind_texture(glow::TEXTURE_2D, Some(tex.handle)); - gl.uniform_1_i32(u_tex, 0); - } else { - gl.bind_texture(glow::TEXTURE_2D, None); - } - - gl.bind_buffer(glow::ARRAY_BUFFER, Some(renderable.vbo)); - gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(renderable.ebo)); - gl.enable_vertex_attrib_array(a_pos); - gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0); - gl.enable_vertex_attrib_array(a_uv); - gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12); - - gl.draw_elements( - glow::TRIANGLES, - renderable.index_count.min(i32::MAX as usize) as i32, - glow::UNSIGNED_SHORT, - 0, - ); - - gl.disable_vertex_attrib_array(a_uv); - gl.disable_vertex_attrib_array(a_pos); - gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None); - gl.bind_buffer(glow::ARRAY_BUFFER, None); - gl.bind_texture(glow::TEXTURE_2D, None); - gl.use_program(None); -} - -fn create_window_and_context( - video: &sdl2::VideoSubsystem, - width: u32, - height: u32, -) -> Result<(sdl2::video::Window, sdl2::video::GLContext, GlBackend), String> { - let candidates = [ - (GlBackend::Gles2, sdl2::video::GLProfile::GLES, 2, 0), - (GlBackend::Core33, sdl2::video::GLProfile::Core, 3, 3), - ]; - let mut errors = Vec::new(); - - for (backend, profile, major, minor) in candidates { - { - let gl_attr = video.gl_attr(); - gl_attr.set_context_profile(profile); - gl_attr.set_context_version(major, minor); - gl_attr.set_depth_size(24); - gl_attr.set_double_buffer(true); - } - - let mut window_builder = video.window("Parkan Mission Demo", width, height); - window_builder.opengl().resizable(); - - let window = match window_builder.build() { - Ok(window) => window, - Err(err) => { - errors.push(format!( - "{profile:?} {major}.{minor}: window build failed ({err})" - )); - continue; - } - }; - - let gl_ctx = match window.gl_create_context() { - Ok(ctx) => ctx, - Err(err) => { - errors.push(format!( - "{profile:?} {major}.{minor}: context create failed ({err})" - )); - continue; - } - }; - - if let Err(err) = window.gl_make_current(&gl_ctx) { - errors.push(format!( - "{profile:?} {major}.{minor}: make current failed ({err})" - )); - continue; - } - - return Ok((window, gl_ctx, backend)); - } - - Err(format!( - "failed to create OpenGL context. Attempts: {}", - errors.join(" | ") - )) -} - -unsafe fn create_texture( - gl: &glow::Context, - texture: &render_demo::LoadedTexture, -) -> Result { - let handle = gl.create_texture().map_err(|e| e.to_string())?; - gl.bind_texture(glow::TEXTURE_2D, Some(handle)); - gl.tex_parameter_i32( - glow::TEXTURE_2D, - glow::TEXTURE_MIN_FILTER, - glow::LINEAR as i32, - ); - gl.tex_parameter_i32( - glow::TEXTURE_2D, - glow::TEXTURE_MAG_FILTER, - glow::LINEAR as i32, - ); - gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32); - gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32); - gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 1); - gl.tex_image_2d( - glow::TEXTURE_2D, - 0, - glow::RGBA as i32, - texture.width.min(i32::MAX as u32) as i32, - texture.height.min(i32::MAX as u32) as i32, - 0, - glow::RGBA, - glow::UNSIGNED_BYTE, - glow::PixelUnpackData::Slice(Some(texture.rgba8.as_slice())), - ); - gl.bind_texture(glow::TEXTURE_2D, None); - Ok(GpuTexture { handle }) -} - -unsafe fn create_program( - gl: &glow::Context, - backend: GlBackend, -) -> Result { - let (vs_src, fs_src) = match backend { - GlBackend::Gles2 => ( - r#" -attribute vec3 a_pos; -attribute vec2 a_uv; -uniform mat4 u_mvp; -varying vec2 v_uv; -void main() { - v_uv = a_uv; - gl_Position = u_mvp * vec4(a_pos, 1.0); -} -"#, - r#" -precision mediump float; -uniform sampler2D u_tex; -uniform float u_use_tex; -varying vec2 v_uv; -void main() { - vec4 base = vec4(0.82, 0.87, 0.95, 1.0); - vec4 texColor = texture2D(u_tex, v_uv); - gl_FragColor = mix(base, texColor, u_use_tex); -} -"#, - ), - GlBackend::Core33 => ( - r#"#version 330 core -in vec3 a_pos; -in vec2 a_uv; -uniform mat4 u_mvp; -out vec2 v_uv; -void main() { - v_uv = a_uv; - gl_Position = u_mvp * vec4(a_pos, 1.0); -} -"#, - r#"#version 330 core -uniform sampler2D u_tex; -uniform float u_use_tex; -in vec2 v_uv; -out vec4 fragColor; -void main() { - vec4 base = vec4(0.82, 0.87, 0.95, 1.0); - vec4 texColor = texture(u_tex, v_uv); - fragColor = mix(base, texColor, u_use_tex); -} -"#, - ), - }; - - let program = gl.create_program().map_err(|e| e.to_string())?; - let vs = gl - .create_shader(glow::VERTEX_SHADER) - .map_err(|e| e.to_string())?; - let fs = gl - .create_shader(glow::FRAGMENT_SHADER) - .map_err(|e| e.to_string())?; - - gl.shader_source(vs, vs_src); - gl.compile_shader(vs); - if !gl.get_shader_compile_status(vs) { - let log = gl.get_shader_info_log(vs); - gl.delete_shader(vs); - gl.delete_shader(fs); - gl.delete_program(program); - return Err(format!("vertex shader compile failed: {log}")); - } - - gl.shader_source(fs, fs_src); - gl.compile_shader(fs); - if !gl.get_shader_compile_status(fs) { - let log = gl.get_shader_info_log(fs); - gl.delete_shader(vs); - gl.delete_shader(fs); - gl.delete_program(program); - return Err(format!("fragment shader compile failed: {log}")); - } - - gl.attach_shader(program, vs); - gl.attach_shader(program, fs); - gl.link_program(program); - - gl.detach_shader(program, vs); - gl.detach_shader(program, fs); - gl.delete_shader(vs); - gl.delete_shader(fs); - - if !gl.get_program_link_status(program) { - let log = gl.get_program_info_log(program); - gl.delete_program(program); - return Err(format!("program link failed: {log}")); - } - - Ok(program) -} - -fn model_matrix(position: [f32; 3], yaw: f32, scale: [f32; 3]) -> [f32; 16] { - let translation = mat4_translation(position[0], position[1], position[2]); - let rotation = mat4_rotation_y(yaw); - let scaling = mat4_scale(scale[0], scale[1], scale[2]); - let tr = mat4_mul(&translation, &rotation); - mat4_mul(&tr, &scaling) -} - -fn camera_forward(yaw: f32, pitch: f32) -> [f32; 3] { - let cp = pitch.cos(); - normalize3([yaw.sin() * cp, pitch.sin(), yaw.cos() * cp]) -} - -fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { - [ - a[1] * b[2] - a[2] * b[1], - a[2] * b[0] - a[0] * b[2], - a[0] * b[1] - a[1] * b[0], - ] -} - -fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 { - a[0] * b[0] + a[1] * b[1] + a[2] * b[2] -} - -fn normalize3(v: [f32; 3]) -> [f32; 3] { - let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); - if len <= 1e-6 { - [0.0, 0.0, 0.0] - } else { - [v[0] / len, v[1] / len, v[2] / len] - } -} - -fn mat4_identity() -> [f32; 16] { - [ - 1.0, 0.0, 0.0, 0.0, // - 0.0, 1.0, 0.0, 0.0, // - 0.0, 0.0, 1.0, 0.0, // - 0.0, 0.0, 0.0, 1.0, // - ] -} - -fn mat4_translation(x: f32, y: f32, z: f32) -> [f32; 16] { - let mut m = mat4_identity(); - m[12] = x; - m[13] = y; - m[14] = z; - m -} - -fn mat4_scale(x: f32, y: f32, z: f32) -> [f32; 16] { - [ - x, 0.0, 0.0, 0.0, // - 0.0, y, 0.0, 0.0, // - 0.0, 0.0, z, 0.0, // - 0.0, 0.0, 0.0, 1.0, // - ] -} - -fn mat4_rotation_y(rad: f32) -> [f32; 16] { - let c = rad.cos(); - let s = rad.sin(); - [ - c, 0.0, -s, 0.0, // - 0.0, 1.0, 0.0, 0.0, // - s, 0.0, c, 0.0, // - 0.0, 0.0, 0.0, 1.0, // - ] -} - -fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] { - let f = 1.0 / (0.5 * fovy).tan(); - let nf = 1.0 / (near - far); - [ - f / aspect, - 0.0, - 0.0, - 0.0, - 0.0, - f, - 0.0, - 0.0, - 0.0, - 0.0, - (far + near) * nf, - -1.0, - 0.0, - 0.0, - (2.0 * far * near) * nf, - 0.0, - ] -} - -fn mat4_look_at(eye: [f32; 3], target: [f32; 3], up: [f32; 3]) -> [f32; 16] { - let f = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]); - let s = normalize3(cross3(f, up)); - let u = cross3(s, f); - - [ - s[0], - u[0], - -f[0], - 0.0, - s[1], - u[1], - -f[1], - 0.0, - s[2], - u[2], - -f[2], - 0.0, - -dot3(s, eye), - -dot3(u, eye), - dot3(f, eye), - 1.0, - ] -} - -fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] { - let mut out = [0.0f32; 16]; - for c in 0..4 { - for r in 0..4 { - let mut acc = 0.0f32; - for k in 0..4 { - acc += a[k * 4 + r] * b[c * 4 + k]; - } - out[c * 4 + r] = acc; - } - } - out -} - -fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec { - let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::())); - for &value in slice { - out.extend_from_slice(&value.to_ne_bytes()); - } - out -} - -fn u16_slice_to_ne_bytes(slice: &[u16]) -> Vec { - let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::())); - for &value in slice { - out.extend_from_slice(&value.to_ne_bytes()); - } - out -} diff --git a/crates/render-parity/Cargo.toml b/crates/render-parity/Cargo.toml deleted file mode 100644 index 865f97e..0000000 --- a/crates/render-parity/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "render-parity" -version = "0.1.0" -edition = "2021" - -[dependencies] -image = { version = "0.25", default-features = false, features = ["png"] } -serde = { version = "1", features = ["derive"] } -toml = "1.0" diff --git a/crates/render-parity/README.md b/crates/render-parity/README.md deleted file mode 100644 index a94520e..0000000 --- a/crates/render-parity/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# render-parity - -Deterministic frame-diff runner for `parkan-render-demo`. - -Usage: - -```bash -cargo run -p render-parity -- \ - --manifest parity/cases.toml \ - --output-dir target/render-parity/current -``` - -Options: - -- `--demo-bin `: use prebuilt `parkan-render-demo` binary instead of `cargo run`. -- `--keep-going`: continue all cases even after failures. diff --git a/crates/render-parity/src/lib.rs b/crates/render-parity/src/lib.rs deleted file mode 100644 index cb412e9..0000000 --- a/crates/render-parity/src/lib.rs +++ /dev/null @@ -1,212 +0,0 @@ -use image::{ImageBuffer, Rgba, RgbaImage}; -use serde::Deserialize; - -#[derive(Debug, Clone, Deserialize, Default)] -pub struct ManifestMeta { - pub width: Option, - pub height: Option, - pub lod: Option, - pub group: Option, - pub angle: Option, - pub diff_threshold: Option, - pub max_mean_abs: Option, - pub max_changed_ratio: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct CaseSpec { - pub id: String, - pub archive: String, - pub model: Option, - pub reference: String, - pub width: Option, - pub height: Option, - pub lod: Option, - pub group: Option, - pub angle: Option, - pub diff_threshold: Option, - pub max_mean_abs: Option, - pub max_changed_ratio: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct ParityManifest { - #[serde(default)] - pub meta: ManifestMeta, - #[serde(rename = "case", default)] - pub cases: Vec, -} - -#[derive(Debug, Clone)] -pub struct DiffMetrics { - pub width: u32, - pub height: u32, - pub mean_abs: f32, - pub max_abs: u8, - pub changed_pixels: u64, - pub changed_ratio: f32, -} - -pub fn compare_images( - reference: &RgbaImage, - actual: &RgbaImage, - diff_threshold: u8, -) -> Result { - let (rw, rh) = reference.dimensions(); - let (aw, ah) = actual.dimensions(); - if rw != aw || rh != ah { - return Err(format!( - "image size mismatch: reference={}x{}, actual={}x{}", - rw, rh, aw, ah - )); - } - - let mut diff_sum = 0u64; - let mut max_abs = 0u8; - let mut changed_pixels = 0u64; - let pixel_count = u64::from(rw).saturating_mul(u64::from(rh)); - - for (ref_px, act_px) in reference.pixels().zip(actual.pixels()) { - let mut pixel_changed = false; - for chan in 0..3 { - let a = i16::from(ref_px[chan]); - let b = i16::from(act_px[chan]); - let diff = (a - b).unsigned_abs() as u8; - diff_sum = diff_sum.saturating_add(u64::from(diff)); - if diff > max_abs { - max_abs = diff; - } - if diff > diff_threshold { - pixel_changed = true; - } - } - if pixel_changed { - changed_pixels = changed_pixels.saturating_add(1); - } - } - - let channels = pixel_count.saturating_mul(3); - let mean_abs = if channels == 0 { - 0.0 - } else { - diff_sum as f32 / channels as f32 - }; - let changed_ratio = if pixel_count == 0 { - 0.0 - } else { - changed_pixels as f32 / pixel_count as f32 - }; - - Ok(DiffMetrics { - width: rw, - height: rh, - mean_abs, - max_abs, - changed_pixels, - changed_ratio, - }) -} - -pub fn build_diff_image(reference: &RgbaImage, actual: &RgbaImage) -> Result { - let (rw, rh) = reference.dimensions(); - let (aw, ah) = actual.dimensions(); - if rw != aw || rh != ah { - return Err(format!( - "image size mismatch: reference={}x{}, actual={}x{}", - rw, rh, aw, ah - )); - } - - let mut out: ImageBuffer, Vec> = ImageBuffer::new(rw, rh); - for (dst, (ref_px, act_px)) in out - .pixels_mut() - .zip(reference.pixels().zip(actual.pixels())) - { - let dr = (i16::from(ref_px[0]) - i16::from(act_px[0])).unsigned_abs() as u8; - let dg = (i16::from(ref_px[1]) - i16::from(act_px[1])).unsigned_abs() as u8; - let db = (i16::from(ref_px[2]) - i16::from(act_px[2])).unsigned_abs() as u8; - *dst = Rgba([dr, dg, db, 255]); - } - Ok(out) -} - -pub fn evaluate_metrics( - metrics: &DiffMetrics, - max_mean_abs: f32, - max_changed_ratio: f32, -) -> Vec { - let mut violations = Vec::new(); - if metrics.mean_abs > max_mean_abs { - violations.push(format!( - "mean_abs {:.4} > allowed {:.4}", - metrics.mean_abs, max_mean_abs - )); - } - if metrics.changed_ratio > max_changed_ratio { - violations.push(format!( - "changed_ratio {:.4}% > allowed {:.4}%", - metrics.changed_ratio * 100.0, - max_changed_ratio * 100.0 - )); - } - violations -} - -#[cfg(test)] -mod tests { - use super::*; - - fn solid(w: u32, h: u32, r: u8, g: u8, b: u8) -> RgbaImage { - let mut img = RgbaImage::new(w, h); - for px in img.pixels_mut() { - *px = Rgba([r, g, b, 255]); - } - img - } - - #[test] - fn compare_identical_images() { - let ref_img = solid(4, 3, 10, 20, 30); - let act_img = solid(4, 3, 10, 20, 30); - let metrics = compare_images(&ref_img, &act_img, 2).expect("comparison must succeed"); - assert_eq!(metrics.width, 4); - assert_eq!(metrics.height, 3); - assert_eq!(metrics.max_abs, 0); - assert_eq!(metrics.changed_pixels, 0); - assert_eq!(metrics.mean_abs, 0.0); - assert_eq!(metrics.changed_ratio, 0.0); - } - - #[test] - fn compare_detects_changes_and_thresholds() { - let mut ref_img = solid(2, 2, 100, 100, 100); - let mut act_img = solid(2, 2, 100, 100, 100); - ref_img.put_pixel(1, 1, Rgba([120, 100, 100, 255])); - act_img.put_pixel(1, 1, Rgba([100, 100, 100, 255])); - - let metrics = compare_images(&ref_img, &act_img, 5).expect("comparison must succeed"); - assert_eq!(metrics.max_abs, 20); - assert_eq!(metrics.changed_pixels, 1); - assert!((metrics.changed_ratio - 0.25).abs() < 1e-6); - assert!(metrics.mean_abs > 0.0); - - let violations = evaluate_metrics(&metrics, 2.0, 0.20); - assert_eq!(violations.len(), 1); - assert!(violations[0].contains("changed_ratio")); - } - - #[test] - fn build_diff_image_returns_per_channel_abs_diff() { - let mut ref_img = solid(1, 1, 100, 150, 200); - let mut act_img = solid(1, 1, 90, 180, 170); - ref_img.put_pixel(0, 0, Rgba([100, 150, 200, 255])); - act_img.put_pixel(0, 0, Rgba([90, 180, 170, 255])); - - let diff = build_diff_image(&ref_img, &act_img).expect("diff image must build"); - let px = diff.get_pixel(0, 0); - assert_eq!(px[0], 10); - assert_eq!(px[1], 30); - assert_eq!(px[2], 30); - assert_eq!(px[3], 255); - } -} diff --git a/crates/render-parity/src/main.rs b/crates/render-parity/src/main.rs deleted file mode 100644 index 22795bc..0000000 --- a/crates/render-parity/src/main.rs +++ /dev/null @@ -1,405 +0,0 @@ -use image::RgbaImage; -use render_parity::{ - build_diff_image, compare_images, evaluate_metrics, CaseSpec, ManifestMeta, ParityManifest, -}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; - -const DEFAULT_MANIFEST: &str = "parity/cases.toml"; -const DEFAULT_OUTPUT_DIR: &str = "target/render-parity/current"; -const DEFAULT_WIDTH: u32 = 1280; -const DEFAULT_HEIGHT: u32 = 720; -const DEFAULT_LOD: usize = 0; -const DEFAULT_GROUP: usize = 0; -const DEFAULT_ANGLE: f32 = 0.0; -const DEFAULT_DIFF_THRESHOLD: u8 = 8; -const DEFAULT_MAX_MEAN_ABS: f32 = 2.0; -const DEFAULT_MAX_CHANGED_RATIO: f32 = 0.01; - -struct Args { - manifest: PathBuf, - output_dir: PathBuf, - demo_bin: Option, - keep_going: bool, -} - -#[derive(Debug, Clone)] -struct EffectiveCase { - id: String, - archive: PathBuf, - model: Option, - reference: PathBuf, - width: u32, - height: u32, - lod: usize, - group: usize, - angle: f32, - diff_threshold: u8, - max_mean_abs: f32, - max_changed_ratio: f32, -} - -fn main() { - let args = match parse_args() { - Ok(v) => v, - Err(err) => { - eprintln!("{err}"); - print_help(); - std::process::exit(2); - } - }; - - if let Err(err) = run(args) { - eprintln!("{err}"); - std::process::exit(1); - } -} - -fn parse_args() -> Result { - let mut manifest = PathBuf::from(DEFAULT_MANIFEST); - let mut output_dir = PathBuf::from(DEFAULT_OUTPUT_DIR); - let mut demo_bin = None; - let mut keep_going = false; - - let mut it = std::env::args().skip(1); - while let Some(arg) = it.next() { - match arg.as_str() { - "--manifest" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --manifest"))?; - manifest = PathBuf::from(value); - } - "--output-dir" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --output-dir"))?; - output_dir = PathBuf::from(value); - } - "--demo-bin" => { - let value = it - .next() - .ok_or_else(|| String::from("missing value for --demo-bin"))?; - demo_bin = Some(PathBuf::from(value)); - } - "--keep-going" => { - keep_going = true; - } - "--help" | "-h" => { - print_help(); - std::process::exit(0); - } - other => { - return Err(format!("unknown argument: {other}")); - } - } - } - - Ok(Args { - manifest, - output_dir, - demo_bin, - keep_going, - }) -} - -fn print_help() { - eprintln!( - "render-parity [--manifest ] [--output-dir ] [--demo-bin ] [--keep-going]" - ); - eprintln!(" --manifest path to parity manifest (default: {DEFAULT_MANIFEST})"); - eprintln!(" --output-dir where current renders and diff images are written"); - eprintln!(" --demo-bin prebuilt parkan-render-demo binary path"); - eprintln!(" --keep-going continue all cases even after failures"); -} - -fn run(args: Args) -> Result<(), String> { - let workspace = workspace_root()?; - let manifest_path = resolve_path(&workspace, &args.manifest); - let output_dir = resolve_path(&workspace, &args.output_dir); - let demo_bin = args - .demo_bin - .as_ref() - .map(|path| resolve_path(&workspace, path)); - - let manifest_raw = fs::read_to_string(&manifest_path) - .map_err(|err| format!("failed to read manifest {}: {err}", manifest_path.display()))?; - let manifest: ParityManifest = toml::from_str(&manifest_raw).map_err(|err| { - format!( - "failed to parse manifest {}: {err}", - manifest_path.display() - ) - })?; - - if manifest.cases.is_empty() { - println!( - "render-parity: no cases in {} (nothing to validate)", - manifest_path.display() - ); - return Ok(()); - } - - fs::create_dir_all(&output_dir).map_err(|err| { - format!( - "failed to create output directory {}: {err}", - output_dir.display() - ) - })?; - - let manifest_dir = manifest_path - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| workspace.clone()); - - let mut failed_cases = 0usize; - for case in &manifest.cases { - let effective = make_effective_case(&manifest.meta, case, &manifest_dir)?; - let case_file = output_dir.join(format!("{}.png", sanitize_case_id(&effective.id))); - let diff_file = output_dir - .join("diff") - .join(format!("{}.png", sanitize_case_id(&effective.id))); - - let run_res = run_single_case( - &workspace, // ensure `cargo run` executes from workspace root - demo_bin.as_deref(), - &effective, - &case_file, - &diff_file, - ); - - match run_res { - Ok(()) => {} - Err(err) => { - failed_cases = failed_cases.saturating_add(1); - eprintln!("[FAIL] {}: {}", effective.id, err); - if !args.keep_going { - break; - } - } - } - } - - if failed_cases > 0 { - return Err(format!( - "render-parity failed: {} case(s) did not match reference frames", - failed_cases - )); - } - - println!("render-parity: all cases passed"); - Ok(()) -} - -fn run_single_case( - workspace: &Path, - demo_bin: Option<&Path>, - case: &EffectiveCase, - case_file: &Path, - diff_file: &Path, -) -> Result<(), String> { - run_render_capture(workspace, demo_bin, case, case_file)?; - - let reference = load_rgba(&case.reference)?; - let actual = load_rgba(case_file)?; - let metrics = compare_images(&reference, &actual, case.diff_threshold)?; - let violations = evaluate_metrics(&metrics, case.max_mean_abs, case.max_changed_ratio); - - if violations.is_empty() { - println!( - "[OK] {} mean_abs={:.4} changed={:.4}% max_abs={} ({}x{})", - case.id, - metrics.mean_abs, - metrics.changed_ratio * 100.0, - metrics.max_abs, - metrics.width, - metrics.height - ); - return Ok(()); - } - - if let Some(parent) = diff_file.parent() { - fs::create_dir_all(parent).map_err(|err| { - format!( - "failed to create diff output directory {}: {err}", - parent.display() - ) - })?; - } - let diff = build_diff_image(&reference, &actual)?; - diff.save(diff_file) - .map_err(|err| format!("failed to save diff image {}: {err}", diff_file.display()))?; - - let mut details = String::new(); - for item in violations { - if !details.is_empty() { - details.push_str("; "); - } - details.push_str(&item); - } - Err(format!( - "{} | diff={} | mean_abs={:.4}, changed={:.4}% ({} px), max_abs={}", - details, - diff_file.display(), - metrics.mean_abs, - metrics.changed_ratio * 100.0, - metrics.changed_pixels, - metrics.max_abs - )) -} - -fn run_render_capture( - workspace: &Path, - demo_bin: Option<&Path>, - case: &EffectiveCase, - out_path: &Path, -) -> Result<(), String> { - if let Some(parent) = out_path.parent() { - fs::create_dir_all(parent).map_err(|err| { - format!( - "failed to create capture directory {}: {err}", - parent.display() - ) - })?; - } - - let mut cmd = if let Some(bin) = demo_bin { - Command::new(bin) - } else { - let mut command = Command::new("cargo"); - command.args(["run", "-p", "render-demo", "--features", "demo", "--"]); - command - }; - - cmd.current_dir(workspace) - .arg("--archive") - .arg(&case.archive) - .arg("--lod") - .arg(case.lod.to_string()) - .arg("--group") - .arg(case.group.to_string()) - .arg("--width") - .arg(case.width.to_string()) - .arg("--height") - .arg(case.height.to_string()) - .arg("--angle") - .arg(case.angle.to_string()) - .arg("--capture") - .arg(out_path); - - if let Some(model) = case.model.as_deref() { - cmd.arg("--model").arg(model); - } - - let output = cmd.output().map_err(|err| { - let mode = if demo_bin.is_some() { - "parkan-render-demo" - } else { - "cargo run -p render-demo" - }; - format!("failed to execute {} for case {}: {err}", mode, case.id) - })?; - if !output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "render command exited with status {:?}\nstdout:\n{}\nstderr:\n{}", - output.status.code(), - stdout, - stderr - )); - } - - Ok(()) -} - -fn load_rgba(path: &Path) -> Result { - image::open(path) - .map_err(|err| format!("failed to load image {}: {err}", path.display())) - .map(|img| img.to_rgba8()) -} - -fn make_effective_case( - meta: &ManifestMeta, - case: &CaseSpec, - manifest_dir: &Path, -) -> Result { - let width = case.width.or(meta.width).unwrap_or(DEFAULT_WIDTH); - let height = case.height.or(meta.height).unwrap_or(DEFAULT_HEIGHT); - if width == 0 || height == 0 { - return Err(format!( - "case '{}' has invalid dimensions {}x{}", - case.id, width, height - )); - } - - let archive = resolve_path(manifest_dir, Path::new(&case.archive)); - let reference = resolve_path(manifest_dir, Path::new(&case.reference)); - if !archive.is_file() { - return Err(format!( - "case '{}' archive not found: {}", - case.id, - archive.display() - )); - } - if !reference.is_file() { - return Err(format!( - "case '{}' reference frame not found: {}", - case.id, - reference.display() - )); - } - - Ok(EffectiveCase { - id: case.id.clone(), - archive, - model: case.model.clone(), - reference, - width, - height, - lod: case.lod.or(meta.lod).unwrap_or(DEFAULT_LOD), - group: case.group.or(meta.group).unwrap_or(DEFAULT_GROUP), - angle: case.angle.or(meta.angle).unwrap_or(DEFAULT_ANGLE), - diff_threshold: case - .diff_threshold - .or(meta.diff_threshold) - .unwrap_or(DEFAULT_DIFF_THRESHOLD), - max_mean_abs: case - .max_mean_abs - .or(meta.max_mean_abs) - .unwrap_or(DEFAULT_MAX_MEAN_ABS), - max_changed_ratio: case - .max_changed_ratio - .or(meta.max_changed_ratio) - .unwrap_or(DEFAULT_MAX_CHANGED_RATIO), - }) -} - -fn sanitize_case_id(id: &str) -> String { - id.chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '-' || c == '_' { - c - } else { - '_' - } - }) - .collect() -} - -fn workspace_root() -> Result { - let root = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .canonicalize() - .map_err(|err| format!("failed to resolve workspace root: {err}"))?; - Ok(root) -} - -fn resolve_path(base: &Path, path: &Path) -> PathBuf { - if path.is_absolute() { - path.to_path_buf() - } else { - base.join(path) - } -} diff --git a/crates/rsli/Cargo.toml b/crates/rsli/Cargo.toml deleted file mode 100644 index 0ab3036..0000000 --- a/crates/rsli/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "rsli" -version = "0.1.0" -edition = "2021" - -[dependencies] -common = { path = "../common" } -flate2 = { version = "1", default-features = false, features = ["rust_backend"] } - -[dev-dependencies] -proptest = "1" diff --git a/crates/rsli/README.md b/crates/rsli/README.md deleted file mode 100644 index 27816d6..0000000 --- a/crates/rsli/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# rsli - -Rust-библиотека для чтения архивов формата **RsLi**. - -## Что умеет - -- Открытие библиотеки из файла (`open_path`, `open_path_with`). -- Дешифрование таблицы записей (XOR stream cipher). -- Поддержка AO-трейлера и media overlay (`allow_ao_trailer`). -- Поддержка quirk для Deflate `EOF+1` (`allow_deflate_eof_plus_one`). -- Поиск по имени (`find`, c приведением запроса к uppercase). -- Загрузка данных: -- `load`, `load_into`, `load_packed`, `unpack`, `load_fast`. - -## Поддерживаемые методы упаковки - -- `0x000` None -- `0x020` XorOnly -- `0x040` Lzss -- `0x060` XorLzss -- `0x080` LzssHuffman -- `0x0A0` XorLzssHuffman -- `0x100` Deflate - -## Модель ошибок - -Типизированные ошибки без паник в production-коде (`InvalidMagic`, `UnsupportedVersion`, `EntryTableOutOfBounds`, `PackedSizePastEof`, `DeflateEofPlusOneQuirkRejected`, `UnsupportedMethod`, и др.). - -## Покрытие тестами - -### Реальные файлы - -- Рекурсивный прогон по `testdata/rsli/**`. -- Сейчас в наборе: **2 архива**. -- На реальных данных подтверждены и проходят byte-to-byte проверки методы: -- `0x040` (LZSS) -- `0x100` (Deflate) -- Для каждого архива проверяется: -- `load`/`load_into`/`load_packed`/`unpack`/`load_fast`; -- `find`; -- пересборка и сравнение **byte-to-byte**. - -### Синтетические тесты - -Из-за отсутствия реальных файлов для части методов добавлены синтетические архивы и тесты: - -- Методы: -- `0x000`, `0x020`, `0x060`, `0x080`, `0x0A0`. -- Спецкейсы формата: - - AO trailer + overlay; - - Deflate `EOF+1` (оба режима: accepted/rejected); -- некорректные заголовки/таблицы/смещения/методы. - -## Быстрый запуск тестов - -```bash -cargo test -p rsli -- --nocapture -``` diff --git a/crates/rsli/src/compress/deflate.rs b/crates/rsli/src/compress/deflate.rs deleted file mode 100644 index 6b8ea73..0000000 --- a/crates/rsli/src/compress/deflate.rs +++ /dev/null @@ -1,14 +0,0 @@ -use crate::error::Error; -use crate::Result; -use flate2::read::DeflateDecoder; -use std::io::Read; - -/// Decode raw Deflate (RFC 1951) payload. -pub fn decode_deflate(packed: &[u8]) -> Result> { - let mut out = Vec::new(); - let mut decoder = DeflateDecoder::new(packed); - decoder - .read_to_end(&mut out) - .map_err(|_| Error::DecompressionFailed("deflate"))?; - Ok(out) -} diff --git a/crates/rsli/src/compress/lzh.rs b/crates/rsli/src/compress/lzh.rs deleted file mode 100644 index 9486c50..0000000 --- a/crates/rsli/src/compress/lzh.rs +++ /dev/null @@ -1,303 +0,0 @@ -use super::xor::XorState; -use crate::error::Error; -use crate::Result; - -pub(crate) const LZH_N: usize = 4096; -pub(crate) const LZH_F: usize = 60; -pub(crate) const LZH_THRESHOLD: usize = 2; -pub(crate) const LZH_N_CHAR: usize = 256 - LZH_THRESHOLD + LZH_F; -pub(crate) const LZH_T: usize = LZH_N_CHAR * 2 - 1; -pub(crate) const LZH_R: usize = LZH_T - 1; -pub(crate) const LZH_MAX_FREQ: u16 = 0x8000; - -/// LZSS-Huffman decompression with optional on-the-fly XOR decryption. -pub fn lzss_huffman_decompress( - data: &[u8], - expected_size: usize, - xor_key: Option, -) -> Result> { - let mut decoder = LzhDecoder::new(data, xor_key); - decoder.decode(expected_size) -} - -struct LzhDecoder<'a> { - bit_reader: BitReader<'a>, - text: [u8; LZH_N], - freq: [u16; LZH_T + 1], - parent: [usize; LZH_T + LZH_N_CHAR], - son: [usize; LZH_T], - d_code: [u8; 256], - d_len: [u8; 256], - ring_pos: usize, -} - -impl<'a> LzhDecoder<'a> { - fn new(data: &'a [u8], xor_key: Option) -> Self { - let mut decoder = Self { - bit_reader: BitReader::new(data, xor_key), - text: [0x20u8; LZH_N], - freq: [0u16; LZH_T + 1], - parent: [0usize; LZH_T + LZH_N_CHAR], - son: [0usize; LZH_T], - d_code: [0u8; 256], - d_len: [0u8; 256], - ring_pos: LZH_N - LZH_F, - }; - decoder.init_tables(); - decoder.start_huff(); - decoder - } - - fn decode(&mut self, expected_size: usize) -> Result> { - let mut out = Vec::with_capacity(expected_size); - - while out.len() < expected_size { - let c = self.decode_char()?; - if c < 256 { - let byte = c as u8; - out.push(byte); - self.text[self.ring_pos] = byte; - self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1); - } else { - let mut offset = self.decode_position()?; - offset = (self.ring_pos.wrapping_sub(offset).wrapping_sub(1)) & (LZH_N - 1); - let mut length = c.saturating_sub(253); - - while length > 0 && out.len() < expected_size { - let byte = self.text[offset]; - out.push(byte); - self.text[self.ring_pos] = byte; - self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1); - offset = (offset + 1) & (LZH_N - 1); - length -= 1; - } - } - } - - if out.len() != expected_size { - return Err(Error::DecompressionFailed("lzss-huffman")); - } - Ok(out) - } - - fn init_tables(&mut self) { - let d_code_group_counts = [1usize, 3, 8, 12, 24, 16]; - let d_len_group_counts = [32usize, 48, 64, 48, 48, 16]; - - let mut group_index = 0u8; - let mut idx = 0usize; - let mut run = 32usize; - for count in d_code_group_counts { - for _ in 0..count { - for _ in 0..run { - self.d_code[idx] = group_index; - idx += 1; - } - group_index = group_index.wrapping_add(1); - } - run >>= 1; - } - - let mut len = 3u8; - idx = 0; - for count in d_len_group_counts { - for _ in 0..count { - self.d_len[idx] = len; - idx += 1; - } - len = len.saturating_add(1); - } - } - - fn start_huff(&mut self) { - for i in 0..LZH_N_CHAR { - self.freq[i] = 1; - self.son[i] = i + LZH_T; - self.parent[i + LZH_T] = i; - } - - let mut i = 0usize; - let mut j = LZH_N_CHAR; - while j <= LZH_R { - self.freq[j] = self.freq[i].saturating_add(self.freq[i + 1]); - self.son[j] = i; - self.parent[i] = j; - self.parent[i + 1] = j; - i += 2; - j += 1; - } - - self.freq[LZH_T] = u16::MAX; - self.parent[LZH_R] = 0; - } - - fn decode_char(&mut self) -> Result { - let mut node = self.son[LZH_R]; - while node < LZH_T { - let bit = usize::from(self.bit_reader.read_bit()?); - let branch = node - .checked_add(bit) - .ok_or(Error::DecompressionFailed("lzss-huffman tree overflow"))?; - node = *self.son.get(branch).ok_or(Error::DecompressionFailed( - "lzss-huffman tree out of bounds", - ))?; - } - - let c = node - LZH_T; - self.update(c); - Ok(c) - } - - fn decode_position(&mut self) -> Result { - let i = self.bit_reader.read_bits(8)? as usize; - let mut c = usize::from(self.d_code[i]) << 6; - let mut j = usize::from(self.d_len[i]).saturating_sub(2); - - while j > 0 { - j -= 1; - c |= usize::from(self.bit_reader.read_bit()?) << j; - } - - Ok(c | (i & 0x3F)) - } - - fn update(&mut self, c: usize) { - if self.freq[LZH_R] == LZH_MAX_FREQ { - self.reconstruct(); - } - - let mut current = self.parent[c + LZH_T]; - loop { - self.freq[current] = self.freq[current].saturating_add(1); - let freq = self.freq[current]; - - if current + 1 < self.freq.len() && freq > self.freq[current + 1] { - let mut swap_idx = current + 1; - while swap_idx + 1 < self.freq.len() && freq > self.freq[swap_idx + 1] { - swap_idx += 1; - } - - self.freq.swap(current, swap_idx); - - let left = self.son[current]; - let right = self.son[swap_idx]; - self.son[current] = right; - self.son[swap_idx] = left; - - self.parent[left] = swap_idx; - if left < LZH_T { - self.parent[left + 1] = swap_idx; - } - - self.parent[right] = current; - if right < LZH_T { - self.parent[right + 1] = current; - } - - current = swap_idx; - } - - current = self.parent[current]; - if current == 0 { - break; - } - } - } - - fn reconstruct(&mut self) { - let mut j = 0usize; - for i in 0..LZH_T { - if self.son[i] >= LZH_T { - self.freq[j] = (self.freq[i].saturating_add(1)) / 2; - self.son[j] = self.son[i]; - j += 1; - } - } - - let mut i = 0usize; - let mut current = LZH_N_CHAR; - while current < LZH_T { - let sum = self.freq[i].saturating_add(self.freq[i + 1]); - self.freq[current] = sum; - - let mut insert_at = current; - while insert_at > 0 && sum < self.freq[insert_at - 1] { - insert_at -= 1; - } - - for move_idx in (insert_at..current).rev() { - self.freq[move_idx + 1] = self.freq[move_idx]; - self.son[move_idx + 1] = self.son[move_idx]; - } - - self.freq[insert_at] = sum; - self.son[insert_at] = i; - - i += 2; - current += 1; - } - - for idx in 0..LZH_T { - let node = self.son[idx]; - self.parent[node] = idx; - if node < LZH_T { - self.parent[node + 1] = idx; - } - } - - self.freq[LZH_T] = u16::MAX; - self.parent[LZH_R] = 0; - } -} - -struct BitReader<'a> { - data: &'a [u8], - byte_pos: usize, - bit_mask: u8, - current_byte: u8, - xor_state: Option, -} - -impl<'a> BitReader<'a> { - fn new(data: &'a [u8], xor_key: Option) -> Self { - Self { - data, - byte_pos: 0, - bit_mask: 0x80, - current_byte: 0, - xor_state: xor_key.map(XorState::new), - } - } - - fn read_bit(&mut self) -> Result { - if self.bit_mask == 0x80 { - let Some(mut byte) = self.data.get(self.byte_pos).copied() else { - return Err(Error::DecompressionFailed("lzss-huffman: unexpected EOF")); - }; - if let Some(state) = &mut self.xor_state { - byte = state.decrypt_byte(byte); - } - self.current_byte = byte; - } - - let bit = if (self.current_byte & self.bit_mask) != 0 { - 1 - } else { - 0 - }; - self.bit_mask >>= 1; - if self.bit_mask == 0 { - self.bit_mask = 0x80; - self.byte_pos = self.byte_pos.saturating_add(1); - } - Ok(bit) - } - - fn read_bits(&mut self, bits: usize) -> Result { - let mut value = 0u32; - for _ in 0..bits { - value = (value << 1) | u32::from(self.read_bit()?); - } - Ok(value) - } -} diff --git a/crates/rsli/src/compress/lzss.rs b/crates/rsli/src/compress/lzss.rs deleted file mode 100644 index d30345c..0000000 --- a/crates/rsli/src/compress/lzss.rs +++ /dev/null @@ -1,79 +0,0 @@ -use super::xor::XorState; -use crate::error::Error; -use crate::Result; - -/// Simple LZSS decompression with optional on-the-fly XOR decryption -pub fn lzss_decompress_simple( - data: &[u8], - expected_size: usize, - xor_key: Option, -) -> Result> { - let mut ring = [0x20u8; 0x1000]; - let mut ring_pos = 0xFEEusize; - let mut out = Vec::with_capacity(expected_size); - let mut in_pos = 0usize; - - let mut control = 0u8; - let mut bits_left = 0u8; - - // XOR state for on-the-fly decryption - let mut xor_state = xor_key.map(XorState::new); - - // Helper to read byte with optional XOR decryption - let read_byte = |pos: usize, state: &mut Option| -> Option { - let encrypted = data.get(pos).copied()?; - Some(if let Some(ref mut s) = state { - s.decrypt_byte(encrypted) - } else { - encrypted - }) - }; - - while out.len() < expected_size { - if bits_left == 0 { - let byte = read_byte(in_pos, &mut xor_state) - .ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?; - control = byte; - in_pos += 1; - bits_left = 8; - } - - if (control & 1) != 0 { - let byte = read_byte(in_pos, &mut xor_state) - .ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?; - in_pos += 1; - - out.push(byte); - ring[ring_pos] = byte; - ring_pos = (ring_pos + 1) & 0x0FFF; - } else { - let low = read_byte(in_pos, &mut xor_state) - .ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?; - let high = read_byte(in_pos + 1, &mut xor_state) - .ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?; - in_pos += 2; - - let offset = usize::from(low) | (usize::from(high & 0xF0) << 4); - let length = usize::from((high & 0x0F) + 3); - - for step in 0..length { - let byte = ring[(offset + step) & 0x0FFF]; - out.push(byte); - ring[ring_pos] = byte; - ring_pos = (ring_pos + 1) & 0x0FFF; - if out.len() >= expected_size { - break; - } - } - } - - control >>= 1; - bits_left -= 1; - } - - if out.len() != expected_size { - return Err(Error::DecompressionFailed("lzss-simple")); - } - - Ok(out) -} diff --git a/crates/rsli/src/compress/mod.rs b/crates/rsli/src/compress/mod.rs deleted file mode 100644 index bd23143..0000000 --- a/crates/rsli/src/compress/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -pub mod deflate; -pub mod lzh; -pub mod lzss; -pub mod xor; - -pub use deflate::decode_deflate; -pub use lzh::lzss_huffman_decompress; -pub use lzss::lzss_decompress_simple; -pub use xor::{xor_stream, XorState}; diff --git a/crates/rsli/src/compress/xor.rs b/crates/rsli/src/compress/xor.rs deleted file mode 100644 index c4c3d7d..0000000 --- a/crates/rsli/src/compress/xor.rs +++ /dev/null @@ -1,29 +0,0 @@ -/// XOR cipher state for RsLi format -pub struct XorState { - lo: u8, - hi: u8, -} - -impl XorState { - /// Create new XOR state from 16-bit key - pub fn new(key16: u16) -> Self { - Self { - lo: (key16 & 0xFF) as u8, - hi: ((key16 >> 8) & 0xFF) as u8, - } - } - - /// Decrypt a single byte and update state - pub fn decrypt_byte(&mut self, encrypted: u8) -> u8 { - self.lo = self.hi ^ self.lo.wrapping_shl(1); - let decrypted = encrypted ^ self.lo; - self.hi = self.lo ^ (self.hi >> 1); - decrypted - } -} - -/// Decrypt entire buffer with XOR stream cipher -pub fn xor_stream(data: &[u8], key16: u16) -> Vec { - let mut state = XorState::new(key16); - data.iter().map(|&b| state.decrypt_byte(b)).collect() -} diff --git a/crates/rsli/src/error.rs b/crates/rsli/src/error.rs deleted file mode 100644 index 5a36101..0000000 --- a/crates/rsli/src/error.rs +++ /dev/null @@ -1,140 +0,0 @@ -use core::fmt; - -#[derive(Debug)] -#[non_exhaustive] -pub enum Error { - Io(std::io::Error), - - InvalidMagic { - got: [u8; 2], - }, - UnsupportedVersion { - got: u8, - }, - InvalidEntryCount { - got: i16, - }, - TooManyEntries { - got: usize, - }, - - EntryTableOutOfBounds { - table_offset: u64, - table_len: u64, - file_len: u64, - }, - EntryTableDecryptFailed, - CorruptEntryTable(&'static str), - - EntryIdOutOfRange { - id: u32, - entry_count: u32, - }, - EntryDataOutOfBounds { - id: u32, - offset: u64, - size: u32, - file_len: u64, - }, - - AoTrailerInvalid, - MediaOverlayOutOfBounds { - overlay: u32, - file_len: u64, - }, - - UnsupportedMethod { - raw: u32, - }, - PackedSizePastEof { - id: u32, - offset: u64, - packed_size: u32, - file_len: u64, - }, - DeflateEofPlusOneQuirkRejected { - id: u32, - }, - - DecompressionFailed(&'static str), - OutputSizeMismatch { - expected: u32, - got: u32, - }, - - IntegerOverflow, -} - -impl From for Error { - fn from(value: std::io::Error) -> Self { - Self::Io(value) - } -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Error::Io(e) => write!(f, "I/O error: {e}"), - Error::InvalidMagic { got } => write!(f, "invalid RsLi magic: {got:02X?}"), - Error::UnsupportedVersion { got } => write!(f, "unsupported RsLi version: {got:#x}"), - Error::InvalidEntryCount { got } => write!(f, "invalid entry_count: {got}"), - Error::TooManyEntries { got } => write!(f, "too many entries: {got} exceeds u32::MAX"), - Error::EntryTableOutOfBounds { - table_offset, - table_len, - file_len, - } => write!( - f, - "entry table out of bounds: off={table_offset}, len={table_len}, file={file_len}" - ), - Error::EntryTableDecryptFailed => write!(f, "failed to decrypt entry table"), - Error::CorruptEntryTable(s) => write!(f, "corrupt entry table: {s}"), - Error::EntryIdOutOfRange { id, entry_count } => { - write!(f, "entry id out of range: id={id}, count={entry_count}") - } - Error::EntryDataOutOfBounds { - id, - offset, - size, - file_len, - } => write!( - f, - "entry data out of bounds: id={id}, off={offset}, size={size}, file={file_len}" - ), - Error::AoTrailerInvalid => write!(f, "invalid AO trailer"), - Error::MediaOverlayOutOfBounds { overlay, file_len } => { - write!( - f, - "media overlay out of bounds: overlay={overlay}, file={file_len}" - ) - } - Error::UnsupportedMethod { raw } => write!(f, "unsupported packing method: {raw:#x}"), - Error::PackedSizePastEof { - id, - offset, - packed_size, - file_len, - } => write!( - f, - "packed range past EOF: id={id}, off={offset}, size={packed_size}, file={file_len}" - ), - Error::DeflateEofPlusOneQuirkRejected { id } => { - write!(f, "deflate EOF+1 quirk rejected for entry {id}") - } - Error::DecompressionFailed(s) => write!(f, "decompression failed: {s}"), - Error::OutputSizeMismatch { expected, got } => { - write!(f, "output size mismatch: expected={expected}, got={got}") - } - Error::IntegerOverflow => write!(f, "integer overflow"), - } - } -} - -impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io(err) => Some(err), - _ => None, - } - } -} diff --git a/crates/rsli/src/lib.rs b/crates/rsli/src/lib.rs deleted file mode 100644 index 1ce3b1f..0000000 --- a/crates/rsli/src/lib.rs +++ /dev/null @@ -1,470 +0,0 @@ -pub mod compress; -pub mod error; -pub mod parse; - -use crate::compress::{ - decode_deflate, lzss_decompress_simple, lzss_huffman_decompress, xor_stream, -}; -use crate::error::Error; -use crate::parse::{c_name_bytes, cmp_c_string, parse_library}; -use common::{OutputBuffer, ResourceData}; -use std::cmp::Ordering; -use std::fs; -use std::path::Path; -use std::sync::Arc; - -pub type Result = core::result::Result; - -#[derive(Clone, Debug)] -pub struct OpenOptions { - pub allow_ao_trailer: bool, - pub allow_deflate_eof_plus_one: bool, -} - -impl Default for OpenOptions { - fn default() -> Self { - Self { - allow_ao_trailer: true, - allow_deflate_eof_plus_one: true, - } - } -} - -#[derive(Clone, Debug)] -pub struct LibraryHeader { - pub raw: [u8; 32], - pub magic: [u8; 2], - pub reserved: u8, - pub version: u8, - pub entry_count: i16, - pub presorted_flag: u16, - pub xor_seed: u32, -} - -#[derive(Clone, Debug)] -pub struct AoTrailer { - pub raw: [u8; 6], - pub overlay: u32, -} - -#[derive(Debug)] -pub struct Library { - bytes: Arc<[u8]>, - entries: Vec, - header: LibraryHeader, - ao_trailer: Option, - #[cfg(test)] - pub(crate) table_plain_original: Vec, - #[cfg(test)] - pub(crate) source_size: usize, -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] -pub struct EntryId(pub u32); - -#[derive(Clone, Debug)] -pub struct EntryMeta { - pub name: String, - pub flags: i32, - pub method: PackMethod, - pub data_offset: u64, - pub packed_size: u32, - pub unpacked_size: u32, -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub enum PackMethod { - None, - XorOnly, - Lzss, - XorLzss, - LzssHuffman, - XorLzssHuffman, - Deflate, - Unknown(u32), -} - -#[derive(Copy, Clone, Debug)] -pub struct EntryRef<'a> { - pub id: EntryId, - pub meta: &'a EntryMeta, -} - -#[derive(Copy, Clone, Debug)] -pub struct EntryInspect<'a> { - pub id: EntryId, - pub meta: &'a EntryMeta, - pub name_raw: &'a [u8; 12], - pub service_tail: &'a [u8; 4], - pub sort_to_original: i16, - pub data_offset_raw: u32, -} - -pub struct PackedResource { - pub meta: EntryMeta, - pub packed: Vec, -} - -#[derive(Clone, Debug)] -pub(crate) struct EntryRecord { - pub(crate) meta: EntryMeta, - pub(crate) name_raw: [u8; 12], - pub(crate) service_tail: [u8; 4], - pub(crate) sort_to_original: i16, - pub(crate) key16: u16, - pub(crate) data_offset_raw: u32, - pub(crate) packed_size_declared: u32, - pub(crate) packed_size_available: usize, - pub(crate) effective_offset: usize, -} - -impl Library { - pub fn open_path(path: impl AsRef) -> Result { - Self::open_path_with(path, OpenOptions::default()) - } - - pub fn open_path_with(path: impl AsRef, opts: OpenOptions) -> Result { - let bytes = fs::read(path.as_ref())?; - let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice()); - parse_library(arc, opts) - } - - pub fn header(&self) -> &LibraryHeader { - &self.header - } - - pub fn ao_trailer(&self) -> Option<&AoTrailer> { - self.ao_trailer.as_ref() - } - - pub fn entry_count(&self) -> usize { - self.entries.len() - } - - pub fn entries(&self) -> impl Iterator> { - self.entries.iter().enumerate().filter_map(|(idx, entry)| { - let id = u32::try_from(idx).ok()?; - Some(EntryRef { - id: EntryId(id), - meta: &entry.meta, - }) - }) - } - - pub fn entries_inspect(&self) -> impl Iterator> { - self.entries.iter().enumerate().filter_map(|(idx, entry)| { - let id = u32::try_from(idx).ok()?; - Some(EntryInspect { - id: EntryId(id), - meta: &entry.meta, - name_raw: &entry.name_raw, - service_tail: &entry.service_tail, - sort_to_original: entry.sort_to_original, - data_offset_raw: entry.data_offset_raw, - }) - }) - } - - pub fn find(&self, name: &str) -> Option { - if self.entries.is_empty() { - return None; - } - - const MAX_INLINE_NAME: usize = 12; - - // Fast path: use stack allocation for short ASCII names (95% of cases) - if name.len() <= MAX_INLINE_NAME && name.is_ascii() { - let mut buf = [0u8; MAX_INLINE_NAME]; - for (i, &b) in name.as_bytes().iter().enumerate() { - buf[i] = b.to_ascii_uppercase(); - } - return self.find_impl(&buf[..name.len()]); - } - - // Slow path: heap allocation for long or non-ASCII names - let query = name.to_ascii_uppercase(); - self.find_impl(query.as_bytes()) - } - - fn find_impl(&self, query_bytes: &[u8]) -> Option { - // Binary search - let mut low = 0usize; - let mut high = self.entries.len(); - while low < high { - let mid = low + (high - low) / 2; - let idx = self.entries[mid].sort_to_original; - if idx < 0 { - break; - } - let idx = usize::try_from(idx).ok()?; - if idx >= self.entries.len() { - break; - } - - let cmp = cmp_c_string(query_bytes, c_name_bytes(&self.entries[idx].name_raw)); - match cmp { - Ordering::Less => high = mid, - Ordering::Greater => low = mid + 1, - Ordering::Equal => { - let id = u32::try_from(idx).ok()?; - return Some(EntryId(id)); - } - } - } - - // Linear fallback search - self.entries.iter().enumerate().find_map(|(idx, entry)| { - if cmp_c_string(query_bytes, c_name_bytes(&entry.name_raw)) == Ordering::Equal { - let id = u32::try_from(idx).ok()?; - Some(EntryId(id)) - } else { - None - } - }) - } - - pub fn get(&self, id: EntryId) -> Option> { - let idx = usize::try_from(id.0).ok()?; - let entry = self.entries.get(idx)?; - Some(EntryRef { - id, - meta: &entry.meta, - }) - } - - pub fn inspect(&self, id: EntryId) -> Option> { - let idx = usize::try_from(id.0).ok()?; - let entry = self.entries.get(idx)?; - Some(EntryInspect { - id, - meta: &entry.meta, - name_raw: &entry.name_raw, - service_tail: &entry.service_tail, - sort_to_original: entry.sort_to_original, - data_offset_raw: entry.data_offset_raw, - }) - } - - pub fn load(&self, id: EntryId) -> Result> { - let entry = self.entry_by_id(id)?; - let packed = self.packed_slice(id, entry)?; - decode_payload( - packed, - entry.meta.method, - entry.key16, - entry.meta.unpacked_size, - ) - } - - pub fn load_into(&self, id: EntryId, out: &mut dyn OutputBuffer) -> Result { - let decoded = self.load(id)?; - out.write_exact(&decoded)?; - Ok(decoded.len()) - } - - pub fn load_packed(&self, id: EntryId) -> Result { - let entry = self.entry_by_id(id)?; - let packed = self.packed_slice(id, entry)?.to_vec(); - Ok(PackedResource { - meta: entry.meta.clone(), - packed, - }) - } - - pub fn unpack(&self, packed: &PackedResource) -> Result> { - let key16 = self.resolve_key_for_meta(&packed.meta).unwrap_or(0); - - let method = packed.meta.method; - if needs_xor_key(method) && self.resolve_key_for_meta(&packed.meta).is_none() { - return Err(Error::CorruptEntryTable( - "cannot resolve XOR key for packed resource", - )); - } - - decode_payload(&packed.packed, method, key16, packed.meta.unpacked_size) - } - - pub fn load_fast(&self, id: EntryId) -> Result> { - let entry = self.entry_by_id(id)?; - if entry.meta.method == PackMethod::None { - let packed = self.packed_slice(id, entry)?; - let size = - usize::try_from(entry.meta.unpacked_size).map_err(|_| Error::IntegerOverflow)?; - if packed.len() < size { - return Err(Error::OutputSizeMismatch { - expected: entry.meta.unpacked_size, - got: u32::try_from(packed.len()).unwrap_or(u32::MAX), - }); - } - return Ok(ResourceData::Borrowed(&packed[..size])); - } - Ok(ResourceData::Owned(self.load(id)?)) - } - - fn entry_by_id(&self, id: EntryId) -> Result<&EntryRecord> { - let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?; - self.entries - .get(idx) - .ok_or_else(|| Error::EntryIdOutOfRange { - id: id.0, - entry_count: saturating_u32_len(self.entries.len()), - }) - } - - fn packed_slice<'a>(&'a self, id: EntryId, entry: &EntryRecord) -> Result<&'a [u8]> { - let start = entry.effective_offset; - let end = start - .checked_add(entry.packed_size_available) - .ok_or(Error::IntegerOverflow)?; - self.bytes - .get(start..end) - .ok_or(Error::EntryDataOutOfBounds { - id: id.0, - offset: u64::try_from(start).unwrap_or(u64::MAX), - size: entry.packed_size_declared, - file_len: u64::try_from(self.bytes.len()).unwrap_or(u64::MAX), - }) - } - - fn resolve_key_for_meta(&self, meta: &EntryMeta) -> Option { - self.entries - .iter() - .find(|entry| { - entry.meta.name == meta.name - && entry.meta.flags == meta.flags - && entry.meta.data_offset == meta.data_offset - && entry.meta.packed_size == meta.packed_size - && entry.meta.unpacked_size == meta.unpacked_size - && entry.meta.method == meta.method - }) - .map(|entry| entry.key16) - } - - #[cfg(test)] - pub(crate) fn rebuild_from_parsed_metadata(&self) -> Result> { - let trailer_len = usize::from(self.ao_trailer.is_some()) * 6; - let pre_trailer_size = self - .source_size - .checked_sub(trailer_len) - .ok_or(Error::IntegerOverflow)?; - - let count = self.entries.len(); - let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?; - let table_end = 32usize - .checked_add(table_len) - .ok_or(Error::IntegerOverflow)?; - if pre_trailer_size < table_end { - return Err(Error::EntryTableOutOfBounds { - table_offset: 32, - table_len: u64::try_from(table_len).map_err(|_| Error::IntegerOverflow)?, - file_len: u64::try_from(pre_trailer_size).map_err(|_| Error::IntegerOverflow)?, - }); - } - - let mut out = vec![0u8; pre_trailer_size]; - out[0..32].copy_from_slice(&self.header.raw); - let encrypted_table = xor_stream( - &self.table_plain_original, - (self.header.xor_seed & 0xFFFF) as u16, - ); - out[32..table_end].copy_from_slice(&encrypted_table); - - let mut occupied = vec![false; pre_trailer_size]; - for byte in occupied.iter_mut().take(table_end) { - *byte = true; - } - - for (idx, entry) in self.entries.iter().enumerate() { - let id = u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?; - let packed = self.load_packed(EntryId(id))?.packed; - let start = - usize::try_from(entry.data_offset_raw).map_err(|_| Error::IntegerOverflow)?; - for (offset, byte) in packed.iter().copied().enumerate() { - let pos = start.checked_add(offset).ok_or(Error::IntegerOverflow)?; - if pos >= out.len() { - return Err(Error::PackedSizePastEof { - id, - offset: u64::from(entry.data_offset_raw), - packed_size: entry.packed_size_declared, - file_len: u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?, - }); - } - if occupied[pos] && out[pos] != byte { - return Err(Error::CorruptEntryTable("packed payload overlap conflict")); - } - out[pos] = byte; - occupied[pos] = true; - } - } - - if let Some(trailer) = &self.ao_trailer { - out.extend_from_slice(&trailer.raw); - } - Ok(out) - } -} - -fn decode_payload( - packed: &[u8], - method: PackMethod, - key16: u16, - unpacked_size: u32, -) -> Result> { - let expected = usize::try_from(unpacked_size).map_err(|_| Error::IntegerOverflow)?; - - let out = match method { - PackMethod::None => { - if packed.len() < expected { - return Err(Error::OutputSizeMismatch { - expected: unpacked_size, - got: u32::try_from(packed.len()).unwrap_or(u32::MAX), - }); - } - packed[..expected].to_vec() - } - PackMethod::XorOnly => { - if packed.len() < expected { - return Err(Error::OutputSizeMismatch { - expected: unpacked_size, - got: u32::try_from(packed.len()).unwrap_or(u32::MAX), - }); - } - xor_stream(&packed[..expected], key16) - } - PackMethod::Lzss => lzss_decompress_simple(packed, expected, None)?, - PackMethod::XorLzss => { - // Optimized: XOR on-the-fly during decompression instead of creating temp buffer - lzss_decompress_simple(packed, expected, Some(key16))? - } - PackMethod::LzssHuffman => lzss_huffman_decompress(packed, expected, None)?, - PackMethod::XorLzssHuffman => { - // Optimized: XOR on-the-fly during decompression - lzss_huffman_decompress(packed, expected, Some(key16))? - } - PackMethod::Deflate => decode_deflate(packed)?, - PackMethod::Unknown(raw) => return Err(Error::UnsupportedMethod { raw }), - }; - - if out.len() != expected { - return Err(Error::OutputSizeMismatch { - expected: unpacked_size, - got: u32::try_from(out.len()).unwrap_or(u32::MAX), - }); - } - - Ok(out) -} - -fn needs_xor_key(method: PackMethod) -> bool { - matches!( - method, - PackMethod::XorOnly | PackMethod::XorLzss | PackMethod::XorLzssHuffman - ) -} - -fn saturating_u32_len(len: usize) -> u32 { - u32::try_from(len).unwrap_or(u32::MAX) -} - -#[cfg(test)] -mod tests; diff --git a/crates/rsli/src/parse.rs b/crates/rsli/src/parse.rs deleted file mode 100644 index d3afcd9..0000000 --- a/crates/rsli/src/parse.rs +++ /dev/null @@ -1,278 +0,0 @@ -use crate::compress::xor::xor_stream; -use crate::error::Error; -use crate::{ - AoTrailer, EntryMeta, EntryRecord, Library, LibraryHeader, OpenOptions, PackMethod, Result, -}; -use std::cmp::Ordering; -use std::sync::Arc; - -pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result { - if bytes.len() < 32 { - return Err(Error::EntryTableOutOfBounds { - table_offset: 32, - table_len: 0, - file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?, - }); - } - - let mut header_raw = [0u8; 32]; - header_raw.copy_from_slice(&bytes[0..32]); - - let mut magic = [0u8; 2]; - magic.copy_from_slice(&bytes[0..2]); - if &magic != b"NL" { - let mut got = [0u8; 2]; - got.copy_from_slice(&bytes[0..2]); - return Err(Error::InvalidMagic { got }); - } - let reserved = bytes[2]; - let version = bytes[3]; - if version != 0x01 { - return Err(Error::UnsupportedVersion { got: version }); - } - - let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]); - if entry_count < 0 { - return Err(Error::InvalidEntryCount { got: entry_count }); - } - let count = usize::try_from(entry_count).map_err(|_| Error::IntegerOverflow)?; - - // Validate entry_count fits in u32 (required for EntryId) - if count > u32::MAX as usize { - return Err(Error::TooManyEntries { got: count }); - } - - let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]); - let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); - let header = LibraryHeader { - raw: header_raw, - magic, - reserved, - version, - entry_count, - presorted_flag, - xor_seed, - }; - - let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?; - let table_offset = 32usize; - let table_end = table_offset - .checked_add(table_len) - .ok_or(Error::IntegerOverflow)?; - if table_end > bytes.len() { - return Err(Error::EntryTableOutOfBounds { - table_offset: u64::try_from(table_offset).map_err(|_| Error::IntegerOverflow)?, - table_len: u64::try_from(table_len).map_err(|_| Error::IntegerOverflow)?, - file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?, - }); - } - - let table_enc = &bytes[table_offset..table_end]; - let table_plain_original = xor_stream(table_enc, (xor_seed & 0xFFFF) as u16); - if table_plain_original.len() != table_len { - return Err(Error::EntryTableDecryptFailed); - } - - let (overlay, trailer_raw) = parse_ao_trailer(&bytes, opts.allow_ao_trailer)?; - - let mut entries = Vec::with_capacity(count); - for idx in 0..count { - let row = &table_plain_original[idx * 32..(idx + 1) * 32]; - - let mut name_raw = [0u8; 12]; - name_raw.copy_from_slice(&row[0..12]); - let mut service_tail = [0u8; 4]; - service_tail.copy_from_slice(&row[12..16]); - - let flags_signed = i16::from_le_bytes([row[16], row[17]]); - let sort_to_original = i16::from_le_bytes([row[18], row[19]]); - let unpacked_size = u32::from_le_bytes([row[20], row[21], row[22], row[23]]); - let data_offset_raw = u32::from_le_bytes([row[24], row[25], row[26], row[27]]); - let packed_size_declared = u32::from_le_bytes([row[28], row[29], row[30], row[31]]); - - let method_raw = (flags_signed as u16 as u32) & 0x1E0; - let method = parse_method(method_raw); - - let effective_offset_u64 = u64::from(data_offset_raw) - .checked_add(u64::from(overlay)) - .ok_or(Error::IntegerOverflow)?; - let effective_offset = - usize::try_from(effective_offset_u64).map_err(|_| Error::IntegerOverflow)?; - - let packed_size_usize = - usize::try_from(packed_size_declared).map_err(|_| Error::IntegerOverflow)?; - let mut packed_size_available = packed_size_usize; - - let end = effective_offset_u64 - .checked_add(u64::from(packed_size_declared)) - .ok_or(Error::IntegerOverflow)?; - let file_len_u64 = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?; - - if end > file_len_u64 { - if method_raw == 0x100 && end == file_len_u64 + 1 { - if opts.allow_deflate_eof_plus_one { - packed_size_available = packed_size_available - .checked_sub(1) - .ok_or(Error::IntegerOverflow)?; - } else { - return Err(Error::DeflateEofPlusOneQuirkRejected { - id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?, - }); - } - } else { - return Err(Error::PackedSizePastEof { - id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?, - offset: effective_offset_u64, - packed_size: packed_size_declared, - file_len: file_len_u64, - }); - } - } - - let available_end = effective_offset - .checked_add(packed_size_available) - .ok_or(Error::IntegerOverflow)?; - if available_end > bytes.len() { - return Err(Error::EntryDataOutOfBounds { - id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?, - offset: effective_offset_u64, - size: packed_size_declared, - file_len: file_len_u64, - }); - } - - let name = decode_name(c_name_bytes(&name_raw)); - - entries.push(EntryRecord { - meta: EntryMeta { - name, - flags: i32::from(flags_signed), - method, - data_offset: effective_offset_u64, - packed_size: packed_size_declared, - unpacked_size, - }, - name_raw, - service_tail, - sort_to_original, - key16: sort_to_original as u16, - data_offset_raw, - packed_size_declared, - packed_size_available, - effective_offset, - }); - } - - if presorted_flag == 0xABBA { - let mut seen = vec![false; count]; - for entry in &entries { - let idx = i32::from(entry.sort_to_original); - if idx < 0 { - return Err(Error::CorruptEntryTable( - "sort_to_original is not a valid permutation index", - )); - } - let idx = usize::try_from(idx).map_err(|_| Error::IntegerOverflow)?; - if idx >= count { - return Err(Error::CorruptEntryTable( - "sort_to_original is not a valid permutation index", - )); - } - if seen[idx] { - return Err(Error::CorruptEntryTable( - "sort_to_original is not a permutation", - )); - } - seen[idx] = true; - } - if seen.iter().any(|value| !*value) { - return Err(Error::CorruptEntryTable( - "sort_to_original is not a permutation", - )); - } - } else { - let mut sorted: Vec = (0..count).collect(); - sorted.sort_by(|a, b| { - cmp_c_string( - c_name_bytes(&entries[*a].name_raw), - c_name_bytes(&entries[*b].name_raw), - ) - }); - for (idx, entry) in entries.iter_mut().enumerate() { - entry.sort_to_original = - i16::try_from(sorted[idx]).map_err(|_| Error::IntegerOverflow)?; - entry.key16 = entry.sort_to_original as u16; - } - } - - #[cfg(test)] - let source_size = bytes.len(); - - Ok(Library { - bytes, - entries, - header, - ao_trailer: trailer_raw.map(|raw| AoTrailer { raw, overlay }), - #[cfg(test)] - table_plain_original, - #[cfg(test)] - source_size, - }) -} - -fn parse_ao_trailer(bytes: &[u8], allow: bool) -> Result<(u32, Option<[u8; 6]>)> { - if !allow || bytes.len() < 6 { - return Ok((0, None)); - } - - if &bytes[bytes.len() - 6..bytes.len() - 4] != b"AO" { - return Ok((0, None)); - } - - let mut trailer = [0u8; 6]; - trailer.copy_from_slice(&bytes[bytes.len() - 6..]); - let overlay = u32::from_le_bytes([trailer[2], trailer[3], trailer[4], trailer[5]]); - - if u64::from(overlay) > u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)? { - return Err(Error::MediaOverlayOutOfBounds { - overlay, - file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?, - }); - } - - Ok((overlay, Some(trailer))) -} - -pub fn parse_method(raw: u32) -> PackMethod { - match raw { - 0x000 => PackMethod::None, - 0x020 => PackMethod::XorOnly, - 0x040 => PackMethod::Lzss, - 0x060 => PackMethod::XorLzss, - 0x080 => PackMethod::LzssHuffman, - 0x0A0 => PackMethod::XorLzssHuffman, - 0x100 => PackMethod::Deflate, - other => PackMethod::Unknown(other), - } -} - -fn decode_name(name: &[u8]) -> String { - name.iter().map(|b| char::from(*b)).collect() -} - -pub fn c_name_bytes(raw: &[u8; 12]) -> &[u8] { - let len = raw.iter().position(|&b| b == 0).unwrap_or(raw.len()); - &raw[..len] -} - -pub fn cmp_c_string(a: &[u8], b: &[u8]) -> Ordering { - let min_len = a.len().min(b.len()); - let mut idx = 0usize; - while idx < min_len { - if a[idx] != b[idx] { - return a[idx].cmp(&b[idx]); - } - idx += 1; - } - a.len().cmp(&b.len()) -} diff --git a/crates/rsli/src/tests.rs b/crates/rsli/src/tests.rs deleted file mode 100644 index ffd611d..0000000 --- a/crates/rsli/src/tests.rs +++ /dev/null @@ -1,1338 +0,0 @@ -use super::*; -use crate::compress::lzh::{LZH_MAX_FREQ, LZH_N_CHAR, LZH_R, LZH_T}; -use crate::compress::xor::xor_stream; -use common::collect_files_recursive; -use flate2::write::DeflateEncoder; -use flate2::write::ZlibEncoder; -use flate2::Compression; -use proptest::prelude::*; -use std::any::Any; -use std::fs; -use std::io::Write as _; -use std::panic::{catch_unwind, AssertUnwindSafe}; -use std::path::PathBuf; -use std::sync::Arc; - -#[derive(Clone, Debug)] -struct SyntheticRsliEntry { - name: String, - method_raw: u16, - plain: Vec, - declared_packed_size: Option, -} - -#[derive(Clone, Debug)] -struct RsliBuildOptions { - seed: u32, - presorted: bool, - overlay: u32, - add_ao_trailer: bool, -} - -impl Default for RsliBuildOptions { - fn default() -> Self { - Self { - seed: 0x1234_5678, - presorted: true, - overlay: 0, - add_ao_trailer: false, - } - } -} - -fn rsli_test_files() -> Vec { - let root = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("testdata") - .join("rsli"); - let mut files = Vec::new(); - collect_files_recursive(&root, &mut files); - files.sort(); - files - .into_iter() - .filter(|path| { - fs::read(path) - .map(|data| data.get(0..4) == Some(b"NL\0\x01")) - .unwrap_or(false) - }) - .collect() -} - -fn panic_message(payload: Box) -> String { - let any = payload.as_ref(); - if let Some(message) = any.downcast_ref::() { - return message.clone(); - } - if let Some(message) = any.downcast_ref::<&str>() { - return (*message).to_string(); - } - String::from("panic without message") -} - -fn write_temp_file(prefix: &str, bytes: &[u8]) -> PathBuf { - let mut path = std::env::temp_dir(); - path.push(format!( - "{}-{}-{}.bin", - prefix, - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - fs::write(&path, bytes).expect("failed to write temp archive"); - path -} - -fn deflate_raw(data: &[u8]) -> Vec { - let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default()); - encoder - .write_all(data) - .expect("deflate encoder write failed"); - encoder.finish().expect("deflate encoder finish failed") -} - -fn deflate_zlib(data: &[u8]) -> Vec { - let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(data).expect("zlib encoder write failed"); - encoder.finish().expect("zlib encoder finish failed") -} - -fn lzss_pack_literals(data: &[u8]) -> Vec { - let mut out = Vec::new(); - for chunk in data.chunks(8) { - let mask = if chunk.len() == 8 { - 0xFF - } else { - (1u16 - .checked_shl(u32::try_from(chunk.len()).expect("chunk len overflow")) - .expect("shift overflow") - - 1) as u8 - }; - out.push(mask); - out.extend_from_slice(chunk); - } - out -} - -struct BitWriter { - bytes: Vec, - current: u8, - mask: u8, -} - -impl BitWriter { - fn new() -> Self { - Self { - bytes: Vec::new(), - current: 0, - mask: 0x80, - } - } - - fn write_bit(&mut self, bit: u8) { - if bit != 0 { - self.current |= self.mask; - } - self.mask >>= 1; - if self.mask == 0 { - self.bytes.push(self.current); - self.current = 0; - self.mask = 0x80; - } - } - - fn finish(mut self) -> Vec { - if self.mask != 0x80 { - self.bytes.push(self.current); - } - self.bytes - } -} - -struct LzhLiteralModel { - freq: [u16; LZH_T + 1], - parent: [usize; LZH_T + LZH_N_CHAR], - son: [usize; LZH_T + 1], -} - -impl LzhLiteralModel { - fn new() -> Self { - let mut model = Self { - freq: [0; LZH_T + 1], - parent: [0; LZH_T + LZH_N_CHAR], - son: [0; LZH_T + 1], - }; - model.start_huff(); - model - } - - fn encode_literal(&mut self, literal: u8, writer: &mut BitWriter) { - let target = usize::from(literal) + LZH_T; - let mut path = Vec::new(); - let mut visited = [false; LZH_T + 1]; - let found = self.find_path(self.son[LZH_R], target, &mut path, &mut visited); - assert!(found, "failed to encode literal {literal}"); - for bit in path { - writer.write_bit(bit); - } - - self.update(usize::from(literal)); - } - - fn find_path( - &self, - node: usize, - target: usize, - path: &mut Vec, - visited: &mut [bool; LZH_T + 1], - ) -> bool { - if node == target { - return true; - } - if node >= LZH_T { - return false; - } - if visited[node] { - return false; - } - visited[node] = true; - - for bit in [0u8, 1u8] { - let child = self.son[node + usize::from(bit)]; - path.push(bit); - if self.find_path(child, target, path, visited) { - visited[node] = false; - return true; - } - path.pop(); - } - - visited[node] = false; - false - } - - fn start_huff(&mut self) { - for i in 0..LZH_N_CHAR { - self.freq[i] = 1; - self.son[i] = i + LZH_T; - self.parent[i + LZH_T] = i; - } - - let mut i = 0usize; - let mut j = LZH_N_CHAR; - while j <= LZH_R { - self.freq[j] = self.freq[i].saturating_add(self.freq[i + 1]); - self.son[j] = i; - self.parent[i] = j; - self.parent[i + 1] = j; - i += 2; - j += 1; - } - - self.freq[LZH_T] = u16::MAX; - self.parent[LZH_R] = 0; - } - - fn update(&mut self, c: usize) { - if self.freq[LZH_R] == LZH_MAX_FREQ { - self.reconstruct(); - } - - let mut current = self.parent[c + LZH_T]; - loop { - self.freq[current] = self.freq[current].saturating_add(1); - let freq = self.freq[current]; - - if current + 1 < self.freq.len() && freq > self.freq[current + 1] { - let mut swap_idx = current + 1; - while swap_idx + 1 < self.freq.len() && freq > self.freq[swap_idx + 1] { - swap_idx += 1; - } - - self.freq.swap(current, swap_idx); - - let left = self.son[current]; - let right = self.son[swap_idx]; - self.son[current] = right; - self.son[swap_idx] = left; - - self.parent[left] = swap_idx; - if left < LZH_T { - self.parent[left + 1] = swap_idx; - } - - self.parent[right] = current; - if right < LZH_T { - self.parent[right + 1] = current; - } - - current = swap_idx; - } - - current = self.parent[current]; - if current == 0 { - break; - } - } - } - - fn reconstruct(&mut self) { - let mut j = 0usize; - for i in 0..LZH_T { - if self.son[i] >= LZH_T { - self.freq[j] = self.freq[i].div_ceil(2); - self.son[j] = self.son[i]; - j += 1; - } - } - - let mut i = 0usize; - let mut current = LZH_N_CHAR; - while current < LZH_T { - let sum = self.freq[i].saturating_add(self.freq[i + 1]); - self.freq[current] = sum; - - let mut insert_at = current; - while insert_at > 0 && sum < self.freq[insert_at - 1] { - insert_at -= 1; - } - - for move_idx in (insert_at..current).rev() { - self.freq[move_idx + 1] = self.freq[move_idx]; - self.son[move_idx + 1] = self.son[move_idx]; - } - - self.freq[insert_at] = sum; - self.son[insert_at] = i; - i += 2; - current += 1; - } - - for idx in 0..LZH_T { - let node = self.son[idx]; - self.parent[node] = idx; - if node < LZH_T { - self.parent[node + 1] = idx; - } - } - - self.freq[LZH_T] = u16::MAX; - self.parent[LZH_R] = 0; - } -} - -fn lzh_pack_literals(data: &[u8]) -> Vec { - let mut writer = BitWriter::new(); - let mut model = LzhLiteralModel::new(); - for byte in data { - model.encode_literal(*byte, &mut writer); - } - writer.finish() -} - -fn packed_for_method(method_raw: u16, plain: &[u8], key16: u16) -> Vec { - match (u32::from(method_raw)) & 0x1E0 { - 0x000 => plain.to_vec(), - 0x020 => xor_stream(plain, key16), - 0x040 => lzss_pack_literals(plain), - 0x060 => xor_stream(&lzss_pack_literals(plain), key16), - 0x080 => lzh_pack_literals(plain), - 0x0A0 => xor_stream(&lzh_pack_literals(plain), key16), - 0x100 => deflate_raw(plain), - _ => plain.to_vec(), - } -} - -fn build_rsli_bytes(entries: &[SyntheticRsliEntry], opts: &RsliBuildOptions) -> Vec { - let count = entries.len(); - let mut rows_plain = vec![0u8; count * 32]; - let table_end = 32 + rows_plain.len(); - - let mut sort_lookup: Vec = (0..count).collect(); - sort_lookup.sort_by(|a, b| entries[*a].name.as_bytes().cmp(entries[*b].name.as_bytes())); - - let mut packed_blobs = Vec::with_capacity(count); - for index in 0..count { - let key16 = u16::try_from(sort_lookup[index]).expect("sort index overflow"); - let packed = packed_for_method(entries[index].method_raw, &entries[index].plain, key16); - packed_blobs.push(packed); - } - - let overlay = usize::try_from(opts.overlay).expect("overlay overflow"); - let mut cursor = table_end + overlay; - let mut output = vec![0u8; cursor]; - - let mut data_offsets = Vec::with_capacity(count); - for (index, packed) in packed_blobs.iter().enumerate() { - let raw_offset = cursor - .checked_sub(overlay) - .expect("overlay larger than cursor"); - data_offsets.push(raw_offset); - - let end = cursor.checked_add(packed.len()).expect("cursor overflow"); - if output.len() < end { - output.resize(end, 0); - } - output[cursor..end].copy_from_slice(packed); - cursor = end; - - let base = index * 32; - let mut name_raw = [0u8; 12]; - let uppercase = entries[index].name.to_ascii_uppercase(); - let name_bytes = uppercase.as_bytes(); - assert!(name_bytes.len() <= 12, "name too long in synthetic fixture"); - name_raw[..name_bytes.len()].copy_from_slice(name_bytes); - - rows_plain[base..base + 12].copy_from_slice(&name_raw); - - let sort_field: i16 = if opts.presorted { - i16::try_from(sort_lookup[index]).expect("sort field overflow") - } else { - 0 - }; - - let packed_size = entries[index] - .declared_packed_size - .unwrap_or_else(|| u32::try_from(packed.len()).expect("packed size overflow")); - - rows_plain[base + 16..base + 18].copy_from_slice(&entries[index].method_raw.to_le_bytes()); - rows_plain[base + 18..base + 20].copy_from_slice(&sort_field.to_le_bytes()); - rows_plain[base + 20..base + 24].copy_from_slice( - &u32::try_from(entries[index].plain.len()) - .expect("unpacked size overflow") - .to_le_bytes(), - ); - rows_plain[base + 24..base + 28].copy_from_slice( - &u32::try_from(data_offsets[index]) - .expect("data offset overflow") - .to_le_bytes(), - ); - rows_plain[base + 28..base + 32].copy_from_slice(&packed_size.to_le_bytes()); - } - - if output.len() < table_end { - output.resize(table_end, 0); - } - - output[0..2].copy_from_slice(b"NL"); - output[2] = 0; - output[3] = 1; - output[4..6].copy_from_slice( - &i16::try_from(count) - .expect("entry count overflow") - .to_le_bytes(), - ); - - let presorted_flag = if opts.presorted { 0xABBA_u16 } else { 0_u16 }; - output[14..16].copy_from_slice(&presorted_flag.to_le_bytes()); - output[20..24].copy_from_slice(&opts.seed.to_le_bytes()); - - let encrypted_table = xor_stream(&rows_plain, (opts.seed & 0xFFFF) as u16); - output[32..table_end].copy_from_slice(&encrypted_table); - - if opts.add_ao_trailer { - output.extend_from_slice(b"AO"); - output.extend_from_slice(&opts.overlay.to_le_bytes()); - } - - output -} - -fn read_u32_le(bytes: &[u8], offset: usize) -> u32 { - let slice = bytes - .get(offset..offset + 4) - .expect("u32 read out of bounds in test"); - let arr: [u8; 4] = slice.try_into().expect("u32 conversion failed in test"); - u32::from_le_bytes(arr) -} - -#[test] -fn rsli_read_unpack_and_repack_all_files() { - let files = rsli_test_files(); - if files.is_empty() { - eprintln!( - "skipping rsli_read_unpack_and_repack_all_files: no RsLi archives in testdata/rsli" - ); - return; - } - - let checked = files.len(); - let mut success = 0usize; - let mut failures = Vec::new(); - - for path in files { - let display_path = path.display().to_string(); - let result = catch_unwind(AssertUnwindSafe(|| { - let original = fs::read(&path).expect("failed to read archive"); - let library = Library::open_path(&path) - .unwrap_or_else(|err| panic!("failed to open {}: {err}", path.display())); - - let count = library.entry_count(); - assert_eq!( - count, - library.entries().count(), - "entry count mismatch: {}", - path.display() - ); - - for idx in 0..count { - let id = EntryId(idx as u32); - let meta_ref = library - .get(id) - .unwrap_or_else(|| panic!("missing entry #{idx} in {}", path.display())); - - let loaded = library.load(id).unwrap_or_else(|err| { - panic!("load failed for {} entry #{idx}: {err}", path.display()) - }); - - let packed = library.load_packed(id).unwrap_or_else(|err| { - panic!( - "load_packed failed for {} entry #{idx}: {err}", - path.display() - ) - }); - let unpacked = library.unpack(&packed).unwrap_or_else(|err| { - panic!("unpack failed for {} entry #{idx}: {err}", path.display()) - }); - assert_eq!( - loaded, - unpacked, - "load != unpack in {} entry #{idx}", - path.display() - ); - - let mut out = Vec::new(); - let written = library.load_into(id, &mut out).unwrap_or_else(|err| { - panic!( - "load_into failed for {} entry #{idx}: {err}", - path.display() - ) - }); - assert_eq!( - written, - loaded.len(), - "load_into size mismatch in {} entry #{idx}", - path.display() - ); - assert_eq!( - out, - loaded, - "load_into payload mismatch in {} entry #{idx}", - path.display() - ); - - let fast = library.load_fast(id).unwrap_or_else(|err| { - panic!( - "load_fast failed for {} entry #{idx}: {err}", - path.display() - ) - }); - assert_eq!( - fast.as_slice(), - loaded.as_slice(), - "load_fast mismatch in {} entry #{idx}", - path.display() - ); - - let found = library.find(&meta_ref.meta.name).unwrap_or_else(|| { - panic!( - "find failed for '{}' in {}", - meta_ref.meta.name, - path.display() - ) - }); - let found_meta = library.get(found).expect("find returned invalid entry id"); - assert_eq!( - found_meta.meta.name, - meta_ref.meta.name, - "find returned a different entry in {}", - path.display() - ); - } - - let rebuilt = library - .rebuild_from_parsed_metadata() - .unwrap_or_else(|err| panic!("rebuild failed for {}: {err}", path.display())); - assert_eq!( - rebuilt, - original, - "byte-to-byte roundtrip mismatch for {}", - path.display() - ); - })); - - match result { - Ok(()) => success += 1, - Err(payload) => failures.push(format!("{}: {}", display_path, panic_message(payload))), - } - } - - let failed = failures.len(); - eprintln!( - "RsLi summary: checked={}, success={}, failed={}", - checked, success, failed - ); - if !failures.is_empty() { - panic!( - "RsLi validation failed.\nsummary: checked={}, success={}, failed={}\n{}", - checked, - success, - failed, - failures.join("\n") - ); - } -} - -#[test] -fn rsli_docs_structural_invariants_all_files() { - let files = rsli_test_files(); - if files.is_empty() { - eprintln!( - "skipping rsli_docs_structural_invariants_all_files: no RsLi archives in testdata/rsli" - ); - return; - } - - let mut deflate_eof_plus_one_quirks = Vec::new(); - - for path in files { - let bytes = fs::read(&path).unwrap_or_else(|err| { - panic!("failed to read {}: {err}", path.display()); - }); - - assert!( - bytes.len() >= 32, - "RsLi header too short in {}", - path.display() - ); - assert_eq!(&bytes[0..2], b"NL", "bad magic in {}", path.display()); - assert_eq!( - bytes[2], - 0, - "reserved header byte must be zero in {}", - path.display() - ); - assert_eq!(bytes[3], 1, "bad version in {}", path.display()); - - let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]); - assert!( - entry_count >= 0, - "negative entry_count={} in {}", - entry_count, - path.display() - ); - let count = usize::try_from(entry_count).expect("entry_count overflow"); - let table_size = count.checked_mul(32).expect("table_size overflow"); - let table_end = 32usize.checked_add(table_size).expect("table_end overflow"); - assert!( - table_end <= bytes.len(), - "table out of bounds in {}", - path.display() - ); - - let seed = read_u32_le(&bytes, 20); - let table_plain = xor_stream(&bytes[32..table_end], (seed & 0xFFFF) as u16); - assert_eq!( - table_plain.len(), - table_size, - "decrypted table size mismatch in {}", - path.display() - ); - - let mut overlay = 0u32; - if bytes.len() >= 6 && &bytes[bytes.len() - 6..bytes.len() - 4] == b"AO" { - overlay = read_u32_le(&bytes, bytes.len() - 4); - assert!( - usize::try_from(overlay).expect("overlay overflow") <= bytes.len(), - "overlay beyond EOF in {}", - path.display() - ); - } - - let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]); - let mut sort_values = Vec::with_capacity(count); - - for index in 0..count { - let base = index * 32; - let row = &table_plain[base..base + 32]; - let flags_signed = i16::from_le_bytes([row[16], row[17]]); - let sort_to_original = i16::from_le_bytes([row[18], row[19]]); - let data_offset = u64::from(read_u32_le(row, 24)); - let packed_size = u64::from(read_u32_le(row, 28)); - - let method = (flags_signed as u16 as u32) & 0x1E0; - let effective_offset = data_offset + u64::from(overlay); - let end = effective_offset + packed_size; - let file_len = u64::try_from(bytes.len()).expect("file size overflow"); - - if end > file_len { - assert!( - method == 0x100 && end == file_len + 1, - "packed range out of bounds in {} entry #{index}: method=0x{method:03X}, range=[{effective_offset}, {end}), file={file_len}", - path.display() - ); - deflate_eof_plus_one_quirks.push((path.display().to_string(), index)); - } - - sort_values.push(sort_to_original); - } - - if presorted_flag == 0xABBA { - let mut sorted = sort_values; - sorted.sort_unstable(); - let expected: Vec = (0..count) - .map(|idx| i16::try_from(idx).expect("too many entries for i16")) - .collect(); - assert_eq!( - sorted, - expected, - "sort_to_original is not a permutation in {}", - path.display() - ); - } - } - - if !deflate_eof_plus_one_quirks.is_empty() { - assert!( - deflate_eof_plus_one_quirks - .iter() - .all(|(file, idx)| file.ends_with("sprites.lib") && *idx == 23), - "unexpected deflate EOF+1 quirks: {:?}", - deflate_eof_plus_one_quirks - ); - } -} - -#[test] -fn rsli_synthetic_all_methods_roundtrip() { - let entries = vec![ - SyntheticRsliEntry { - name: "M_NONE".to_string(), - method_raw: 0x000, - plain: b"plain-data".to_vec(), - declared_packed_size: None, - }, - SyntheticRsliEntry { - name: "M_XOR".to_string(), - method_raw: 0x020, - plain: b"xor-only".to_vec(), - declared_packed_size: None, - }, - SyntheticRsliEntry { - name: "M_LZSS".to_string(), - method_raw: 0x040, - plain: b"lzss literals payload".to_vec(), - declared_packed_size: None, - }, - SyntheticRsliEntry { - name: "M_XLZS".to_string(), - method_raw: 0x060, - plain: b"xor lzss payload".to_vec(), - declared_packed_size: None, - }, - SyntheticRsliEntry { - name: "M_LZHU".to_string(), - method_raw: 0x080, - plain: b"huffman literals payload".to_vec(), - declared_packed_size: None, - }, - SyntheticRsliEntry { - name: "M_XLZH".to_string(), - method_raw: 0x0A0, - plain: b"xor huffman payload".to_vec(), - declared_packed_size: None, - }, - SyntheticRsliEntry { - name: "M_DEFL".to_string(), - method_raw: 0x100, - plain: b"deflate payload with repetition repetition repetition".to_vec(), - declared_packed_size: None, - }, - ]; - - let bytes = build_rsli_bytes( - &entries, - &RsliBuildOptions { - seed: 0xA1B2_C3D4, - presorted: false, - overlay: 0, - add_ao_trailer: false, - }, - ); - let path = write_temp_file("rsli-all-methods", &bytes); - - let library = Library::open_path(&path).expect("open synthetic rsli failed"); - assert_eq!(library.entry_count(), entries.len()); - - for entry in &entries { - let id = library - .find(&entry.name) - .unwrap_or_else(|| panic!("find failed for {}", entry.name)); - let loaded = library - .load(id) - .unwrap_or_else(|err| panic!("load failed for {}: {err}", entry.name)); - assert_eq!( - loaded, entry.plain, - "decoded payload mismatch for {}", - entry.name - ); - - let packed = library - .load_packed(id) - .unwrap_or_else(|err| panic!("load_packed failed for {}: {err}", entry.name)); - let unpacked = library - .unpack(&packed) - .unwrap_or_else(|err| panic!("unpack failed for {}: {err}", entry.name)); - assert_eq!(unpacked, entry.plain, "unpack mismatch for {}", entry.name); - } - - let _ = fs::remove_file(&path); -} - -#[test] -fn rsli_empty_archive_roundtrip() { - let bytes = build_rsli_bytes(&[], &RsliBuildOptions::default()); - let path = write_temp_file("rsli-empty", &bytes); - - let library = Library::open_path(&path).expect("open empty rsli failed"); - assert_eq!(library.entry_count(), 0); - assert_eq!(library.find("ANYTHING"), None); - - let rebuilt = library - .rebuild_from_parsed_metadata() - .expect("rebuild empty rsli failed"); - assert_eq!(rebuilt, bytes, "empty rsli roundtrip mismatch"); - - let _ = fs::remove_file(&path); -} - -#[test] -fn rsli_max_name_length_without_nul_roundtrip() { - let max_name = "NAME12345678"; - assert_eq!(max_name.len(), 12); - - let bytes = build_rsli_bytes( - &[SyntheticRsliEntry { - name: max_name.to_string(), - method_raw: 0x000, - plain: b"payload".to_vec(), - declared_packed_size: None, - }], - &RsliBuildOptions::default(), - ); - let path = write_temp_file("rsli-max-name", &bytes); - - let library = Library::open_path(&path).expect("open max-name rsli failed"); - assert_eq!(library.entry_count(), 1); - assert_eq!(library.find(max_name), Some(EntryId(0))); - assert_eq!( - library.find(&max_name.to_ascii_lowercase()), - Some(EntryId(0)) - ); - assert_eq!( - library.entries[0] - .name_raw - .iter() - .position(|byte| *byte == 0), - None, - "name_raw must occupy full 12 bytes without NUL" - ); - - let entry = library.get(EntryId(0)).expect("missing entry"); - assert_eq!(entry.meta.name, max_name); - assert_eq!( - library.load(EntryId(0)).expect("load failed"), - b"payload", - "payload mismatch" - ); - - let _ = fs::remove_file(&path); -} - -#[test] -fn rsli_lzss_large_payload_over_4k_roundtrip() { - let plain: Vec = (0..10_000u32).map(|v| (v % 251) as u8).collect(); - let entries = vec![ - SyntheticRsliEntry { - name: "LZSS4K".to_string(), - method_raw: 0x040, - plain: plain.clone(), - declared_packed_size: None, - }, - SyntheticRsliEntry { - name: "XLZS4K".to_string(), - method_raw: 0x060, - plain: plain.clone(), - declared_packed_size: None, - }, - ]; - let bytes = build_rsli_bytes(&entries, &RsliBuildOptions::default()); - let path = write_temp_file("rsli-lzss-4k", &bytes); - - let library = Library::open_path(&path).expect("open large-lzss rsli failed"); - assert_eq!(library.entry_count(), entries.len()); - - for entry in &entries { - let id = library - .find(&entry.name) - .unwrap_or_else(|| panic!("find failed for {}", entry.name)); - let loaded = library - .load(id) - .unwrap_or_else(|err| panic!("load failed for {}: {err}", entry.name)); - assert_eq!(loaded, plain, "payload mismatch for {}", entry.name); - } - - let _ = fs::remove_file(&path); -} - -#[test] -fn rsli_find_falls_back_when_sort_table_corrupted_in_memory() { - let entries = vec![ - SyntheticRsliEntry { - name: "AAA".to_string(), - method_raw: 0x000, - plain: b"a".to_vec(), - declared_packed_size: None, - }, - SyntheticRsliEntry { - name: "BBB".to_string(), - method_raw: 0x000, - plain: b"b".to_vec(), - declared_packed_size: None, - }, - SyntheticRsliEntry { - name: "CCC".to_string(), - method_raw: 0x000, - plain: b"c".to_vec(), - declared_packed_size: None, - }, - ]; - let bytes = build_rsli_bytes( - &entries, - &RsliBuildOptions { - presorted: true, - ..RsliBuildOptions::default() - }, - ); - let path = write_temp_file("rsli-find-fallback", &bytes); - - let mut library = Library::open_path(&path).expect("open synthetic rsli failed"); - library.entries[1].sort_to_original = -1; - - assert_eq!(library.find("AAA"), Some(EntryId(0))); - assert_eq!(library.find("bbb"), Some(EntryId(1))); - assert_eq!(library.find("CcC"), Some(EntryId(2))); - assert_eq!(library.find("missing"), None); - - let _ = fs::remove_file(&path); -} - -#[test] -fn rsli_deflate_method_rejects_zlib_wrapped_stream() { - let plain = b"payload".to_vec(); - let zlib_payload = deflate_zlib(&plain); - let entries = vec![SyntheticRsliEntry { - name: "ZLIB".to_string(), - method_raw: 0x100, - plain, - declared_packed_size: Some( - u32::try_from(zlib_payload.len()).expect("zlib payload size overflow"), - ), - }]; - let mut bytes = build_rsli_bytes( - &entries, - &RsliBuildOptions { - presorted: true, - ..RsliBuildOptions::default() - }, - ); - - let table_end = 32 + entries.len() * 32; - let data_offset = table_end; - let data_end = data_offset + zlib_payload.len(); - if bytes.len() < data_end { - bytes.resize(data_end, 0); - } - bytes[data_offset..data_end].copy_from_slice(&zlib_payload); - - let path = write_temp_file("rsli-zlib-reject", &bytes); - let library = Library::open_path(&path).expect("open zlib-wrapped rsli failed"); - match library.load(EntryId(0)) { - Err(Error::DecompressionFailed(reason)) => { - assert_eq!(reason, "deflate"); - } - other => panic!("expected deflate decompression error, got {other:?}"), - } - let _ = fs::remove_file(&path); -} - -#[test] -fn rsli_lzss_huffman_reports_unexpected_eof() { - let entries = vec![SyntheticRsliEntry { - name: "TRUNC".to_string(), - method_raw: 0x080, - plain: b"this payload is long enough".to_vec(), - declared_packed_size: None, - }]; - let mut bytes = build_rsli_bytes( - &entries, - &RsliBuildOptions { - presorted: true, - ..RsliBuildOptions::default() - }, - ); - - let seed = read_u32_le(&bytes, 20); - let mut table_plain = xor_stream(&bytes[32..64], (seed & 0xFFFF) as u16); - let original_packed_size = u32::from_le_bytes([ - table_plain[28], - table_plain[29], - table_plain[30], - table_plain[31], - ]); - assert!( - original_packed_size > 4, - "packed payload too small for truncation" - ); - let truncated_size = original_packed_size - 3; - table_plain[28..32].copy_from_slice(&truncated_size.to_le_bytes()); - let encrypted_table = xor_stream(&table_plain, (seed & 0xFFFF) as u16); - bytes[32..64].copy_from_slice(&encrypted_table); - - let path = write_temp_file("rsli-lzh-truncated", &bytes); - let library = Library::open_path(&path).expect("open truncated lzh rsli failed"); - match library.load(EntryId(0)) { - Err(Error::DecompressionFailed(reason)) => { - assert_eq!(reason, "lzss-huffman: unexpected EOF"); - } - other => panic!("expected lzss-huffman EOF error, got {other:?}"), - } - let _ = fs::remove_file(&path); -} - -#[test] -fn rsli_presorted_flag_requires_permutation() { - let entries = vec![ - SyntheticRsliEntry { - name: "AAA".to_string(), - method_raw: 0x000, - plain: b"a".to_vec(), - declared_packed_size: None, - }, - SyntheticRsliEntry { - name: "BBB".to_string(), - method_raw: 0x000, - plain: b"b".to_vec(), - declared_packed_size: None, - }, - ]; - let mut bytes = build_rsli_bytes( - &entries, - &RsliBuildOptions { - presorted: true, - ..RsliBuildOptions::default() - }, - ); - - let seed = read_u32_le(&bytes, 20); - let mut table_plain = xor_stream(&bytes[32..32 + entries.len() * 32], (seed & 0xFFFF) as u16); - - // Corrupt sort_to_original: duplicate index 0, so the table is not a permutation. - table_plain[18..20].copy_from_slice(&0i16.to_le_bytes()); - table_plain[50..52].copy_from_slice(&0i16.to_le_bytes()); - - let table_encrypted = xor_stream(&table_plain, (seed & 0xFFFF) as u16); - bytes[32..32 + table_encrypted.len()].copy_from_slice(&table_encrypted); - - let path = write_temp_file("rsli-bad-presorted-perm", &bytes); - match Library::open_path(&path) { - Err(Error::CorruptEntryTable(message)) => { - assert!( - message.contains("permutation"), - "unexpected error message: {message}" - ); - } - other => panic!("expected CorruptEntryTable for invalid permutation, got {other:?}"), - } - let _ = fs::remove_file(&path); -} - -#[test] -fn rsli_load_reports_correct_entry_id_on_range_failure() { - let entries = vec![ - SyntheticRsliEntry { - name: "ONE".to_string(), - method_raw: 0x000, - plain: b"one".to_vec(), - declared_packed_size: None, - }, - SyntheticRsliEntry { - name: "TWO".to_string(), - method_raw: 0x000, - plain: b"two".to_vec(), - declared_packed_size: None, - }, - ]; - let bytes = build_rsli_bytes( - &entries, - &RsliBuildOptions { - presorted: true, - ..RsliBuildOptions::default() - }, - ); - let path = write_temp_file("rsli-entry-id-error", &bytes); - - let mut library = Library::open_path(&path).expect("open synthetic rsli failed"); - library.entries[1].packed_size_available = usize::MAX; - - match library.load(EntryId(1)) { - Err(Error::IntegerOverflow) => {} - other => panic!("expected IntegerOverflow, got {other:?}"), - } - - library.entries[1].packed_size_available = library.bytes.len(); - match library.load(EntryId(1)) { - Err(Error::EntryDataOutOfBounds { id, .. }) => assert_eq!(id, 1), - other => panic!("expected EntryDataOutOfBounds with id=1, got {other:?}"), - } - - let _ = fs::remove_file(&path); -} - -#[test] -fn rsli_xorlzss_huffman_on_the_fly_roundtrip() { - let plain: Vec = (0..512u16).map(|i| b'A' + (i % 26) as u8).collect(); - let entries = vec![SyntheticRsliEntry { - name: "XLZH_ONFLY".to_string(), - method_raw: 0x0A0, - plain: plain.clone(), - declared_packed_size: None, - }]; - - let bytes = build_rsli_bytes( - &entries, - &RsliBuildOptions { - seed: 0x0BAD_C0DE, - presorted: true, - overlay: 0, - add_ao_trailer: false, - }, - ); - let path = write_temp_file("rsli-xorlzh-onfly", &bytes); - - let library = Library::open_path(&path).expect("open synthetic XLZH archive failed"); - let id = library - .find("XLZH_ONFLY") - .expect("find XLZH_ONFLY entry failed"); - - let loaded = library.load(id).expect("load XLZH_ONFLY failed"); - assert_eq!(loaded, plain); - - let packed = library - .load_packed(id) - .expect("load_packed XLZH_ONFLY failed"); - let unpacked = library.unpack(&packed).expect("unpack XLZH_ONFLY failed"); - assert_eq!(unpacked, loaded); - - let _ = fs::remove_file(&path); -} - -#[test] -fn rsli_synthetic_overlay_and_ao_trailer() { - let entries = vec![SyntheticRsliEntry { - name: "OVERLAY".to_string(), - method_raw: 0x040, - plain: b"overlay-data".to_vec(), - declared_packed_size: None, - }]; - - let bytes = build_rsli_bytes( - &entries, - &RsliBuildOptions { - seed: 0x4433_2211, - presorted: true, - overlay: 128, - add_ao_trailer: true, - }, - ); - let path = write_temp_file("rsli-overlay", &bytes); - - let library = Library::open_path_with( - &path, - OpenOptions { - allow_ao_trailer: true, - allow_deflate_eof_plus_one: true, - }, - ) - .expect("open with AO trailer enabled failed"); - - let id = library.find("OVERLAY").expect("find overlay entry failed"); - let payload = library.load(id).expect("load overlay entry failed"); - assert_eq!(payload, b"overlay-data"); - - let _ = fs::remove_file(&path); -} - -#[test] -fn rsli_deflate_eof_plus_one_quirk() { - let plain = b"quirk deflate payload".to_vec(); - let packed = deflate_raw(&plain); - let declared = u32::try_from(packed.len() + 1).expect("declared size overflow"); - - let entries = vec![SyntheticRsliEntry { - name: "QUIRK".to_string(), - method_raw: 0x100, - plain, - declared_packed_size: Some(declared), - }]; - let bytes = build_rsli_bytes(&entries, &RsliBuildOptions::default()); - let path = write_temp_file("rsli-deflate-quirk", &bytes); - - let lib_ok = Library::open_path_with( - &path, - OpenOptions { - allow_ao_trailer: true, - allow_deflate_eof_plus_one: true, - }, - ) - .expect("open with EOF+1 quirk enabled failed"); - let loaded = lib_ok - .load(lib_ok.find("QUIRK").expect("find quirk entry failed")) - .expect("load quirk entry failed"); - assert_eq!(loaded, b"quirk deflate payload"); - - match Library::open_path_with( - &path, - OpenOptions { - allow_ao_trailer: true, - allow_deflate_eof_plus_one: false, - }, - ) { - Err(Error::DeflateEofPlusOneQuirkRejected { id }) => assert_eq!(id, 0), - other => panic!("expected DeflateEofPlusOneQuirkRejected, got {other:?}"), - } - - let _ = fs::remove_file(&path); -} - -#[test] -fn rsli_validation_error_cases() { - let valid = build_rsli_bytes( - &[SyntheticRsliEntry { - name: "BASE".to_string(), - method_raw: 0x000, - plain: b"abc".to_vec(), - declared_packed_size: None, - }], - &RsliBuildOptions::default(), - ); - - let mut bad_magic = valid.clone(); - bad_magic[0..2].copy_from_slice(b"XX"); - let path = write_temp_file("rsli-bad-magic", &bad_magic); - match Library::open_path(&path) { - Err(Error::InvalidMagic { .. }) => {} - other => panic!("expected InvalidMagic, got {other:?}"), - } - let _ = fs::remove_file(&path); - - let mut bad_version = valid.clone(); - bad_version[3] = 2; - let path = write_temp_file("rsli-bad-version", &bad_version); - match Library::open_path(&path) { - Err(Error::UnsupportedVersion { got }) => assert_eq!(got, 2), - other => panic!("expected UnsupportedVersion, got {other:?}"), - } - let _ = fs::remove_file(&path); - - let mut bad_count = valid.clone(); - bad_count[4..6].copy_from_slice(&(-1_i16).to_le_bytes()); - let path = write_temp_file("rsli-bad-count", &bad_count); - match Library::open_path(&path) { - Err(Error::InvalidEntryCount { got }) => assert_eq!(got, -1), - other => panic!("expected InvalidEntryCount, got {other:?}"), - } - let _ = fs::remove_file(&path); - - let mut bad_table = valid.clone(); - bad_table[4..6].copy_from_slice(&100_i16.to_le_bytes()); - let path = write_temp_file("rsli-bad-table", &bad_table); - match Library::open_path(&path) { - Err(Error::EntryTableOutOfBounds { .. }) => {} - other => panic!("expected EntryTableOutOfBounds, got {other:?}"), - } - let _ = fs::remove_file(&path); - - let mut unknown_method = build_rsli_bytes( - &[SyntheticRsliEntry { - name: "UNK".to_string(), - method_raw: 0x120, - plain: b"x".to_vec(), - declared_packed_size: None, - }], - &RsliBuildOptions::default(), - ); - // Force truly unknown method by writing 0x1C0 mask bits. - let row = 32; - unknown_method[row + 16..row + 18].copy_from_slice(&(0x1C0_u16).to_le_bytes()); - // Re-encrypt table with the same seed. - let seed = u32::from_le_bytes([ - unknown_method[20], - unknown_method[21], - unknown_method[22], - unknown_method[23], - ]); - let mut plain_row = vec![0u8; 32]; - plain_row.copy_from_slice(&unknown_method[32..64]); - plain_row = xor_stream(&plain_row, (seed & 0xFFFF) as u16); - plain_row[16..18].copy_from_slice(&(0x1C0_u16).to_le_bytes()); - let encrypted_row = xor_stream(&plain_row, (seed & 0xFFFF) as u16); - unknown_method[32..64].copy_from_slice(&encrypted_row); - - let path = write_temp_file("rsli-unknown-method", &unknown_method); - let lib = Library::open_path(&path).expect("open archive with unknown method failed"); - match lib.load(EntryId(0)) { - Err(Error::UnsupportedMethod { raw }) => assert_eq!(raw, 0x1C0), - other => panic!("expected UnsupportedMethod, got {other:?}"), - } - let _ = fs::remove_file(&path); - - let mut bad_packed = valid.clone(); - bad_packed[32 + 28..32 + 32].copy_from_slice(&0xFFFF_FFF0_u32.to_le_bytes()); - let path = write_temp_file("rsli-bad-packed", &bad_packed); - match Library::open_path(&path) { - Err(Error::PackedSizePastEof { .. }) => {} - other => panic!("expected PackedSizePastEof, got {other:?}"), - } - let _ = fs::remove_file(&path); - - let mut with_bad_overlay = valid; - with_bad_overlay.extend_from_slice(b"AO"); - with_bad_overlay.extend_from_slice(&0xFFFF_FFFF_u32.to_le_bytes()); - let path = write_temp_file("rsli-bad-overlay", &with_bad_overlay); - match Library::open_path_with( - &path, - OpenOptions { - allow_ao_trailer: true, - allow_deflate_eof_plus_one: true, - }, - ) { - Err(Error::MediaOverlayOutOfBounds { .. }) => {} - other => panic!("expected MediaOverlayOutOfBounds, got {other:?}"), - } - let _ = fs::remove_file(&path); -} - -proptest! { - #![proptest_config(ProptestConfig::with_cases(64))] - - #[test] - fn parse_library_is_panic_free_on_random_bytes(data in proptest::collection::vec(any::(), 0..4096)) { - let _ = crate::parse::parse_library( - Arc::from(data.into_boxed_slice()), - OpenOptions::default(), - ); - } -} diff --git a/crates/terrain-core/Cargo.toml b/crates/terrain-core/Cargo.toml deleted file mode 100644 index fd4380f..0000000 --- a/crates/terrain-core/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "terrain-core" -version = "0.1.0" -edition = "2021" - -[dependencies] -nres = { path = "../nres" } - -[dev-dependencies] -common = { path = "../common" } diff --git a/crates/terrain-core/src/lib.rs b/crates/terrain-core/src/lib.rs deleted file mode 100644 index 36a3e42..0000000 --- a/crates/terrain-core/src/lib.rs +++ /dev/null @@ -1,281 +0,0 @@ -use nres::Archive; -use std::fmt; -use std::path::Path; - -pub const TERRAIN_UV_SCALE: f32 = 1024.0; - -pub type Result = core::result::Result; - -#[derive(Debug)] -pub enum Error { - Nres(nres::error::Error), - MissingChunk(&'static str), - InvalidChunkSize { - label: &'static str, - size: usize, - stride: usize, - }, - VertexCountOverflow { - count: usize, - }, -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Nres(err) => write!(f, "{err}"), - Self::MissingChunk(label) => write!(f, "missing required terrain chunk: {label}"), - Self::InvalidChunkSize { - label, - size, - stride, - } => write!( - f, - "invalid chunk size for {label}: {size} (must be divisible by {stride})" - ), - Self::VertexCountOverflow { count } => { - write!(f, "terrain vertex count {count} exceeds u16 range") - } - } - } -} - -impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Nres(err) => Some(err), - _ => None, - } - } -} - -impl From for Error { - fn from(value: nres::error::Error) -> Self { - Self::Nres(value) - } -} - -#[derive(Clone, Debug)] -pub struct TerrainMesh { - pub positions: Vec<[f32; 3]>, - pub uv0: Vec<[f32; 2]>, - pub faces: Vec, -} - -#[derive(Copy, Clone, Debug)] -pub struct TerrainFace { - pub indices: [u16; 3], - pub flags: u32, - pub material_tag: u16, - pub aux_tag: u16, -} - -#[derive(Clone, Debug)] -pub struct TerrainRenderMesh { - pub vertices: Vec, - pub indices: Vec, - pub face_count_raw: usize, - pub face_count_kept: usize, - pub face_count_dropped_invalid: usize, -} - -#[derive(Copy, Clone, Debug)] -pub struct TerrainRenderVertex { - pub position: [f32; 3], - pub uv0: [f32; 2], -} - -pub fn load_land_mesh(path: impl AsRef) -> Result { - let archive = Archive::open_path(path.as_ref())?; - - let positions_entry = archive - .entries() - .find(|entry| entry.meta.kind == 3) - .ok_or(Error::MissingChunk("type=3 (positions)"))?; - let uv_entry = archive.entries().find(|entry| entry.meta.kind == 5); - let faces_entry = archive - .entries() - .find(|entry| entry.meta.kind == 21) - .ok_or(Error::MissingChunk("type=21 (faces)"))?; - - let positions_payload = archive.read(positions_entry.id)?.into_owned(); - if positions_payload.len() % 12 != 0 { - return Err(Error::InvalidChunkSize { - label: "type=3 (positions)", - size: positions_payload.len(), - stride: 12, - }); - } - - let mut positions = Vec::with_capacity(positions_payload.len() / 12); - for chunk in positions_payload.chunks_exact(12) { - let x = f32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4])); - let y = f32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0; 4])); - let z = f32::from_le_bytes(chunk[8..12].try_into().unwrap_or([0; 4])); - positions.push([x, y, z]); - } - - let mut uv0 = vec![[0.0f32, 0.0f32]; positions.len()]; - if let Some(uv_entry) = uv_entry { - let uv_payload = archive.read(uv_entry.id)?.into_owned(); - if uv_payload.len() % 4 != 0 { - return Err(Error::InvalidChunkSize { - label: "type=5 (uv)", - size: uv_payload.len(), - stride: 4, - }); - } - let uv_count = uv_payload.len() / 4; - for idx in 0..uv_count.min(uv0.len()) { - let off = idx * 4; - let u = i16::from_le_bytes([uv_payload[off], uv_payload[off + 1]]) as f32; - let v = i16::from_le_bytes([uv_payload[off + 2], uv_payload[off + 3]]) as f32; - uv0[idx] = [u / TERRAIN_UV_SCALE, v / TERRAIN_UV_SCALE]; - } - } - - let face_payload = archive.read(faces_entry.id)?.into_owned(); - if face_payload.len() % 28 != 0 { - return Err(Error::InvalidChunkSize { - label: "type=21 (faces)", - size: face_payload.len(), - stride: 28, - }); - } - - let mut faces = Vec::with_capacity(face_payload.len() / 28); - for chunk in face_payload.chunks_exact(28) { - let flags = u32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4])); - let material_tag = u16::from_le_bytes(chunk[4..6].try_into().unwrap_or([0; 2])); - let aux_tag = u16::from_le_bytes(chunk[6..8].try_into().unwrap_or([0; 2])); - let i0 = u16::from_le_bytes(chunk[8..10].try_into().unwrap_or([0; 2])); - let i1 = u16::from_le_bytes(chunk[10..12].try_into().unwrap_or([0; 2])); - let i2 = u16::from_le_bytes(chunk[12..14].try_into().unwrap_or([0; 2])); - if usize::from(i0) >= positions.len() - || usize::from(i1) >= positions.len() - || usize::from(i2) >= positions.len() - { - continue; - } - faces.push(TerrainFace { - indices: [i0, i1, i2], - flags, - material_tag, - aux_tag, - }); - } - - Ok(TerrainMesh { - positions, - uv0, - faces, - }) -} - -pub fn build_render_mesh(mesh: &TerrainMesh) -> Result { - if mesh.positions.len() > usize::from(u16::MAX) + 1 { - return Err(Error::VertexCountOverflow { - count: mesh.positions.len(), - }); - } - - let vertices = mesh - .positions - .iter() - .enumerate() - .map(|(idx, &position)| TerrainRenderVertex { - position, - uv0: mesh.uv0.get(idx).copied().unwrap_or([0.0, 0.0]), - }) - .collect::>(); - - let mut indices = Vec::with_capacity(mesh.faces.len() * 3); - for face in &mesh.faces { - indices.extend_from_slice(&face.indices); - } - - Ok(TerrainRenderMesh { - vertices, - indices, - face_count_raw: mesh.faces.len(), - face_count_kept: mesh.faces.len(), - face_count_dropped_invalid: 0, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use common::collect_files_recursive; - use std::path::{Path, PathBuf}; - - fn game_root() -> Option { - let root = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("testdata") - .join("Parkan - Iron Strategy"); - root.is_dir().then_some(root) - } - - #[test] - fn loads_known_land_mesh() { - let Some(root) = game_root() else { - eprintln!("skipping: game root missing"); - return; - }; - - let land = root - .join("DATA") - .join("MAPS") - .join("Tut_1") - .join("Land.msh"); - if !land.is_file() { - eprintln!("skipping missing sample {}", land.display()); - return; - } - - let mesh = load_land_mesh(&land) - .unwrap_or_else(|err| panic!("failed to parse {}: {err}", land.display())); - assert!(mesh.positions.len() > 1000); - assert!(mesh.faces.len() > 1000); - - let render = build_render_mesh(&mesh).expect("failed to build render mesh"); - assert_eq!(render.vertices.len(), mesh.positions.len()); - assert_eq!(render.indices.len(), mesh.faces.len() * 3); - } - - #[test] - fn loads_all_retail_land_meshes() { - let Some(root) = game_root() else { - eprintln!("skipping: game root missing"); - return; - }; - - let maps_root = root.join("DATA").join("MAPS"); - let mut files = Vec::new(); - collect_files_recursive(&maps_root, &mut files); - files.sort(); - - let mut parsed = 0usize; - for path in files { - if !path - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.eq_ignore_ascii_case("Land.msh")) - { - continue; - } - let mesh = load_land_mesh(&path) - .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display())); - assert!( - !mesh.positions.is_empty() && !mesh.faces.is_empty(), - "{} parsed but empty", - path.display() - ); - parsed += 1; - } - - assert!(parsed > 0, "no Land.msh files parsed"); - } -} diff --git a/crates/texm/Cargo.toml b/crates/texm/Cargo.toml deleted file mode 100644 index f9c49b6..0000000 --- a/crates/texm/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "texm" -version = "0.1.0" -edition = "2021" - -[dev-dependencies] -common = { path = "../common" } -nres = { path = "../nres" } -proptest = "1" diff --git a/crates/texm/README.md b/crates/texm/README.md deleted file mode 100644 index 370ac54..0000000 --- a/crates/texm/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# texm - -Парсер формата текстур `Texm`. - -Покрывает: - -- header (`width/height/mipCount/flags/format`); -- core size расчёт; -- optional `Page` chunk; -- строгую валидацию layout. - -Тесты: - -- прогон по реальным `Texm` из `testdata`; -- синтетические edge-cases (indexed + page, minimal rgba). diff --git a/crates/texm/src/error.rs b/crates/texm/src/error.rs deleted file mode 100644 index 90d618d..0000000 --- a/crates/texm/src/error.rs +++ /dev/null @@ -1,86 +0,0 @@ -use core::fmt; - -#[derive(Debug)] -#[non_exhaustive] -pub enum Error { - HeaderTooSmall { - size: usize, - }, - InvalidMagic { - got: u32, - }, - InvalidDimensions { - width: u32, - height: u32, - }, - InvalidMipCount { - mip_count: u32, - }, - UnknownFormat { - format: u32, - }, - IntegerOverflow, - CoreDataOutOfBounds { - expected_end: usize, - actual_size: usize, - }, - MipIndexOutOfRange { - requested: usize, - mip_count: usize, - }, - MipDataOutOfBounds { - offset: usize, - size: usize, - payload_size: usize, - }, - InvalidPageMagic, - InvalidPageSize { - expected: usize, - actual: usize, - }, -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::HeaderTooSmall { size } => { - write!(f, "Texm payload too small for header: {size}") - } - Self::InvalidMagic { got } => write!(f, "invalid Texm magic: 0x{got:08X}"), - Self::InvalidDimensions { width, height } => { - write!(f, "invalid Texm dimensions: {width}x{height}") - } - Self::InvalidMipCount { mip_count } => write!(f, "invalid Texm mip_count={mip_count}"), - Self::UnknownFormat { format } => write!(f, "unknown Texm format={format}"), - Self::IntegerOverflow => write!(f, "integer overflow"), - Self::CoreDataOutOfBounds { - expected_end, - actual_size, - } => write!( - f, - "Texm core data out of bounds: expected_end={expected_end}, actual_size={actual_size}" - ), - Self::MipIndexOutOfRange { - requested, - mip_count, - } => write!( - f, - "Texm mip index out of range: requested={requested}, mip_count={mip_count}" - ), - Self::MipDataOutOfBounds { - offset, - size, - payload_size, - } => write!( - f, - "Texm mip data out of bounds: offset={offset}, size={size}, payload_size={payload_size}" - ), - Self::InvalidPageMagic => write!(f, "Texm tail exists but Page magic is missing"), - Self::InvalidPageSize { expected, actual } => { - write!(f, "invalid Page chunk size: expected={expected}, actual={actual}") - } - } - } -} - -impl std::error::Error for Error {} diff --git a/crates/texm/src/lib.rs b/crates/texm/src/lib.rs deleted file mode 100644 index 7a166f3..0000000 --- a/crates/texm/src/lib.rs +++ /dev/null @@ -1,417 +0,0 @@ -pub mod error; - -use crate::error::Error; - -pub type Result = core::result::Result; - -pub const TEXM_MAGIC: u32 = 0x6D78_6554; -pub const PAGE_MAGIC: u32 = 0x6567_6150; - -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub enum PixelFormat { - Indexed8, - Rgb565, - Rgb556, - Argb4444, - LuminanceAlpha88, - Rgb888, - Argb8888, -} - -impl PixelFormat { - pub fn from_raw(raw: u32) -> Option { - match raw { - 0 => Some(Self::Indexed8), - 565 => Some(Self::Rgb565), - 556 => Some(Self::Rgb556), - 4444 => Some(Self::Argb4444), - 88 => Some(Self::LuminanceAlpha88), - 888 => Some(Self::Rgb888), - 8888 => Some(Self::Argb8888), - _ => None, - } - } - - pub fn bytes_per_pixel(self) -> usize { - match self { - Self::Indexed8 => 1, - Self::Rgb565 | Self::Rgb556 | Self::Argb4444 | Self::LuminanceAlpha88 => 2, - // Parkan stores format 888 as 32-bit RGBX in texture payloads. - Self::Rgb888 | Self::Argb8888 => 4, - } - } -} - -#[derive(Clone, Debug)] -pub struct Header { - pub width: u32, - pub height: u32, - pub mip_count: u32, - pub flags4: u32, - pub flags5: u32, - pub unk6: u32, - pub format_raw: u32, - pub format: PixelFormat, -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub struct MipLevel { - pub width: u32, - pub height: u32, - pub offset: usize, - pub size: usize, -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub struct PageRect { - pub x: i16, - pub w: i16, - pub y: i16, - pub h: i16, -} - -#[derive(Clone, Debug)] -pub struct Texture { - pub header: Header, - pub palette: Option<[u8; 1024]>, - pub mip_levels: Vec, - pub page_rects: Vec, -} - -impl Texture { - pub fn core_size(&self) -> usize { - let mut size = 32usize; - if self.palette.is_some() { - size += 1024; - } - for level in &self.mip_levels { - size += level.size; - } - size - } -} - -#[derive(Clone, Debug)] -pub struct DecodedMip { - pub width: u32, - pub height: u32, - pub rgba8: Vec, -} - -pub fn parse_texm(payload: &[u8]) -> Result { - if payload.len() < 32 { - return Err(Error::HeaderTooSmall { - size: payload.len(), - }); - } - - let magic = read_u32(payload, 0)?; - if magic != TEXM_MAGIC { - return Err(Error::InvalidMagic { got: magic }); - } - - let width = read_u32(payload, 4)?; - let height = read_u32(payload, 8)?; - let mip_count = read_u32(payload, 12)?; - let flags4 = read_u32(payload, 16)?; - let flags5 = read_u32(payload, 20)?; - let unk6 = read_u32(payload, 24)?; - let format_raw = read_u32(payload, 28)?; - - if width == 0 || height == 0 { - return Err(Error::InvalidDimensions { width, height }); - } - if mip_count == 0 { - return Err(Error::InvalidMipCount { mip_count }); - } - - let format = - PixelFormat::from_raw(format_raw).ok_or(Error::UnknownFormat { format: format_raw })?; - let bytes_per_pixel = format.bytes_per_pixel(); - - let mut offset = 32usize; - let palette = if format == PixelFormat::Indexed8 { - let end = offset.checked_add(1024).ok_or(Error::IntegerOverflow)?; - if end > payload.len() { - return Err(Error::CoreDataOutOfBounds { - expected_end: end, - actual_size: payload.len(), - }); - } - let mut pal = [0u8; 1024]; - pal.copy_from_slice(&payload[offset..end]); - offset = end; - Some(pal) - } else { - None - }; - - let mut mip_levels = - Vec::with_capacity(usize::try_from(mip_count).map_err(|_| Error::IntegerOverflow)?); - let mut w = width; - let mut h = height; - for _ in 0..mip_count { - let pixel_count_u64 = u64::from(w) - .checked_mul(u64::from(h)) - .ok_or(Error::IntegerOverflow)?; - let level_size_u64 = pixel_count_u64 - .checked_mul(u64::try_from(bytes_per_pixel).map_err(|_| Error::IntegerOverflow)?) - .ok_or(Error::IntegerOverflow)?; - let level_size = usize::try_from(level_size_u64).map_err(|_| Error::IntegerOverflow)?; - let level_offset = offset; - offset = offset - .checked_add(level_size) - .ok_or(Error::IntegerOverflow)?; - if offset > payload.len() { - return Err(Error::CoreDataOutOfBounds { - expected_end: offset, - actual_size: payload.len(), - }); - } - mip_levels.push(MipLevel { - width: w, - height: h, - offset: level_offset, - size: level_size, - }); - w = (w >> 1).max(1); - h = (h >> 1).max(1); - } - - let page_rects = parse_page_tail(payload, offset)?; - - Ok(Texture { - header: Header { - width, - height, - mip_count, - flags4, - flags5, - unk6, - format_raw, - format, - }, - palette, - mip_levels, - page_rects, - }) -} - -pub fn decode_mip_rgba8(texture: &Texture, payload: &[u8], mip_index: usize) -> Result { - let Some(level) = texture.mip_levels.get(mip_index).copied() else { - return Err(Error::MipIndexOutOfRange { - requested: mip_index, - mip_count: texture.mip_levels.len(), - }); - }; - - let end = level - .offset - .checked_add(level.size) - .ok_or(Error::IntegerOverflow)?; - let Some(level_data) = payload.get(level.offset..end) else { - return Err(Error::MipDataOutOfBounds { - offset: level.offset, - size: level.size, - payload_size: payload.len(), - }); - }; - - let pixel_count = usize::try_from(level.width) - .ok() - .and_then(|w| { - usize::try_from(level.height) - .ok() - .map(|h| w.saturating_mul(h)) - }) - .ok_or(Error::IntegerOverflow)?; - let mut rgba = vec![0u8; pixel_count.saturating_mul(4)]; - - match texture.header.format { - PixelFormat::Indexed8 => { - let palette = texture.palette.as_ref().ok_or(Error::IntegerOverflow)?; - for (i, &index) in level_data.iter().enumerate() { - if i >= pixel_count { - break; - } - let poff = usize::from(index).saturating_mul(4); - // Keep this form to accept the last palette item (index 255). - if poff + 4 > palette.len() { - continue; - } - let out = i.saturating_mul(4); - rgba[out] = palette[poff]; - rgba[out + 1] = palette[poff + 1]; - rgba[out + 2] = palette[poff + 2]; - rgba[out + 3] = palette[poff + 3]; - } - } - PixelFormat::Rgb565 => { - decode_words(level_data, pixel_count, &mut rgba, decode_rgb565); - } - PixelFormat::Rgb556 => { - decode_words(level_data, pixel_count, &mut rgba, decode_rgb556); - } - PixelFormat::Argb4444 => { - decode_words(level_data, pixel_count, &mut rgba, decode_argb4444); - } - PixelFormat::LuminanceAlpha88 => { - decode_words(level_data, pixel_count, &mut rgba, decode_luminance_alpha88); - } - PixelFormat::Rgb888 => { - decode_dwords(level_data, pixel_count, &mut rgba, decode_rgb888x); - } - PixelFormat::Argb8888 => { - decode_dwords(level_data, pixel_count, &mut rgba, decode_argb8888); - } - } - - Ok(DecodedMip { - width: level.width, - height: level.height, - rgba8: rgba, - }) -} - -fn parse_page_tail(payload: &[u8], core_end: usize) -> Result> { - if core_end == payload.len() { - return Ok(Vec::new()); - } - if payload.len().saturating_sub(core_end) < 8 { - return Err(Error::InvalidPageSize { - expected: 8, - actual: payload.len().saturating_sub(core_end), - }); - } - let magic = read_u32(payload, core_end)?; - if magic != PAGE_MAGIC { - return Err(Error::InvalidPageMagic); - } - let rect_count = read_u32(payload, core_end + 4)?; - let rect_count_usize = usize::try_from(rect_count).map_err(|_| Error::IntegerOverflow)?; - let expected_size = 8usize - .checked_add( - rect_count_usize - .checked_mul(8) - .ok_or(Error::IntegerOverflow)?, - ) - .ok_or(Error::IntegerOverflow)?; - let actual = payload.len().saturating_sub(core_end); - if expected_size != actual { - return Err(Error::InvalidPageSize { - expected: expected_size, - actual, - }); - } - - let mut rects = Vec::with_capacity(rect_count_usize); - for i in 0..rect_count_usize { - let off = core_end - .checked_add(8) - .and_then(|v| v.checked_add(i * 8)) - .ok_or(Error::IntegerOverflow)?; - rects.push(PageRect { - x: read_i16(payload, off)?, - w: read_i16(payload, off + 2)?, - y: read_i16(payload, off + 4)?, - h: read_i16(payload, off + 6)?, - }); - } - Ok(rects) -} - -fn read_u32(data: &[u8], offset: usize) -> Result { - let bytes = data.get(offset..offset + 4).ok_or(Error::IntegerOverflow)?; - let arr: [u8; 4] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?; - Ok(u32::from_le_bytes(arr)) -} - -fn read_i16(data: &[u8], offset: usize) -> Result { - let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?; - let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?; - Ok(i16::from_le_bytes(arr)) -} - -fn decode_words(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u16) -> [u8; 4]) { - for i in 0..pixel_count { - let off = i.saturating_mul(2); - let Some(bytes) = data.get(off..off + 2) else { - break; - }; - let word = u16::from_le_bytes([bytes[0], bytes[1]]); - let px = decode(word); - let out = i.saturating_mul(4); - rgba[out..out + 4].copy_from_slice(&px); - } -} - -fn decode_dwords(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u32) -> [u8; 4]) { - for i in 0..pixel_count { - let off = i.saturating_mul(4); - let Some(bytes) = data.get(off..off + 4) else { - break; - }; - let dword = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); - let px = decode(dword); - let out = i.saturating_mul(4); - rgba[out..out + 4].copy_from_slice(&px); - } -} - -fn expand5(v: u16) -> u8 { - ((u32::from(v) * 255 + 15) / 31) as u8 -} - -fn expand6(v: u16) -> u8 { - ((u32::from(v) * 255 + 31) / 63) as u8 -} - -fn expand4(v: u16) -> u8 { - (u32::from(v) * 17) as u8 -} - -fn decode_rgb565(word: u16) -> [u8; 4] { - let r = expand5((word >> 11) & 0x1F); - let g = expand6((word >> 5) & 0x3F); - let b = expand5(word & 0x1F); - [r, g, b, 255] -} - -fn decode_rgb556(word: u16) -> [u8; 4] { - let r = expand5((word >> 11) & 0x1F); - let g = expand5((word >> 6) & 0x1F); - let b = expand6(word & 0x3F); - [r, g, b, 255] -} - -fn decode_argb4444(word: u16) -> [u8; 4] { - let a = expand4((word >> 12) & 0x0F); - let r = expand4((word >> 8) & 0x0F); - let g = expand4((word >> 4) & 0x0F); - let b = expand4(word & 0x0F); - [r, g, b, a] -} - -fn decode_luminance_alpha88(word: u16) -> [u8; 4] { - let l = ((word >> 8) & 0xFF) as u8; - let a = (word & 0xFF) as u8; - [l, l, l, a] -} - -fn decode_rgb888x(dword: u32) -> [u8; 4] { - let r = (dword & 0xFF) as u8; - let g = ((dword >> 8) & 0xFF) as u8; - let b = ((dword >> 16) & 0xFF) as u8; - [r, g, b, 255] -} - -fn decode_argb8888(dword: u32) -> [u8; 4] { - let a = (dword & 0xFF) as u8; - let r = ((dword >> 8) & 0xFF) as u8; - let g = ((dword >> 16) & 0xFF) as u8; - let b = ((dword >> 24) & 0xFF) as u8; - [r, g, b, a] -} - -#[cfg(test)] -mod tests; diff --git a/crates/texm/src/tests.rs b/crates/texm/src/tests.rs deleted file mode 100644 index 49a7100..0000000 --- a/crates/texm/src/tests.rs +++ /dev/null @@ -1,330 +0,0 @@ -use super::*; -use common::collect_files_recursive; -use nres::Archive; -use proptest::prelude::*; -use std::fs; -use std::path::{Path, PathBuf}; - -fn nres_test_files() -> Vec { - let root = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("testdata"); - let mut files = Vec::new(); - collect_files_recursive(&root, &mut files); - files.sort(); - files - .into_iter() - .filter(|path| { - fs::read(path) - .map(|bytes| bytes.get(0..4) == Some(b"NRes")) - .unwrap_or(false) - }) - .collect() -} - -fn build_texm_payload( - width: u32, - height: u32, - format_raw: u32, - flags5: u32, - palette: Option<[u8; 1024]>, - mip_levels: &[&[u8]], -) -> Vec { - let mut payload = Vec::new(); - payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes()); - payload.extend_from_slice(&width.to_le_bytes()); - payload.extend_from_slice(&height.to_le_bytes()); - payload.extend_from_slice( - &u32::try_from(mip_levels.len()) - .expect("mip level count overflow in test") - .to_le_bytes(), - ); - payload.extend_from_slice(&0u32.to_le_bytes()); // flags4 - payload.extend_from_slice(&flags5.to_le_bytes()); - payload.extend_from_slice(&0u32.to_le_bytes()); // unk6 - payload.extend_from_slice(&format_raw.to_le_bytes()); - if let Some(palette) = palette { - payload.extend_from_slice(&palette); - } - for level in mip_levels { - payload.extend_from_slice(level); - } - payload -} - -#[test] -fn texm_parse_all_game_textures() { - let archives = nres_test_files(); - if archives.is_empty() { - eprintln!("skipping texm_parse_all_game_textures: no NRes files in testdata"); - return; - } - - let mut texm_total = 0usize; - let mut texm_with_page = 0usize; - for archive_path in archives { - let archive = Archive::open_path(&archive_path) - .unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display())); - - for entry in archive.entries() { - if entry.meta.kind != TEXM_MAGIC { - continue; - } - texm_total += 1; - let payload = archive.read(entry.id).unwrap_or_else(|err| { - panic!( - "failed to read Texm entry '{}' in {}: {err}", - entry.meta.name, - archive_path.display() - ) - }); - let texture = parse_texm(payload.as_slice()).unwrap_or_else(|err| { - panic!( - "failed to parse Texm '{}' in {}: {err}", - entry.meta.name, - archive_path.display() - ) - }); - if !texture.page_rects.is_empty() { - texm_with_page += 1; - } - - assert!( - texture.core_size() <= payload.as_slice().len(), - "core size must be within payload for '{}' in {}", - entry.meta.name, - archive_path.display() - ); - assert_eq!( - usize::try_from(texture.header.mip_count).ok(), - Some(texture.mip_levels.len()), - "mip count mismatch for '{}' in {}", - entry.meta.name, - archive_path.display() - ); - } - } - - assert!(texm_total > 0, "no Texm textures found"); - assert!( - texm_with_page > 0, - "expected at least one Texm texture with Page chunk" - ); -} - -#[test] -fn texm_parse_minimal_argb8888_no_page() { - let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[1, 2, 3, 4]]); - - let parsed = parse_texm(&payload).expect("failed to parse minimal texm"); - assert_eq!(parsed.header.width, 1); - assert_eq!(parsed.header.height, 1); - assert_eq!(parsed.mip_levels.len(), 1); - assert!(parsed.page_rects.is_empty()); -} - -#[test] -fn texm_decode_minimal_argb8888_no_page() { - let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[0x40, 0x11, 0x22, 0x33]]); - let parsed = parse_texm(&payload).expect("failed to parse minimal texm"); - let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode mip"); - assert_eq!(decoded.width, 1); - assert_eq!(decoded.height, 1); - assert_eq!(decoded.rgba8, vec![0x11, 0x22, 0x33, 0x40]); -} - -#[test] -fn texm_decode_rgb565() { - let word = 0xFFE0u16; // r=31 g=63 b=0 - let payload = build_texm_payload(1, 1, 565, 0, None, &[&word.to_le_bytes()]); - let parsed = parse_texm(&payload).expect("failed to parse rgb565 texm"); - let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb565 texm"); - assert_eq!(decoded.rgba8, vec![255, 255, 0, 255]); -} - -#[test] -fn texm_decode_rgb556() { - let word = 0xF800u16; // r=31 g=0 b=0 - let payload = build_texm_payload(1, 1, 556, 0, None, &[&word.to_le_bytes()]); - let parsed = parse_texm(&payload).expect("failed to parse rgb556 texm"); - let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb556 texm"); - assert_eq!(decoded.rgba8, vec![255, 0, 0, 255]); -} - -#[test] -fn texm_decode_argb4444() { - let word = 0xF12Eu16; // a=F r=1 g=2 b=E - let payload = build_texm_payload(1, 1, 4444, 0, None, &[&word.to_le_bytes()]); - let parsed = parse_texm(&payload).expect("failed to parse argb4444 texm"); - let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode argb4444 texm"); - assert_eq!(decoded.rgba8, vec![17, 34, 238, 255]); -} - -#[test] -fn texm_decode_luminance_alpha88() { - let word = 0x7F40u16; // luminance=0x7F alpha=0x40 - let payload = build_texm_payload(1, 1, 88, 0, None, &[&word.to_le_bytes()]); - let parsed = parse_texm(&payload).expect("failed to parse la88 texm"); - let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode la88 texm"); - assert_eq!(decoded.rgba8, vec![0x7F, 0x7F, 0x7F, 0x40]); -} - -#[test] -fn texm_decode_rgb888x() { - let payload = build_texm_payload(1, 1, 888, 0, None, &[&[0x11, 0x22, 0x33, 0x99]]); - let parsed = parse_texm(&payload).expect("failed to parse rgb888 texm"); - let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb888 texm"); - assert_eq!(decoded.rgba8, vec![0x11, 0x22, 0x33, 255]); -} - -#[test] -fn texm_parse_indexed_with_page_chunk() { - let mut palette = [0u8; 1024]; - palette[4..8].copy_from_slice(&[10, 20, 30, 255]); - let mut payload = build_texm_payload(2, 2, 0, 0, Some(palette), &[&[1, 1, 1, 1]]); - payload.extend_from_slice(&PAGE_MAGIC.to_le_bytes()); - payload.extend_from_slice(&1u32.to_le_bytes()); // rect_count - payload.extend_from_slice(&0i16.to_le_bytes()); // x - payload.extend_from_slice(&2i16.to_le_bytes()); // w - payload.extend_from_slice(&0i16.to_le_bytes()); // y - payload.extend_from_slice(&2i16.to_le_bytes()); // h - - let parsed = parse_texm(&payload).expect("failed to parse indexed texm"); - assert!(parsed.palette.is_some()); - assert_eq!(parsed.page_rects.len(), 1); - assert_eq!( - parsed.page_rects[0], - PageRect { - x: 0, - w: 2, - y: 0, - h: 2 - } - ); -} - -#[test] -fn texm_decode_indexed_with_palette_last_entry() { - let mut palette = [0u8; 1024]; - palette[4..8].copy_from_slice(&[10, 20, 30, 255]); // index 1 - palette[8..12].copy_from_slice(&[40, 50, 60, 200]); // index 2 - palette[1020..1024].copy_from_slice(&[1, 2, 3, 4]); // index 255 (last) - let payload = build_texm_payload(3, 1, 0, 0, Some(palette), &[&[1u8, 2u8, 255u8]]); - - let parsed = parse_texm(&payload).expect("failed to parse indexed texm"); - let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode indexed texm"); - assert_eq!(decoded.width, 3); - assert_eq!(decoded.height, 1); - assert_eq!( - decoded.rgba8, - vec![10, 20, 30, 255, 40, 50, 60, 200, 1, 2, 3, 4] - ); -} - -#[test] -fn texm_parse_multi_mip_offsets() { - let mip0 = [0x10u8; 32]; // 4*2*4 - let mip1 = [0x20u8; 8]; // 2*1*4 - let mip2 = [0x30u8; 4]; // 1*1*4 - let payload = build_texm_payload(4, 2, 8888, 0, None, &[&mip0, &mip1, &mip2]); - - let parsed = parse_texm(&payload).expect("failed to parse multi-mip texm"); - assert_eq!(parsed.header.mip_count, 3); - assert_eq!(parsed.mip_levels.len(), 3); - assert_eq!( - parsed.mip_levels, - vec![ - MipLevel { - width: 4, - height: 2, - offset: 32, - size: 32 - }, - MipLevel { - width: 2, - height: 1, - offset: 64, - size: 8 - }, - MipLevel { - width: 1, - height: 1, - offset: 72, - size: 4 - }, - ] - ); -} - -#[test] -fn texm_preserves_flags5_for_mip_skip_metadata() { - let payload = build_texm_payload(1, 1, 8888, 0x0000_00A5, None, &[&[0, 0, 0, 0]]); - let parsed = parse_texm(&payload).expect("failed to parse texm"); - assert_eq!(parsed.header.flags5, 0x0000_00A5); -} - -#[test] -fn texm_errors_for_invalid_header_values() { - let mut bad_magic = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]); - bad_magic[0..4].copy_from_slice(&0u32.to_le_bytes()); - assert!(matches!( - parse_texm(&bad_magic), - Err(Error::InvalidMagic { .. }) - )); - - let zero_dims = build_texm_payload(0, 1, 8888, 0, None, &[&[]]); - assert!(matches!( - parse_texm(&zero_dims), - Err(Error::InvalidDimensions { .. }) - )); - - let mut bad_mips = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]); - bad_mips[12..16].copy_from_slice(&0u32.to_le_bytes()); - assert!(matches!( - parse_texm(&bad_mips), - Err(Error::InvalidMipCount { .. }) - )); - - let bad_format = build_texm_payload(1, 1, 12345, 0, None, &[&[0, 0, 0, 0]]); - assert!(matches!( - parse_texm(&bad_format), - Err(Error::UnknownFormat { .. }) - )); -} - -#[test] -fn texm_errors_for_page_chunk_and_mip_bounds() { - let mut bad_page = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]); - bad_page.extend_from_slice(b"X"); - assert!(matches!( - parse_texm(&bad_page), - Err(Error::InvalidPageSize { .. }) - )); - - let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[1, 2, 3, 4]]); - let parsed = parse_texm(&payload).expect("failed to parse valid texm"); - assert!(matches!( - decode_mip_rgba8(&parsed, &payload, 7), - Err(Error::MipIndexOutOfRange { .. }) - )); - - let truncated = &payload[..payload.len() - 1]; - assert!(matches!( - decode_mip_rgba8(&parsed, truncated, 0), - Err(Error::MipDataOutOfBounds { .. }) - )); -} - -proptest! { - #![proptest_config(ProptestConfig::with_cases(64))] - - #[test] - fn parse_texm_is_panic_free_on_random_bytes(payload in proptest::collection::vec(any::(), 0..4096)) { - if let Ok(texture) = parse_texm(&payload) { - for mip_index in 0..texture.mip_levels.len() { - let _ = decode_mip_rgba8(&texture, &payload, mip_index); - } - } - } -} diff --git a/crates/tma/Cargo.toml b/crates/tma/Cargo.toml deleted file mode 100644 index 99360c3..0000000 --- a/crates/tma/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "tma" -version = "0.1.0" -edition = "2021" - -[dependencies] -encoding_rs = "0.8" - -[dev-dependencies] -common = { path = "../common" } diff --git a/crates/tma/src/lib.rs b/crates/tma/src/lib.rs deleted file mode 100644 index 3b41bc4..0000000 --- a/crates/tma/src/lib.rs +++ /dev/null @@ -1,485 +0,0 @@ -use encoding_rs::WINDOWS_1251; -use std::fmt; -use std::fs; -use std::path::Path; - -const OBJECT_RECORD_FLAGS: u32 = 0x8000_0002; -const FOOTER_MAGIC: &[u8; 4] = b"MtPr"; -const MAP_PATH_TOKEN: &[u8; 10] = b"DATA\\MAPS\\"; - -pub type Result = core::result::Result; - -#[derive(Debug)] -pub enum Error { - Io(std::io::Error), - FooterNotFound, - FooterCorrupt(&'static str), -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Io(err) => write!(f, "{err}"), - Self::FooterNotFound => write!(f, "footer magic 'MtPr' not found"), - Self::FooterCorrupt(reason) => write!(f, "corrupt mission footer: {reason}"), - } - } -} - -impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io(err) => Some(err), - _ => None, - } - } -} - -impl From for Error { - fn from(value: std::io::Error) -> Self { - Self::Io(value) - } -} - -#[derive(Clone, Debug)] -pub struct MissionFile { - pub footer: MissionFooter, - pub objects: Vec, -} - -#[derive(Clone, Debug)] -pub struct MissionFooter { - pub map_path: String, - pub title: String, - pub version: u32, -} - -#[derive(Clone, Debug)] -pub struct MissionObject { - pub offset: usize, - pub group_id: u32, - pub flags: u32, - pub resource_name: String, - pub logical_id: i32, - pub clan_id: i32, - pub position: [f32; 3], - pub orientation: [f32; 3], - pub scale: [f32; 3], - pub alias: String, -} - -pub fn parse_path(path: impl AsRef) -> Result { - let bytes = fs::read(path.as_ref())?; - parse_bytes(&bytes) -} - -pub fn parse_bytes(bytes: &[u8]) -> Result { - let footer = parse_footer(bytes)?; - let objects = parse_objects(bytes); - Ok(MissionFile { footer, objects }) -} - -fn parse_footer(bytes: &[u8]) -> Result { - let map_positions = find_all_map_path_positions(bytes); - if map_positions.is_empty() { - return Err(Error::FooterNotFound); - } - - for map_start in map_positions.into_iter().rev() { - if map_start < 4 { - continue; - } - - let map_end = scan_path_end(bytes, map_start); - if map_end <= map_start { - continue; - } - let map_len = map_end - map_start; - let Some(declared_map_len) = read_u32(bytes, map_start - 4).map(|v| v as usize) else { - continue; - }; - if declared_map_len != map_len { - continue; - } - - let Some(zero_pad) = read_u32(bytes, map_end) else { - continue; - }; - if zero_pad != 0 { - continue; - } - - let title_len_off = map_end + 4; - let Some(title_len) = read_u32(bytes, title_len_off).map(|v| v as usize) else { - continue; - }; - if title_len == 0 || title_len > 256 { - continue; - } - let title_start = title_len_off + 4; - let Some(title_end) = title_start.checked_add(title_len) else { - continue; - }; - if title_end > bytes.len() { - continue; - } - - let map_path = decode_cp1251(&bytes[map_start..map_end]); - if !map_path.to_ascii_uppercase().contains("DATA\\MAPS\\") { - continue; - } - let title = decode_title(&bytes[title_start..title_end]); - let version = parse_footer_version(bytes, title_end)?; - - return Ok(MissionFooter { - map_path, - title, - version, - }); - } - - // Fallback for multiplayer/legacy variants where the footer tail differs, - // but map path is still present in clear text near EOF. - let Some(map_start) = bytes - .windows(MAP_PATH_TOKEN.len()) - .rposition(|window| window == MAP_PATH_TOKEN) - else { - return Err(Error::FooterCorrupt("failed to decode map/title envelope")); - }; - let map_end = scan_path_end(bytes, map_start); - if map_end <= map_start { - return Err(Error::FooterCorrupt("failed to decode map/title envelope")); - } - let map_path = decode_cp1251(&bytes[map_start..map_end]); - if !map_path.to_ascii_uppercase().contains("DATA\\MAPS\\") { - return Err(Error::FooterCorrupt("failed to decode map/title envelope")); - } - - let mut title = String::new(); - if let Some(title_len) = read_u32(bytes, map_end + 8).map(|v| v as usize) { - let title_start = map_end + 12; - let title_end = title_start.saturating_add(title_len); - if title_len > 0 && title_len <= 256 && title_end <= bytes.len() { - let raw = &bytes[title_start..title_end]; - if raw.iter().all(|b| b.is_ascii_graphic() || *b == b' ') { - title = decode_title(raw); - } - } - } - - let version = if let Some(magic_off) = bytes - .windows(FOOTER_MAGIC.len()) - .rposition(|window| window == FOOTER_MAGIC) - { - read_u32(bytes, magic_off + 4).unwrap_or(1) - } else { - read_u32(bytes, map_end).unwrap_or(1) - }; - - Ok(MissionFooter { - map_path, - title, - version, - }) -} - -fn parse_footer_version(bytes: &[u8], after_title_off: usize) -> Result { - if after_title_off + 8 <= bytes.len() - && &bytes[after_title_off..after_title_off + 4] == FOOTER_MAGIC - { - let version = read_u32(bytes, after_title_off + 4) - .ok_or(Error::FooterCorrupt("missing version after MtPr"))?; - return Ok(version); - } - - let version = read_u32(bytes, after_title_off) - .ok_or(Error::FooterCorrupt("missing version after title"))?; - Ok(version) -} - -fn find_all_map_path_positions(bytes: &[u8]) -> Vec { - bytes - .windows(MAP_PATH_TOKEN.len()) - .enumerate() - .filter_map(|(idx, window)| (window == MAP_PATH_TOKEN).then_some(idx)) - .collect() -} - -fn scan_path_end(bytes: &[u8], start: usize) -> usize { - let mut off = start; - while off < bytes.len() && is_path_byte(bytes[off]) { - off += 1; - } - off -} - -fn is_path_byte(byte: u8) -> bool { - byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'/' | b'\\' | b'-' | b' ' | b':') -} - -fn parse_objects(bytes: &[u8]) -> Vec { - let mut objects = Vec::new(); - let min_record_tail = 48usize; - - for offset in 0..bytes.len().saturating_sub(16) { - let Some(flags) = read_u32(bytes, offset + 4) else { - continue; - }; - if flags != OBJECT_RECORD_FLAGS { - continue; - } - - let Some(name_len) = read_u32(bytes, offset + 8).map(|v| v as usize) else { - continue; - }; - if !(3..=260).contains(&name_len) { - continue; - } - - let name_start = offset + 12; - let Some(name_end) = name_start.checked_add(name_len) else { - continue; - }; - if name_end + min_record_tail > bytes.len() { - continue; - } - - let name_raw = &bytes[name_start..name_end]; - if !is_object_name_bytes(name_raw) { - continue; - } - - let resource_name = decode_cp1251(name_raw); - if !looks_like_object_name(&resource_name) { - continue; - } - - let Some(group_id) = read_u32(bytes, offset) else { - continue; - }; - let Some(logical_id) = read_i32(bytes, name_end) else { - continue; - }; - let Some(clan_id) = read_i32(bytes, name_end + 4) else { - continue; - }; - let Some(position) = read_vec3(bytes, name_end + 8) else { - continue; - }; - let Some(orientation) = read_vec3(bytes, name_end + 20) else { - continue; - }; - let Some(scale) = read_vec3(bytes, name_end + 32) else { - continue; - }; - if !all_finite(&position) || !all_finite(&orientation) || !all_finite(&scale) { - continue; - } - - let alias = parse_alias(bytes, name_end + 44); - - objects.push(MissionObject { - offset, - group_id, - flags, - resource_name, - logical_id, - clan_id, - position, - orientation, - scale, - alias, - }); - } - - objects.sort_by_key(|obj| obj.offset); - objects.dedup_by_key(|obj| obj.offset); - objects -} - -fn parse_alias(bytes: &[u8], alias_len_off: usize) -> String { - let Some(alias_len) = read_u32(bytes, alias_len_off).map(|v| v as usize) else { - return String::new(); - }; - if alias_len == 0 || alias_len > 96 { - return String::new(); - } - let alias_start = alias_len_off + 4; - let Some(alias_end) = alias_start.checked_add(alias_len) else { - return String::new(); - }; - if alias_end > bytes.len() { - return String::new(); - } - let alias_raw = &bytes[alias_start..alias_end]; - if !alias_raw - .iter() - .all(|&b| b == b'_' || b == b'-' || b == b'.' || b.is_ascii_alphanumeric()) - { - return String::new(); - } - decode_cp1251(alias_raw) -} - -fn looks_like_object_name(name: &str) -> bool { - if name.ends_with(".dat") { - return true; - } - name.contains('_') -} - -fn is_object_name_bytes(bytes: &[u8]) -> bool { - bytes - .iter() - .all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'_' | b'.' | b'/' | b'\\' | b'-')) -} - -fn all_finite(v: &[f32; 3]) -> bool { - v.iter().all(|c| c.is_finite()) -} - -fn decode_cp1251(bytes: &[u8]) -> String { - let (decoded, _, _) = WINDOWS_1251.decode(bytes); - decoded.into_owned() -} - -fn decode_title(bytes: &[u8]) -> String { - let end = bytes - .iter() - .rposition(|b| *b != 0 && *b != 0xCD) - .map(|idx| idx + 1) - .unwrap_or(0); - decode_cp1251(&bytes[..end]).trim().to_string() -} - -fn read_u32(bytes: &[u8], offset: usize) -> Option { - let end = offset.checked_add(4)?; - let chunk = bytes.get(offset..end)?; - Some(u32::from_le_bytes(chunk.try_into().ok()?)) -} - -fn read_i32(bytes: &[u8], offset: usize) -> Option { - read_u32(bytes, offset).map(|v| v as i32) -} - -fn read_f32(bytes: &[u8], offset: usize) -> Option { - let end = offset.checked_add(4)?; - let chunk = bytes.get(offset..end)?; - Some(f32::from_le_bytes(chunk.try_into().ok()?)) -} - -fn read_vec3(bytes: &[u8], offset: usize) -> Option<[f32; 3]> { - Some([ - read_f32(bytes, offset)?, - read_f32(bytes, offset + 4)?, - read_f32(bytes, offset + 8)?, - ]) -} - -#[cfg(test)] -mod tests { - use super::*; - use common::collect_files_recursive; - use std::path::{Path, PathBuf}; - - fn game_root() -> Option { - let root = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("testdata") - .join("Parkan - Iron Strategy"); - root.is_dir().then_some(root) - } - - #[test] - fn parses_known_mission_footer_and_objects() { - let Some(root) = game_root() else { - eprintln!("skipping: game root is missing"); - return; - }; - - let path = root - .join("MISSIONS") - .join("CAMPAIGN") - .join("CAMPAIGN.00") - .join("Mission.01") - .join("data.tma"); - if !path.is_file() { - eprintln!("skipping: sample mission is missing ({})", path.display()); - return; - } - - let mission = parse_path(&path).expect("parse mission failed"); - assert_eq!(mission.footer.version, 1); - assert!( - mission - .footer - .map_path - .eq_ignore_ascii_case("DATA\\MAPS\\Tut_1\\land"), - "unexpected map path: {}", - mission.footer.map_path - ); - assert!(mission.objects.len() >= 20); - assert!(mission - .objects - .iter() - .any(|obj| obj.resource_name.eq_ignore_ascii_case("s_tree_04"))); - assert!(mission.objects.iter().any(|obj| { - obj.resource_name - .eq_ignore_ascii_case("UNITS\\UNITS\\HERO\\tut1_p.dat") - })); - } - - #[test] - fn parses_all_retail_missions() { - let Some(root) = game_root() else { - eprintln!("skipping: game root is missing"); - return; - }; - - let mission_root = root.join("MISSIONS"); - let mut files = Vec::new(); - collect_files_recursive(&mission_root, &mut files); - files.sort(); - - let mut mission_count = 0usize; - for path in files { - if !path - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.eq_ignore_ascii_case("data.tma")) - { - continue; - } - - mission_count += 1; - let mission = parse_path(&path) - .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display())); - assert!( - mission - .footer - .map_path - .to_ascii_uppercase() - .contains("DATA\\MAPS\\"), - "{}: invalid map path '{}'", - path.display(), - mission.footer.map_path - ); - assert!( - !mission.objects.is_empty(), - "{}: mission has no parsed object records", - path.display() - ); - assert!( - mission - .objects - .iter() - .all(|obj| obj.position.iter().all(|v| v.is_finite())), - "{}: mission has non-finite position", - path.display() - ); - } - - assert!(mission_count > 0, "no data.tma files found"); - } -} diff --git a/crates/unitdat/Cargo.toml b/crates/unitdat/Cargo.toml deleted file mode 100644 index 73df4df..0000000 --- a/crates/unitdat/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "unitdat" -version = "0.1.0" -edition = "2021" - -[dependencies] -encoding_rs = "0.8" - -[dev-dependencies] -common = { path = "../common" } diff --git a/crates/unitdat/src/lib.rs b/crates/unitdat/src/lib.rs deleted file mode 100644 index 6414e66..0000000 --- a/crates/unitdat/src/lib.rs +++ /dev/null @@ -1,180 +0,0 @@ -use encoding_rs::WINDOWS_1251; -use std::fmt; -use std::fs; -use std::path::Path; - -const MIN_SIZE: usize = 0x48; -const MAGIC: u32 = 0x0000_F0F1; - -pub type Result = core::result::Result; - -#[derive(Debug)] -pub enum Error { - Io(std::io::Error), - TooSmall { got: usize }, - InvalidMagic { got: u32 }, - MissingArchiveName, - MissingModelKey, -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Io(err) => write!(f, "{err}"), - Self::TooSmall { got } => write!(f, "unit .dat is too small: {got} bytes"), - Self::InvalidMagic { got } => write!(f, "invalid .dat magic: 0x{got:08X}"), - Self::MissingArchiveName => write!(f, "unit .dat has empty archive name"), - Self::MissingModelKey => write!(f, "unit .dat has empty model key"), - } - } -} - -impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io(err) => Some(err), - _ => None, - } - } -} - -impl From for Error { - fn from(value: std::io::Error) -> Self { - Self::Io(value) - } -} - -#[derive(Clone, Debug)] -pub struct UnitDat { - pub magic: u32, - pub flags: u32, - pub archive_name: String, - pub model_key: String, -} - -pub fn parse_path(path: impl AsRef) -> Result { - let bytes = fs::read(path.as_ref())?; - parse_bytes(&bytes) -} - -pub fn parse_bytes(bytes: &[u8]) -> Result { - if bytes.len() < MIN_SIZE { - return Err(Error::TooSmall { got: bytes.len() }); - } - - let magic = read_u32(bytes, 0).ok_or(Error::TooSmall { got: bytes.len() })?; - if magic != MAGIC { - return Err(Error::InvalidMagic { got: magic }); - } - - let flags = read_u32(bytes, 4).ok_or(Error::TooSmall { got: bytes.len() })?; - let archive_name = decode_c_string_fixed(&bytes[0x08..0x28]); - if archive_name.is_empty() { - return Err(Error::MissingArchiveName); - } - - let model_key = decode_c_string_fixed(&bytes[0x28..0x48]); - if model_key.is_empty() { - return Err(Error::MissingModelKey); - } - - Ok(UnitDat { - magic, - flags, - archive_name, - model_key, - }) -} - -fn read_u32(bytes: &[u8], offset: usize) -> Option { - let end = offset.checked_add(4)?; - let chunk = bytes.get(offset..end)?; - Some(u32::from_le_bytes(chunk.try_into().ok()?)) -} - -fn decode_c_string_fixed(bytes: &[u8]) -> String { - let used = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); - let (decoded, _, _) = WINDOWS_1251.decode(&bytes[..used]); - decoded.trim().to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - use common::collect_files_recursive; - use std::path::{Path, PathBuf}; - - fn game_root() -> Option { - let root = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("testdata") - .join("Parkan - Iron Strategy"); - root.is_dir().then_some(root) - } - - #[test] - fn parses_known_dat_files() { - let Some(root) = game_root() else { - eprintln!("skipping: game root missing"); - return; - }; - - let samples = [ - root.join("UNITS/UNITS/HERO/tut1_p.dat"), - root.join("UNITS/UNITS/BATTLE/l_targ.dat"), - root.join("UNITS/BUILDS/BRIDGE/m_bridge.dat"), - ]; - - for path in samples { - if !path.is_file() { - eprintln!("skipping missing sample {}", path.display()); - continue; - } - let dat = parse_path(&path) - .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display())); - assert_eq!(dat.magic, MAGIC); - assert!(dat.archive_name.to_ascii_lowercase().ends_with(".rlb")); - assert!(dat.model_key.contains('_')); - } - } - - #[test] - fn parses_retail_dat_corpus() { - let Some(root) = game_root() else { - eprintln!("skipping: game root missing"); - return; - }; - - let units_root = root.join("UNITS"); - let mut files = Vec::new(); - collect_files_recursive(&units_root, &mut files); - files.sort(); - - let mut parsed = 0usize; - for path in files { - if !path - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| ext.eq_ignore_ascii_case("dat")) - { - continue; - } - let dat = parse_path(&path) - .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display())); - assert!( - !dat.archive_name.is_empty(), - "{} empty archive", - path.display() - ); - assert!( - !dat.model_key.is_empty(), - "{} empty model key", - path.display() - ); - parsed += 1; - } - - assert!(parsed > 0, "no .dat files parsed"); - } -} diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index f9d7d0e..bf63b4e 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -111,6 +111,39 @@ launcher, который восстанавливает snapshot, запуска key, configuration, device profile, initial state, input/time script и версии инструментов. +## Local evidence requests + +На текущем рабочем месте закрыты статические, corpus и headless runtime gates. +Для macOS Desktop GL подтверждены безопасный command/state trace и offscreen +pixel capture: + +- `cargo test -p fparkan-render-gl --offline desktop_gl33_triangle_command_capture`; +- `fixtures/acceptance/macos-gl33-triangle-capture.json`. + +`S3-GL-001` считается закрытым для текущей macOS-focused цели: временный +`rustc` probe создал CGL/OpenGL offscreen FBO, выполнил shader-based triangle +draw, прочитал RGBA pixels и сохранил hash capture. Probe не добавляет +project-owned `unsafe` в workspace; постоянный adapter API остаётся safe. + +Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2 +backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же +command/state trace. Локальный Docker probe существующего Rust image не нашёл +`libGL`, `libEGL`, `libGLES` или `libOSMesa`, поэтому закрытие этого gate требует +отдельно предоставленного Docker image с Rust + Mesa/EGL/OSMesa либо разрешения +на установку соответствующего проверочного окружения. + +Для текущей macOS-focused цели `S3-GL-002`, `L3-DEVICE-001` и `L5-RG40-001` +помечены как `omitted`: они остаются требованиями portable target scope, но не +блокируют локальный macOS acceptance-аудит. При возврате RG40XX/GLES2 в область +цели эти gates снова должны требовать внешнего evidence. + +`L3-DEVICE-001` и `L5-RG40-001` не закрываются локально без RG40XX H или +эквивалентного удалённого runner-а. Требуемое доказательство: запуск выбранной +миссии при 640x480 на целевом профиле, сохранённые stdout/stderr, build +fingerprint, manifest игрового каталога, frame/tick budget, memory budget и +итоговый pass/fail report. Desktop/headless результаты не считаются заменой +on-device smoke. + ## Closure criteria Вопрос считается закрытым только при наличии build fingerprint, raw trace, diff --git a/docs/baseline/current-project-audit.md b/docs/baseline/current-project-audit.md new file mode 100644 index 0000000..1c566fd --- /dev/null +++ b/docs/baseline/current-project-audit.md @@ -0,0 +1,13 @@ +# Current Project Audit + +Baseline command: + +```text +env RUSTC=/Users/valentineus/.rustup/toolchains/stable-aarch64-apple-darwin/bin/rustc /opt/homebrew/bin/rustup run stable cargo test --workspace --offline +``` + +Result on 2026-06-22: + +- library and binary unit tests compile and pass after aligning SDL2 versions and pinning `toml` to cached `0.8`; +- doctests fail in this shell because `rustdoc` is not in PATH unless `RUSTDOC` is also set to the real toolchain binary; +- full online dependency resolution is unavailable in the sandbox. diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv new file mode 100644 index 0000000..6eb7e67 --- /dev/null +++ b/fixtures/acceptance/coverage.tsv @@ -0,0 +1,365 @@ +# Acceptance coverage manifest. +# Format: \t\t +L0-COPYRIGHT-001 covered cargo test -p fparkan-corpus --offline report_json_contains_metrics_and_hashes_not_paths_or_payloads +L0-P1-001 covered cargo test -p fparkan-corpus --offline licensed_part1_manifest_profile_and_counts_match_baseline +L0-P1-002 covered cargo test -p fparkan-corpus --offline licensed_part1_has_no_casefold_relative_path_collisions +L0-P2-001 covered cargo test -p fparkan-corpus --offline licensed_part2_manifest_profile_and_counts_match_baseline +L0-P2-002 covered cargo test -p fparkan-corpus --offline licensed_part2_has_no_casefold_relative_path_collisions +S0-ARCH-001 covered cargo xtask policy runs cargo metadata --offline --no-deps successfully +S0-ARCH-002 covered cargo xtask policy rejects forbidden GUI/adapter dependencies from domain crates +S0-ARCH-003 covered cargo xtask policy rejects platform/render adapter dependencies from fparkan-headless +S0-ARCH-004 covered cargo xtask policy scans workspace-owned Rust/TOML for unsafe constructs and workspace lints forbid unsafe_code +S0-ARCH-005 covered cargo xtask policy rejects Python source files, Python shebangs, and Python CI workflow steps while allowing docs requirements.txt +S0-ARCH-006 covered cargo xtask policy rejects non-fparkan package directories under crates/ +S0-DIAG-001 covered cargo test -p fparkan-diagnostics --offline diagnostic_chain_preserves_context +S0-DIAG-002 covered cargo test -p fparkan-diagnostics --offline json_is_stable +S0-CORPUS-001 covered cargo test -p fparkan-corpus --offline deterministic_traversal_is_creation_order_independent +S0-CORPUS-002 covered cargo test -p fparkan-corpus --offline unreadable_directory_produces_error +S0-CORPUS-003 covered cargo test -p fparkan-corpus --offline symlink_loop_is_not_traversed_by_default +S0-CORPUS-004 covered cargo test -p fparkan-corpus --offline casefold_collisions_are_registered +S0-CORPUS-005 covered cargo test -p fparkan-corpus --offline fingerprint_changes +S0-CORPUS-006 covered cargo test -p fparkan-corpus --offline atomic_report_write +S0-CLI-001 covered cargo test -p fparkan-cli --offline stable_exit_codes_are_mapped +S0-CLI-002 covered cargo test -p fparkan-cli --offline accepts_json_format_option archive_json_has_schema_version +S0-GL-001 covered cargo test -p fparkan-platform-sdl -p fparkan-render-gl --offline adapter_reports_safe_project_layer_ready +S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow +S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read +L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates +L1-P2-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates +L1-P1-NRES-002 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates +L1-P2-NRES-002 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates +L1-P1-NRES-003 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates +L1-P2-NRES-003 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates +L1-P1-RSLI-001 covered cargo test -p fparkan-rsli --offline licensed_part1_rsli_method_distribution_baseline +L1-P2-RSLI-001 covered cargo test -p fparkan-rsli --offline licensed_part2_rsli_method_distribution_baseline +L1-RSLI-QUIRK-001 covered cargo test -p fparkan-rsli --offline licensed_corpora_rsli_quirk_is_only_approved_interf8_tex +L1-P1-PATH-001 covered cargo test -p fparkan-corpus --offline licensed_part1_paths_stay_under_root +L1-P2-PATH-001 covered cargo test -p fparkan-corpus --offline licensed_part2_paths_stay_under_root +S1-NRES-001 covered cargo test -p fparkan-nres --offline parses_minimal_empty_archive +S1-NRES-002 covered cargo test -p fparkan-nres --offline one_entry_archive_uses_8_byte_alignment +S1-NRES-003 covered cargo test -p fparkan-nres --offline rejects_invalid_magic +S1-NRES-004 covered cargo test -p fparkan-nres --offline rejects_unsupported_version +S1-NRES-005 covered cargo test -p fparkan-nres --offline rejects_negative_entry_count +S1-NRES-006 covered cargo test -p fparkan-nres --offline rejects_directory_size_before_allocation +S1-NRES-007 covered cargo test -p fparkan-nres --offline rejects_total_size_mismatch +S1-NRES-008 covered cargo test -p fparkan-nres --offline rejects_directory_before_header +S1-NRES-009 covered cargo test -p fparkan-nres --offline rejects_payload_before_data_region +S1-NRES-010 covered cargo test -p fparkan-nres --offline rejects_payload_crossing_directory +S1-NRES-020 covered cargo test -p fparkan-nres --offline preserves_nonzero_unindexed_region +S1-NRES-021 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates +S1-NRES-011 covered cargo test -p fparkan-nres --offline rejects_overlapping_payloads +S1-NRES-012 covered cargo test -p fparkan-nres --offline rejects_name_without_nul_terminator +S1-NRES-013 covered cargo test -p fparkan-nres --offline preserves_name_bytes_after_nul +S1-NRES-014 covered cargo test -p fparkan-nres --offline rejects_sort_index_out_of_range +S1-NRES-015 covered cargo test -p fparkan-nres --offline rejects_duplicate_sort_mapping +S1-NRES-016 covered cargo test -p fparkan-nres --offline binary_lookup_returns_original_entry_index +S1-NRES-017 covered cargo test -p fparkan-nres --offline compatible_profile_uses_linear_fallback_for_broken_mapping +S1-NRES-018 covered cargo test -p fparkan-nres --offline lookup_is_ascii_case_insensitive +S1-NRES-019 covered cargo test -p fparkan-nres --offline rejects_empty_names_and_resolves_duplicates_to_first_entry +S1-NRES-022 covered cargo test -p fparkan-nres --offline canonical_compact_roundtrip_preserves_entry_semantics +S1-NRES-023 covered cargo test -p fparkan-nres --offline editor_payload_update_rewrites_offsets_and_size +S1-NRES-024 covered cargo test -p fparkan-nres --offline editor_rename_rebuilds_search_mapping +S1-NRES-025 covered cargo test -p fparkan-nres --offline editor_rejects_invalid_authoring_names +S1-NRES-PROP-001 covered cargo test -p fparkan-nres --offline generated_archives_preserve_lossless_and_canonical_semantics +S1-NRES-PROP-002 covered cargo test -p fparkan-nres --offline generated_editor_updates_roundtrip +S1-NRES-FUZZ-001 covered cargo test -p fparkan-nres --offline arbitrary_small_inputs_do_not_panic_or_overallocate +S1-PATH-001 covered cargo test -p fparkan-path --offline normalizes_separators +S1-PATH-002 covered cargo test -p fparkan-path --offline ascii_casefold_does_not_unicode_fold +S1-PATH-003 covered cargo test -p fparkan-path --offline non_ascii_original_bytes_remain_stable +S1-PATH-004 covered cargo test -p fparkan-path --offline rejects_absolute_drive_and_nul_paths +S1-PATH-005 covered cargo test -p fparkan-path --offline rejects_escape +S1-PATH-006 covered cargo test -p fparkan-path --offline rejects_absolute_drive_and_nul_paths +S1-PATH-007 covered cargo test -p fparkan-path --offline join_under_keeps_normalized_path_below_root +S1-PATH-008 covered cargo test -p fparkan-path --offline original_separators_and_raw_bytes_are_preserved +S1-RSLI-001 covered cargo test -p fparkan-rsli --offline parses_minimal_empty_library +S1-RSLI-002 covered cargo test -p fparkan-rsli --offline rejects_invalid_header_fields +S1-RSLI-003 covered cargo test -p fparkan-rsli --offline rejects_entry_table_bounds +S1-RSLI-004 covered cargo test -p fparkan-rsli --offline table_xor_transform_uses_known_vector +S1-RSLI-005 covered cargo test -p fparkan-rsli --offline table_xor_transform_is_symmetric +S1-RSLI-006 covered cargo test -p fparkan-rsli --offline table_xor_state_spans_entries +S1-RSLI-007 covered cargo test -p fparkan-rsli --offline presorted_mapping_uses_valid_permutation +S1-RSLI-008 covered cargo test -p fparkan-rsli --offline compatible_profile_rebuilds_invalid_presorted_mapping +S1-RSLI-009 covered cargo test -p fparkan-rsli --offline stored_method_uses_exact_size +S1-RSLI-010 covered cargo test -p fparkan-rsli --offline xor_only_method_uses_entry_key +S1-RSLI-011 covered cargo test -p fparkan-rsli --offline lzss_method_decodes_literals_references_and_wrap +S1-RSLI-012 covered cargo test -p fparkan-rsli --offline xor_lzss_method_uses_entry_key +S1-RSLI-013 covered cargo test -p fparkan-rsli --offline adaptive_lzss_method_decodes_synthetic_vector +S1-RSLI-014 covered cargo test -p fparkan-rsli --offline xor_adaptive_lzss_method_decodes_synthetic_vector +S1-RSLI-015 covered cargo test -p fparkan-rsli --offline raw_deflate_method_expects_raw_stream_not_zlib_wrapper +S1-RSLI-016 covered cargo test -p fparkan-rsli --offline unknown_method_is_rejected_on_load +S1-RSLI-017 covered cargo test -p fparkan-rsli --offline decoded_size_mismatch_is_rejected +S1-RSLI-018 covered cargo test -p fparkan-rsli --offline ao_overlay_adjusts_effective_offsets +S1-RSLI-019 covered cargo test -p fparkan-rsli --offline invalid_ao_overlay_is_rejected +S1-RSLI-020 covered cargo test -p fparkan-rsli --offline rejects_registered_quirks_in_strict_profile +S1-RSLI-021 covered cargo test -p fparkan-rsli --offline named_deflate_eof_plus_one_quirk_accepts_only_approved_entry +S1-RSLI-022 covered cargo test -p fparkan-rsli --offline unknown_header_bytes_are_lossless +S1-RSLI-023 covered cargo test -p fparkan-rsli --offline no_op_lossless_roundtrip_preserves_bytes +S1-RSLI-PROP-001 covered cargo test -p fparkan-rsli --offline generated_supported_methods_decode_expected_bytes +S1-RSLI-FUZZ-001 covered cargo test -p fparkan-rsli --offline arbitrary_small_inputs_do_not_panic +S1-RES-001 covered cargo test -p fparkan-resource --offline cached_repository_reads_synthetic_nres +S1-RES-002 covered cargo test -p fparkan-resource --offline entry_handles_are_archive_qualified +S1-RES-003 covered cargo test -p fparkan-resource --offline archive_cache_and_decoded_payload_cache_evict_independently +S1-RES-004 covered cargo test -p fparkan-resource --offline entry_read_error_carries_archive_path_and_entry_name +S1-VFS-001 covered cargo test -p fparkan-vfs --offline memory_vfs_uses_exact_lookup +S1-VFS-002 covered cargo test -p fparkan-vfs --offline overlay_vfs_uses_first_matching_layer +S1-VFS-003 covered cargo test -p fparkan-vfs --offline directory_vfs_resolves_ascii_casefolded_segments +S1-VFS-004 covered cargo test -p fparkan-vfs --offline casefold_selector_reports_ambiguous_segments +L2-P1-UNIT-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_unit_dat_parse_counts +L2-P2-UNIT-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_unit_dat_parse_counts +L2-P1-REG-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_registry_payloads_are_record_aligned +L2-P2-REG-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_registry_payloads_are_record_aligned +L2-P1-GRAPH-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations +L2-P2-GRAPH-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations +L2-P1-INHERIT-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations +L2-P2-INHERIT-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations +L2-P1-NONGEO-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations +L2-P2-NONGEO-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations +L2-P1-GRAPH-002 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations +L2-P2-GRAPH-002 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations +S2-REG-001 covered cargo test -p fparkan-prototype --offline registry_zero_records_payload_is_empty +S2-REG-002 covered cargo test -p fparkan-prototype --offline registry_requires_record_multiple +S2-REG-003 covered cargo test -p fparkan-prototype --offline registry_preserves_bounded_name_tails_and_order +S2-REG-004 covered cargo test -p fparkan-prototype --offline registry_preserves_bounded_name_tails_and_order +S2-UNIT-001 covered cargo test -p fparkan-prototype --offline unit_zero_records_uses_exact_size +S2-UNIT-002 covered cargo test -p fparkan-prototype --offline unit_dat_one_record_uses_exact_size_formula +S2-UNIT-003 covered cargo test -p fparkan-prototype --offline unit_dat_rejects_truncated_record +S2-UNIT-004 covered cargo test -p fparkan-prototype --offline unit_dat_preserves_header_description_tail_and_parent_link +S2-UNIT-005 covered cargo test -p fparkan-prototype --offline unit_dat_preserves_header_description_tail_and_parent_link +S2-UNIT-006 covered cargo test -p fparkan-prototype --offline unit_dat_accepts_full_description_without_nul +S2-UNIT-007 covered cargo test -p fparkan-prototype --offline unit_dat_preserves_header_description_tail_and_parent_link unit_dat_preserves_positive_parent_link +S2-PROTO-001 covered cargo test -p fparkan-prototype --offline resolves_synthetic_objects_registry_model +S2-PROTO-002 covered cargo test -p fparkan-prototype --offline base_only_registry_entry_is_nongeometric +S2-PROTO-003 covered cargo test -p fparkan-prototype --offline graph_report_records_resolved_roots_and_failures +S2-PROTO-004 covered cargo test -p fparkan-prototype --offline missing_referenced_archive_reports_root_chain +S2-PROTO-005 covered cargo test -p fparkan-prototype --offline missing_referenced_resource_reports_root_chain +S2-PROTO-006 covered cargo test -p fparkan-prototype --offline objects_registry_inheritance_merges_parent_then_local_refs +S2-PROTO-007 covered cargo test -p fparkan-prototype --offline objects_registry_inheritance_resolves_multiple_levels +S2-PROTO-008 covered cargo test -p fparkan-prototype --offline objects_registry_inheritance_rejects_direct_cycle +S2-PROTO-009 covered cargo test -p fparkan-prototype --offline objects_registry_inheritance_rejects_indirect_cycle +S2-PROTO-010 covered cargo test -p fparkan-prototype --offline objects_registry_inheritance_rejects_depth_limit +S2-PROTO-011 covered cargo test -p fparkan-prototype --offline base_only_registry_entry_is_nongeometric +S2-PROTO-012 covered cargo test -p fparkan-prototype --offline first_existing_explicit_msh_is_selected_in_order +S2-PROTO-013 covered cargo test -p fparkan-prototype --offline invalid_referenced_msh_is_error +S2-PROTO-014 covered cargo test -p fparkan-prototype --offline resolver_cache_invalidates_when_archive_fingerprint_changes +S2-GRAPH-001 covered cargo test -p fparkan-prototype --offline graph_report_records_resolved_roots_and_failures +S2-GRAPH-002 covered cargo test -p fparkan-prototype --offline unit_dat_expands_components_in_order +S2-GRAPH-003 covered cargo test -p fparkan-assets --offline repository_plan_deduplicates_duplicate_visuals_but_graph_preserves_requests +S2-GRAPH-004 covered cargo test -p fparkan-prototype --offline graph_report_records_resolved_roots_and_failures +S2-GRAPH-005 covered cargo test -p fparkan-cli --offline prototype_graph_json_has_canonical_field_order +S2-GRAPH-006 covered cargo test -p fparkan-prototype --offline graph_report_records_resolved_roots_and_failures +S2-PROP-001 covered cargo test -p fparkan-prototype --offline generated_acyclic_prototype_graph_resolves_deterministically +S2-FUZZ-001 covered cargo test -p fparkan-prototype --offline arbitrary_unit_and_registry_bytes_are_bounded_and_panic_free +L3-P1-MSH-001 covered cargo test -p fparkan-msh --offline licensed_corpus_msh_assets_validate +L3-P2-MSH-001 covered cargo test -p fparkan-msh --offline licensed_corpus_msh_assets_validate +L3-P1-TEXM-001 covered cargo test -p fparkan-texm --offline licensed_corpus_texm_assets_validate_and_decode_mip0 +L3-P2-TEXM-001 covered cargo test -p fparkan-texm --offline licensed_corpus_texm_assets_validate_and_decode_mip0 +L3-P1-MAT0-001 covered cargo test -p fparkan-material --offline licensed_corpus_mat0_and_wear_parse +L3-P2-MAT0-001 covered cargo test -p fparkan-material --offline licensed_corpus_mat0_and_wear_parse +L3-P1-WEAR-001 covered cargo test -p fparkan-material --offline licensed_corpus_mat0_and_wear_parse +L3-P2-WEAR-001 covered cargo test -p fparkan-material --offline licensed_corpus_mat0_and_wear_parse +L3-P1-ASSET-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations +L3-P2-ASSET-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations +L3-P1-CAPTURE-001 covered cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures +L3-P2-CAPTURE-001 covered cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures +S3-WEAR-001 covered cargo test -p fparkan-material --offline wear_preserves_legacy_id_but_selects_by_index +S3-WEAR-002 covered cargo test -p fparkan-material --offline wear_requires_declared_rows +S3-WEAR-003 covered cargo test -p fparkan-material --offline wear_preserves_legacy_id_but_selects_by_index +S3-WEAR-004 covered cargo test -p fparkan-material --offline wear_requires_blank_separator_before_lightmaps +S3-WEAR-005 covered cargo test -p fparkan-material --offline wear_parses_lightmaps +S3-MAT0-001 covered cargo test -p fparkan-material --offline mat0_version_prefix_and_primary_texture +S3-MAT0-002 covered cargo test -p fparkan-material --offline mat0_accepts_zero_phase_material +S3-MAT0-003 covered cargo test -p fparkan-material --offline mat0_phase34_exact_framing_and_full_texture_name +S3-MAT0-004 covered cargo test -p fparkan-material --offline mat0_phase34_exact_framing_and_full_texture_name +S3-MAT0-005 covered cargo test -p fparkan-material --offline mat0_animation_block_has_no_implicit_padding +S3-MAT0-006 covered cargo test -p fparkan-material --offline mat0_rejects_animation_block_count_limit +S3-MSH-001 covered cargo test -p fparkan-msh --offline canonical_stream_set_is_independent_of_entry_order +S3-MSH-002 covered cargo test -p fparkan-msh --offline missing_required_stream_is_error +S3-MSH-003 covered cargo test -p fparkan-msh --offline duplicate_required_stream_type_is_error +S3-MSH-004 covered cargo test -p fparkan-msh --offline node38_stride_is_exact +S3-MSH-005 covered cargo test -p fparkan-msh --offline node38_uses_three_by_five_slot_mapping_and_absent_marker +S3-MSH-006 covered cargo test -p fparkan-msh --offline node38_uses_three_by_five_slot_mapping_and_absent_marker +S3-MSH-007 covered cargo test -p fparkan-msh --offline type2_header_and_slot_tail_framing_are_exact +S3-MSH-008 covered cargo test -p fparkan-msh --offline type2_header_and_slot_tail_framing_are_exact +S3-MSH-009 covered cargo test -p fparkan-msh --offline slot_batch_range_out_of_bounds_is_error +S3-MSH-010 covered cargo test -p fparkan-msh --offline vertex_stream_strides_are_exact +S3-MSH-011 covered cargo test -p fparkan-msh --offline base_vertex_plus_index_must_reference_position +S3-MSH-012 covered cargo test -p fparkan-msh --offline batch20_uses_unaligned_field_offsets +S3-MSH-013 covered cargo test -p fparkan-msh --offline auxiliary_and_extended_streams_are_preserved +S3-MSH-014 covered cargo test -p fparkan-msh --offline auxiliary_and_extended_streams_are_preserved +S3-MSH-015 covered cargo test -p fparkan-msh --offline mtcheck_variant_is_preserved_and_recognized +S3-MSH-016 covered cargo test -p fparkan-msh --offline invalid_bounds_are_rejected +S3-MSH-017 covered cargo test -p fparkan-msh --offline slot_batch_range_out_of_bounds_is_error +S3-MSH-PROP-001 covered cargo test -p fparkan-msh --offline canonical_stream_set_is_independent_of_entry_order +S3-MSH-FUZZ-001 covered cargo test -p fparkan-msh --offline arbitrary_nested_payloads_are_bounded_and_panic_free +S3-TEXM-001 covered cargo test -p fparkan-texm --offline decodes_all_synthetic_formats +S3-TEXM-002 covered cargo test -p fparkan-texm --offline rejects_zero_dimensions +S3-TEXM-003 covered cargo test -p fparkan-texm --offline non_power_of_two_mip_chain_clamps_each_dimension +S3-TEXM-004 covered cargo test -p fparkan-texm --offline rejects_mip_size_arithmetic_overflow_or_oob +S3-TEXM-005 covered cargo test -p fparkan-texm --offline indexed_palette_requires_exact_1024_bytes +S3-TEXM-006 covered cargo test -p fparkan-texm --offline channel_expansion_boundary_values_are_stable +S3-TEXM-007 covered cargo test -p fparkan-texm --offline rgb888x_preserves_fourth_disk_byte_but_outputs_opaque_alpha +S3-TEXM-008 covered cargo test -p fparkan-texm --offline page_tail_absent_and_exact_rect_framing +S3-TEXM-009 covered cargo test -p fparkan-texm --offline page_tail_absent_and_exact_rect_framing +S3-TEXM-010 covered cargo test -p fparkan-texm --offline invalid_page_magic_size_and_trailing_bytes_are_rejected +S3-TEXM-011 covered cargo test -p fparkan-texm --offline invalid_page_magic_size_and_trailing_bytes_are_rejected +S3-TEXM-012 covered cargo test -p fparkan-texm --offline exposes_mip_views_and_upload_plan_without_mutating_document +S3-TEXM-013 covered cargo test -p fparkan-texm --offline page_scaling_uses_floor_origin_and_ceil_end_policy +S3-TEXM-FUZZ-001 covered cargo test -p fparkan-texm --offline arbitrary_texm_payloads_do_not_panic +S3-MAT0-007 covered cargo test -p fparkan-material --offline mat0_rejects_trailing_bytes +S3-MAT-RESOLVE-001 covered cargo test -p fparkan-material --offline resolve_material_uses_exact_match +S3-MAT-RESOLVE-002 covered cargo test -p fparkan-material --offline resolve_material_falls_back_to_default +S3-MAT-RESOLVE-003 covered cargo test -p fparkan-material --offline resolve_material_uses_first_entry_only_after_missing_default +S3-MAT-RESOLVE-004 covered cargo test -p fparkan-material --offline resolve_material_empty_texture_means_untextured +S3-MAT-RESOLVE-005 covered cargo test -p fparkan-material --offline resolve_material_without_lightmap_keeps_lightmap_absent +S3-RENDER-001 covered cargo test -p fparkan-render --offline one_snapshot_draw_produces_one_draw_command +S3-RENDER-002 covered cargo test -p fparkan-render --offline material_index_maps_through_resolved_material_slots +S3-RENDER-003 covered cargo test -p fparkan-render --offline node_transform_is_retained +S3-RENDER-004 covered cargo test -p fparkan-render --offline command_order_uses_phase_then_stable_key +S3-RENDER-005 covered cargo test -p fparkan-render --offline command_capture_independent_of_snapshot_construction_order +S3-RENDER-006 covered cargo test -p fparkan-render --offline invalid_range_returns_contextual_error +S3-RENDER-007 covered cargo test -p fparkan-render --offline capture_is_stable +S3-RENDER-008 covered cargo test -p fparkan-render --offline recording_backend_stores_captures +S3-RENDER-009 covered cargo xtask policy +S3-GL-001 covered cargo test -p fparkan-render-gl --offline desktop_gl33_triangle_command_capture plus fixtures/acceptance/macos-gl33-triangle-capture.json records macOS CGL/OpenGL offscreen FBO pixel capture +S3-GL-002 omitted outside the current macOS-focused goal scope; GLES2 remains documented for portable/non-macOS targets +S3-GL-003 covered cargo test -p fparkan-render-gl --offline shader_compile_failure_diagnostic_contains_profile_and_log +S3-VIEWER-001 covered cargo test -p fparkan-viewer --offline model_fixture_uses_viewer_service_and_render_commands +S4-ANIM-001 covered cargo test -p fparkan-animation --offline anim_key24_decodes_signed_quaternion +S4-ANIM-002 covered cargo test -p fparkan-animation --offline frame_map_decodes_u16_and_uses_attr_frame_count +S4-ANIM-003 covered cargo test -p fparkan-animation --offline frame_map_falls_back_when_absent_or_invalid +S4-ANIM-004 covered cargo test -p fparkan-animation --offline frame_map_falls_back_when_absent_or_invalid +S4-ANIM-005 covered cargo test -p fparkan-animation --offline exact_key_time_returns_exact_pose +S4-ANIM-006 covered cargo test -p fparkan-animation --offline pose_track_blends_translation_and_rotation +S4-ANIM-007 covered cargo test -p fparkan-animation --offline quaternion_shortest_path_sign_flip_is_stable +S4-ANIM-008 covered cargo test -p fparkan-animation --offline zero_or_degenerate_key_interval_is_rejected +S4-ANIM-009 covered cargo test -p fparkan-animation --offline x87_boundary_golden_vectors_and_profile_difference_report +S4-ANIM-010 covered cargo test -p fparkan-animation --offline x87_boundary_golden_vectors_and_profile_difference_report +S4-ANIM-011 covered cargo test -p fparkan-animation --offline blend_optional_pose_uses_valid_side +S4-ANIM-012 covered cargo test -p fparkan-animation --offline hierarchy_evaluates_parent_before_child_and_rejects_cycles +S4-ANIM-013 covered cargo test -p fparkan-animation --offline hierarchy_evaluates_parent_before_child_and_rejects_cycles +S4-ANIM-014 covered cargo test -p fparkan-animation --offline pose_capture_uses_float_bits +S4-ANIM-PROP-001 covered cargo test -p fparkan-animation --offline generated_valid_quaternions_remain_finite +S4-MAT-001 covered cargo test -p fparkan-material --offline material_modes_zero_to_three_choose_stable_phases +S4-MAT-002 covered cargo test -p fparkan-material --offline material_exact_key_boundary_selects_exact_phase +S4-MAT-003 covered cargo test -p fparkan-material --offline material_interpolation_mask_affects_only_selected_fields +S4-MAT-004 covered cargo test -p fparkan-material --offline material_timeline_profile_cases_are_evidence_labeled +S4-MAT-005 covered cargo test -p fparkan-material --offline material_random_offset_uses_material_stream_only +S4-MAT-006 covered cargo test -p fparkan-material --offline material_same_seed_and_timeline_produces_same_phase_capture +S4-FX-001 covered cargo test -p fparkan-fx --offline header_is_exactly_sixty_bytes_and_command_sizes_are_fixed +S4-FX-002 covered cargo test -p fparkan-fx --offline header_is_exactly_sixty_bytes_and_command_sizes_are_fixed +S4-FX-011 covered cargo test -p fparkan-fx --offline header_is_exactly_sixty_bytes_and_command_sizes_are_fixed +S4-FX-012 covered cargo test -p fparkan-fx --offline opcode6_four_byte_command_is_accepted +S4-FX-013 covered cargo test -p fparkan-fx --offline rejects_unknown_opcode_at_command_index +S4-FX-014 covered cargo test -p fparkan-fx --offline rejects_command_count_that_exceeds_payload +S4-FX-015 covered cargo test -p fparkan-fx --offline rejects_trailing_bytes_after_command_stream +S4-FX-016 covered cargo test -p fparkan-fx --offline fixed_resource_refs_preserve_tails +S4-FX-017 covered cargo test -p fparkan-fx --offline missing_dependency_error_contains_effect_command_archive_and_name +S4-FX-018 covered cargo test -p fparkan-fx --offline create_records_seed_transform_and_start_time +S4-FX-019 covered cargo test -p fparkan-fx --offline update_and_emit_are_separate +S4-FX-020 covered cargo test -p fparkan-fx --offline update_and_emit_are_separate +S4-FX-021 covered cargo test -p fparkan-fx --offline stable_command_order_and_emission_capture_are_seed_stable +S4-FX-022 covered cargo test -p fparkan-fx --offline stop_restart_end_lifecycle_controls_emission +S4-FX-023 covered cargo test -p fparkan-fx --offline stable_command_order_and_emission_capture_are_seed_stable +S4-FX-024 covered cargo test -p fparkan-fx --offline unrelated_rng_stream_use_does_not_perturb_fx_capture +S4-FX-FUZZ-001 covered cargo test -p fparkan-fx --offline arbitrary_command_streams_are_bounded_and_panic_free +L4-P1-ANIM-001 covered cargo test -p fparkan-msh --offline licensed_corpus_animation_streams_sample_approved_pose_captures +L4-P2-ANIM-001 covered cargo test -p fparkan-msh --offline licensed_corpus_animation_streams_sample_approved_pose_captures +L4-P1-CAPTURE-001 covered cargo test -p fparkan-msh --offline licensed_corpus_animation_streams_sample_approved_pose_captures +L4-P2-CAPTURE-001 covered cargo test -p fparkan-msh --offline licensed_corpus_animation_streams_sample_approved_pose_captures +L4-P1-FX-001 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution +L4-P2-FX-001 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-FX-OP6-001 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution +L4-P1-EFFECT-001 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_emission_captures_are_approved +L4-P2-EFFECT-001 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_emission_captures_are_approved +S5-LMESH-001 covered cargo test -p fparkan-terrain-format --offline land_msh_required_streams_are_order_independent_and_stride_checked +S5-LMESH-002 covered cargo test -p fparkan-terrain-format --offline land_msh_required_streams_are_order_independent_and_stride_checked +S5-LMESH-003 covered cargo test -p fparkan-terrain-format --offline decodes_minimal_land_msh +S5-LMESH-004 covered cargo test -p fparkan-terrain-format --offline decodes_minimal_land_msh +S5-LMESH-005 covered cargo test -p fparkan-terrain-format --offline rejects_invalid_vertex_index +S5-LMESH-006 covered cargo test -p fparkan-terrain-format --offline rejects_invalid_neighbor_index +S5-LMESH-007 covered cargo test -p fparkan-terrain-format --offline face_layout_preserves_tail_and_all_surface_mask_mappings_are_explicit +S5-LMESH-008 covered cargo test -p fparkan-terrain-format --offline face_layout_preserves_tail_and_all_surface_mask_mappings_are_explicit +S5-LMESH-009 covered cargo test -p fparkan-terrain-format --offline face_layout_preserves_tail_and_all_surface_mask_mappings_are_explicit +S5-LMAP-001 covered cargo test -p fparkan-terrain-format --offline decodes_minimal_land_map +S5-LMAP-002 covered cargo test -p fparkan-terrain-format --offline land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof +S5-LMAP-003 covered cargo test -p fparkan-terrain-format --offline land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof +S5-LMAP-004 covered cargo test -p fparkan-terrain-format --offline land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof +S5-LMAP-005 covered cargo test -p fparkan-terrain-format --offline land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof +S5-LMAP-006 covered cargo test -p fparkan-terrain-format --offline rejects_invalid_areal_link +S5-LMAP-007 covered cargo test -p fparkan-terrain-format --offline land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof +S5-LMAP-008 covered cargo test -p fparkan-terrain-format --offline land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof +S5-LMAP-009 covered cargo test -p fparkan-terrain-format --offline land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof +S5-LMAP-010 covered cargo test -p fparkan-terrain-format --offline rejects_invalid_grid_area_ref +S5-LMAP-011 covered cargo test -p fparkan-terrain-format --offline land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof +S5-TERRAIN-001 covered cargo test -p fparkan-terrain --offline locates_areal_and_routes_synthetic_neighbors +S5-TERRAIN-002 covered cargo test -p fparkan-terrain --offline locates_areal_and_routes_synthetic_neighbors +S5-TERRAIN-003 covered cargo test -p fparkan-terrain --offline missing_start_or_goal_returns_no_route +S5-TERRAIN-004 covered cargo test -p fparkan-terrain --offline synthetic_surface_height_and_raycast_work +S5-TMA-001 covered cargo test -p fparkan-mission-format --offline minimal_synthetic_exact_eof +S5-TMA-002 covered cargo test -p fparkan-mission-format --offline lp_string_does_not_consume_implicit_nul +S5-TMA-003 covered cargo test -p fparkan-mission-format --offline path_ids_retain_nonsequential_order_and_truncated_points_fail +S5-TMA-004 covered cargo test -p fparkan-mission-format --offline path_ids_retain_nonsequential_order_and_truncated_points_fail +S5-TMA-005 covered cargo test -p fparkan-mission-format --offline clan_modes_one_to_three_and_spatial_mode_zero_decode +S5-TMA-006 covered cargo test -p fparkan-mission-format --offline clan_modes_one_to_three_and_spatial_mode_zero_decode +S5-TMA-007 covered cargo test -p fparkan-mission-format --offline unknown_clan_mode_nonfinite_transform_and_trailing_bytes_are_rejected +S5-TMA-008 covered cargo test -p fparkan-mission-format --offline synthetic_standard_clan_and_object_preserve_ordered_properties +S5-TMA-009 covered cargo test -p fparkan-mission-format --offline synthetic_standard_clan_and_object_preserve_ordered_properties +S5-TMA-010 covered cargo test -p fparkan-mission-format --offline synthetic_standard_clan_and_object_preserve_ordered_properties +S5-TMA-011 covered cargo test -p fparkan-mission-format --offline unknown_clan_mode_nonfinite_transform_and_trailing_bytes_are_rejected +S5-TMA-012 covered cargo test -p fparkan-mission-format --offline description_and_extras_are_exact_raw_records +S5-TMA-013 covered cargo test -p fparkan-mission-format --offline description_and_extras_are_exact_raw_records +S5-TMA-014 covered cargo test -p fparkan-mission-format --offline description_and_extras_are_exact_raw_records +S5-TMA-015 covered cargo test -p fparkan-mission-format --offline unknown_clan_mode_nonfinite_transform_and_trailing_bytes_are_rejected +S5-TMA-016 covered cargo test -p fparkan-mission-format --offline signatures_inside_strings_do_not_create_records_and_truncations_are_bounded +S5-TMA-017 covered cargo test -p fparkan-mission-format --offline signatures_inside_strings_do_not_create_records_and_truncations_are_bounded +S5-TMA-PROP-001 covered cargo test -p fparkan-mission-format --offline generated_valid_documents_and_arbitrary_inputs_are_bounded +S5-TMA-FUZZ-001 covered cargo test -p fparkan-mission-format --offline generated_valid_documents_and_arbitrary_inputs_are_bounded +S5-LOAD-001 covered cargo test -p fparkan-runtime --offline load_trace_records_preparation_before_registration_and_raw_transforms +S5-LOAD-002 covered cargo test -p fparkan-runtime --offline missing_map_and_missing_reachable_resource_fail_before_registration +S5-LOAD-003 covered cargo test -p fparkan-runtime --offline missing_map_and_missing_reachable_resource_fail_before_registration +S5-LOAD-004 covered cargo test -p fparkan-material --offline resolve_material_falls_back_to_default +S5-LOAD-005 covered cargo test -p fparkan-runtime --offline load_trace_records_preparation_before_registration_and_raw_transforms +S5-LOAD-006 covered cargo test -p fparkan-runtime --offline load_mission_requires_vfs_and_keeps_world_unchanged_on_error +S5-LOAD-007 covered cargo test -p fparkan-runtime --offline registration_phase_failure_uses_normal_teardown_and_keeps_engine_world +S5-LOAD-008 covered cargo test -p fparkan-runtime --offline load_trace_records_preparation_before_registration_and_raw_transforms +S5-LOAD-009 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations +S5-LOAD-010 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations +S5-WORLD-001 covered cargo test -p fparkan-world --offline construct_register_and_hash_are_stable +S5-WORLD-002 covered cargo test -p fparkan-world --offline registration_sequence_stale_and_duplicate_original_contracts +S5-WORLD-003 covered cargo test -p fparkan-world --offline registration_sequence_stale_and_duplicate_original_contracts +S5-WORLD-004 covered cargo test -p fparkan-world --offline registration_sequence_stale_and_duplicate_original_contracts +S5-WORLD-005 covered cargo test -p fparkan-world --offline identity_metadata_keeps_original_mirror_and_owner_distinct +S5-WORLD-006 covered cargo test -p fparkan-world --offline command_fifo_and_deferred_delete_during_calculation +S5-WORLD-007 covered cargo test -p fparkan-world --offline registration_sequence_stale_and_duplicate_original_contracts +S5-WORLD-008 covered cargo test -p fparkan-world --offline command_fifo_and_deferred_delete_during_calculation +S5-WORLD-009 covered cargo test -p fparkan-world --offline command_fifo_and_deferred_delete_during_calculation +S5-WORLD-010 covered cargo test -p fparkan-world --offline command_fifo_and_deferred_delete_during_calculation +S5-WORLD-011 covered cargo test -p fparkan-world --offline command_fifo_and_deferred_delete_during_calculation +S5-WORLD-012 covered cargo test -p fparkan-world --offline fixed_step_pause_and_long_determinism_are_stable +S5-WORLD-013 covered cargo test -p fparkan-world --offline fixed_step_pause_and_long_determinism_are_stable +S5-WORLD-014 covered cargo test -p fparkan-world --offline snapshot_hash_determinism_and_immutability +S5-WORLD-015 covered cargo test -p fparkan-world --offline snapshot_hash_determinism_and_immutability +S5-WORLD-016 covered cargo test -p fparkan-world --offline fixed_step_pause_and_long_determinism_are_stable +S5-WORLD-017 covered cargo test -p fparkan-world --offline render_disabled_does_not_change_hash_end_callbacks_and_shutdown_order +S5-WORLD-018 covered cargo test -p fparkan-world --offline render_disabled_does_not_change_hash_end_callbacks_and_shutdown_order +S5-WORLD-019 covered cargo test -p fparkan-world --offline render_disabled_does_not_change_hash_end_callbacks_and_shutdown_order +S5-WORLD-PROP-001 covered cargo test -p fparkan-world --offline generated_command_delete_sequences_preserve_registry_invariants +L5-P1-LMESH-001 covered cargo test -p fparkan-terrain-format --offline licensed_corpus_land_msh_validate +L5-P2-LMESH-001 covered cargo test -p fparkan-terrain-format --offline licensed_corpus_land_msh_validate +L5-P1-LMAP-001 covered cargo test -p fparkan-terrain-format --offline licensed_corpus_land_map_validate +L5-P2-LMAP-001 covered cargo test -p fparkan-terrain-format --offline licensed_corpus_land_map_validate +L5-LMAP-POLY-001 covered cargo test -p fparkan-terrain-format --offline licensed_corpus_land_map_validate land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof +L5-P1-TMA-001 covered cargo test -p fparkan-mission-format --offline licensed_corpus_tma_validate +L5-P2-TMA-001 covered cargo test -p fparkan-mission-format --offline licensed_corpus_tma_validate +L5-P1-MISSION-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations +L5-P2-MISSION-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations +L5-P1-MISSION-002 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations +L5-P2-MISSION-002 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations +L5-P1-HEADLESS-001 covered cargo test -p fparkan-runtime --offline selected_is_and_is2_missions_execute_10000_deterministic_ticks +L5-P2-HEADLESS-001 covered cargo test -p fparkan-runtime --offline selected_is_and_is2_missions_execute_10000_deterministic_ticks +L5-P1-RENDER-001 covered cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures +L5-P2-RENDER-001 covered cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures +L3-DEVICE-001 omitted outside the current macOS-focused goal scope; RG40XX-capable device/profile evidence remains documented for the portable target scope +L5-RG40-001 omitted outside the current macOS-focused goal scope; RG40XX 640x480 on-device mission smoke/performance/memory evidence remains documented for the portable target scope diff --git a/fixtures/acceptance/macos-gl33-triangle-capture.json b/fixtures/acceptance/macos-gl33-triangle-capture.json new file mode 100644 index 0000000..6107dc2 --- /dev/null +++ b/fixtures/acceptance/macos-gl33-triangle-capture.json @@ -0,0 +1,17 @@ +{ + "schema_version": "fparkan-macos-gl33-triangle-capture-v1", + "acceptance_id": "S3-GL-001", + "platform_scope": "macos", + "profile": "DesktopCore33", + "probe": "temporary rustc CGL/OpenGL offscreen FBO triangle capture", + "workspace_source_unsafe_free": true, + "requested_cgl_profile": "3.2-core-or-newer", + "version": "4.1 APPLE-23.1.1", + "renderer": "Apple Software Renderer", + "width": 64, + "height": 64, + "magenta_pixels": 1352, + "black_pixels": 2744, + "center_rgba": [255, 0, 255, 255], + "fnv1a64": "060aba78d7123f25" +} diff --git a/fixtures/schemas/corpus-manifest.schema.json b/fixtures/schemas/corpus-manifest.schema.json new file mode 100644 index 0000000..4542ec0 --- /dev/null +++ b/fixtures/schemas/corpus-manifest.schema.json @@ -0,0 +1,19 @@ +{ + "schema": 1, + "required": ["schema", "corpus"], + "properties": { + "schema": { "const": 1 }, + "corpus": { + "type": "array", + "items": { + "required": ["id", "kind", "root", "expected_profile"], + "properties": { + "id": { "type": "string" }, + "kind": { "enum": ["demo", "part1", "part2"] }, + "root": { "type": "string" }, + "expected_profile": { "type": "string" } + } + } + } + } +} diff --git a/fixtures/schemas/report.schema.json b/fixtures/schemas/report.schema.json new file mode 100644 index 0000000..70ab4df --- /dev/null +++ b/fixtures/schemas/report.schema.json @@ -0,0 +1,10 @@ +{ + "schema": 1, + "required": ["schema", "kind", "files", "metrics"], + "properties": { + "schema": { "const": 1 }, + "kind": { "type": "string" }, + "files": { "type": "integer" }, + "metrics": { "type": "object" } + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..d0ead5e --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["clippy", "rustfmt"] diff --git a/testdata/.gitignore b/testdata/.gitignore new file mode 100644 index 0000000..e5af87e --- /dev/null +++ b/testdata/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!README.md \ No newline at end of file diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..ce7316f --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "xtask" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-corpus = { path = "../crates/fparkan-corpus" } + +[lints] +workspace = true diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..4141d92 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,1442 @@ +#![forbid(unsafe_code)] +#![allow(clippy::print_stderr, clippy::print_stdout)] +//! Repository automation for `FParkan`. + +use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn main() { + let args = std::env::args().skip(1).collect::>(); + let code = match run(&args) { + Ok(()) => 0, + Err(err) => { + eprintln!("{err}"); + 2 + } + }; + std::process::exit(code); +} + +fn run(args: &[String]) -> Result<(), String> { + match args { + [cmd] if cmd == "ci" => { + run_rustfmt_check(Path::new("."))?; + run_policy(Path::new("."))?; + cargo(&["test", "--workspace", "--offline"])?; + clippy_rustup(&["--workspace", "--offline"])?; + Ok(()) + } + [cmd] if cmd == "policy" => run_policy(Path::new(".")), + [cmd, subcmd, rest @ ..] if cmd == "acceptance" && subcmd == "report" => { + let options = parse_acceptance_options(rest)?; + run_acceptance_report(&options) + } + [cmd, subcmd, rest @ ..] if cmd == "acceptance" && subcmd == "audit" => { + let options = parse_audit_options(rest)?; + run_acceptance_audit(&options) + } + [cmd, rest @ ..] if cmd == "package" => { + let options = parse_package_options(rest)?; + run_package(&options) + } + [cmd, suite, rest @ ..] if cmd == "test" && suite == "synthetic" => { + let options = parse_test_options(rest, PathBuf::from("testdata"))?; + run_stage_tests(options.stage) + } + [cmd, suite, rest @ ..] if cmd == "test" && suite == "licensed" => { + let options = parse_test_options(rest, PathBuf::from("testdata"))?; + validate_licensed_root(&options.root)?; + run_stage_tests(options.stage) + } + [cmd, subcmd, rest @ ..] if cmd == "corpus" && subcmd == "baseline" => { + let root = parse_root(rest)?; + let manifest = + discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?; + let report = report(&root, &manifest); + println!("{}", render_report_json(&report)); + Ok(()) + } + _ => Err( + "usage: cargo xtask ci | policy | acceptance report --suite synthetic|licensed [--stage 0..5|all] [--root testdata] [--out ] | acceptance audit [--roadmap ] [--coverage ] [--out ] [--strict] | package --target --app viewer|game|headless|cli | test synthetic|licensed [--stage 0..5|all] [--root testdata] | corpus baseline --root " + .to_string(), + ), + } +} + +fn cargo(args: &[&str]) -> Result<(), String> { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let status = Command::new(cargo) + .args(args) + .status() + .map_err(|err| format!("failed to run cargo: {err}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("cargo exited with {status}")) + } +} + +fn cargo_owned(args: &[String]) -> Result<(), String> { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let status = Command::new(cargo) + .args(args) + .status() + .map_err(|err| format!("failed to run cargo: {err}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("cargo exited with {status}")) + } +} + +fn clippy_rustup(args: &[&str]) -> Result<(), String> { + let rustup = std::env::var_os("RUSTUP").unwrap_or_else(|| "rustup".into()); + let status = Command::new(rustup) + .args(["run", "stable", "cargo-clippy"]) + .args(args) + .status() + .map_err(|err| format!("failed to run cargo-clippy through rustup: {err}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("cargo-clippy exited with {status}")) + } +} + +fn run_rustfmt_check(root: &Path) -> Result<(), String> { + let mut files = Vec::new(); + collect_rust_files(root, &mut files)?; + if files.is_empty() { + return Ok(()); + } + + let rustup = std::env::var_os("RUSTUP").unwrap_or_else(|| "rustup".into()); + let status = Command::new(rustup) + .args(["run", "stable", "rustfmt", "--check"]) + .args(files) + .status() + .map_err(|err| format!("failed to run rustfmt: {err}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("rustfmt exited with {status}")) + } +} + +fn collect_rust_files(dir: &Path, out: &mut Vec) -> Result<(), String> { + let entries = fs::read_dir(dir).map_err(|err| format!("{}: {err}", dir.display()))?; + for entry in entries { + let entry = entry.map_err(|err| format!("{}: {err}", dir.display()))?; + let path = entry.path(); + if should_skip_policy_path(&path) { + continue; + } + let file_type = entry + .file_type() + .map_err(|err| format!("{}: {err}", path.display()))?; + if file_type.is_dir() { + collect_rust_files(&path, out)?; + } else if file_type.is_file() + && path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext == "rs") + { + out.push(path); + } + } + Ok(()) +} + +fn validate_licensed_root(root: &Path) -> Result<(), String> { + for part in ["IS", "IS2"] { + let part_root = root.join(part); + if !part_root.is_dir() { + return Err(format!( + "licensed corpus part is missing: {}", + part_root.display() + )); + } + } + Ok(()) +} + +fn parse_root(args: &[String]) -> Result { + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if arg == "--root" { + return iter + .next() + .map(PathBuf::from) + .ok_or_else(|| "--root requires a path".to_string()); + } + } + Err("missing --root".to_string()) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PackageOptions { + target: String, + app: AppPackage, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AppPackage { + Cli, + Game, + Headless, + Viewer, +} + +impl AppPackage { + fn parse(value: &str) -> Result { + match value { + "cli" => Ok(Self::Cli), + "game" => Ok(Self::Game), + "headless" => Ok(Self::Headless), + "viewer" => Ok(Self::Viewer), + _ => Err(format!("unknown app: {value}")), + } + } + + fn package(self) -> &'static str { + match self { + Self::Cli => "fparkan-cli", + Self::Game => "fparkan-game", + Self::Headless => "fparkan-headless", + Self::Viewer => "fparkan-viewer", + } + } +} + +fn parse_package_options(args: &[String]) -> Result { + let mut target = None; + let mut app = None; + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--target" => { + target = Some( + iter.next() + .cloned() + .ok_or_else(|| "--target requires a value".to_string())?, + ); + } + "--app" => { + let value = iter + .next() + .ok_or_else(|| "--app requires a value".to_string())?; + app = Some(AppPackage::parse(value)?); + } + _ => return Err(format!("unknown package option: {arg}")), + } + } + Ok(PackageOptions { + target: target.ok_or_else(|| "missing --target".to_string())?, + app: app.ok_or_else(|| "missing --app".to_string())?, + }) +} + +fn run_package(options: &PackageOptions) -> Result<(), String> { + cargo_owned(&[ + "build".to_string(), + "-p".to_string(), + options.app.package().to_string(), + "--release".to_string(), + "--offline".to_string(), + "--target".to_string(), + options.target.clone(), + ]) +} + +fn run_policy(root: &Path) -> Result<(), String> { + let mut failures = Vec::new(); + scan_policy_dir(root, &mut failures)?; + validate_cargo_metadata(root, &mut failures)?; + validate_dependency_boundaries(root, &mut failures)?; + if failures.is_empty() { + Ok(()) + } else { + Err(format!("workspace policy failed:\n{}", failures.join("\n"))) + } +} + +fn validate_cargo_metadata(root: &Path, failures: &mut Vec) -> Result<(), String> { + let manifest = root.join("Cargo.toml"); + if !manifest.exists() { + return Ok(()); + } + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let output = Command::new(cargo) + .args([ + "metadata", + "--format-version", + "1", + "--offline", + "--no-deps", + "--manifest-path", + ]) + .arg(&manifest) + .output() + .map_err(|err| format!("failed to run cargo metadata: {err}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + failures.push(format!( + "{}: cargo metadata failed: {}", + manifest.display(), + stderr.trim() + )); + } + Ok(()) +} + +fn validate_dependency_boundaries(root: &Path, failures: &mut Vec) -> Result<(), String> { + let mut manifests = Vec::new(); + collect_cargo_manifests(root, &mut manifests)?; + for manifest in manifests { + let text = fs::read_to_string(&manifest) + .map_err(|err| format!("{}: {err}", manifest.display()))?; + let Some(package) = parse_package_name(&text) else { + continue; + }; + let dependencies = parse_manifest_dependencies(&text); + if is_domain_manifest(root, &manifest) { + for dependency in &dependencies { + if is_forbidden_domain_dependency(dependency) { + failures.push(format!( + "{}: domain package {package} depends on forbidden GUI/adapter package {dependency}", + manifest.display() + )); + } + } + } + if package == "fparkan-headless" { + for dependency in &dependencies { + if matches!( + dependency.as_str(), + "fparkan-platform-sdl" | "fparkan-render-gl" + ) { + failures.push(format!( + "{}: fparkan-headless depends on forbidden platform/render adapter {dependency}", + manifest.display() + )); + } + } + } + } + Ok(()) +} + +fn collect_cargo_manifests(dir: &Path, out: &mut Vec) -> Result<(), String> { + let entries = fs::read_dir(dir).map_err(|err| format!("{}: {err}", dir.display()))?; + for entry in entries { + let entry = entry.map_err(|err| format!("{}: {err}", dir.display()))?; + let path = entry.path(); + if should_skip_policy_path(&path) { + continue; + } + let file_type = entry + .file_type() + .map_err(|err| format!("{}: {err}", path.display()))?; + if file_type.is_dir() { + collect_cargo_manifests(&path, out)?; + } else if file_type.is_file() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "Cargo.toml") + { + out.push(path); + } + } + Ok(()) +} + +fn parse_package_name(manifest: &str) -> Option { + let mut in_package = false; + for line in manifest.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_package = trimmed == "[package]"; + continue; + } + if in_package && trimmed.starts_with("name") { + return parse_toml_string_value(trimmed); + } + } + None +} + +fn parse_manifest_dependencies(manifest: &str) -> BTreeSet { + let mut dependencies = BTreeSet::new(); + let mut in_dependency_section = false; + for line in manifest.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_dependency_section = matches!( + trimmed, + "[dependencies]" | "[dev-dependencies]" | "[build-dependencies]" + ); + continue; + } + if !in_dependency_section || trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let Some((name, _)) = trimmed.split_once('=') else { + continue; + }; + let dependency = name.trim().trim_matches('"'); + if !dependency.is_empty() { + dependencies.insert(dependency.to_string()); + } + } + dependencies +} + +fn parse_toml_string_value(line: &str) -> Option { + let (_, value) = line.split_once('=')?; + let value = value.trim(); + if !(value.starts_with('"') && value.ends_with('"')) { + return None; + } + Some(value.trim_matches('"').to_string()) +} + +fn is_domain_manifest(root: &Path, manifest: &Path) -> bool { + let relative = manifest.strip_prefix(root).unwrap_or(manifest); + relative + .components() + .next() + .is_some_and(|component| component.as_os_str() == "crates") +} + +fn is_forbidden_domain_dependency(dependency: &str) -> bool { + matches!( + dependency, + "fparkan-platform-sdl" + | "fparkan-render-gl" + | "fparkan-cli" + | "fparkan-game" + | "fparkan-headless" + | "fparkan-viewer" + | "sdl2" + | "gl" + | "glow" + | "glium" + | "glutin" + | "winit" + ) +} + +fn scan_policy_dir(dir: &Path, failures: &mut Vec) -> Result<(), String> { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(err) => return Err(format!("{}: {err}", dir.display())), + }; + for entry in entries { + let entry = entry.map_err(|err| format!("{}: {err}", dir.display()))?; + let path = entry.path(); + if should_skip_policy_path(&path) { + continue; + } + let file_type = entry + .file_type() + .map_err(|err| format!("{}: {err}", path.display()))?; + if file_type.is_dir() { + if is_forbidden_generic_crate_dir(&path) { + failures.push(format!( + "{}: package under crates/ must use the fparkan-* prefix", + path.display() + )); + } + scan_policy_dir(&path, failures)?; + } else if file_type.is_file() { + scan_repository_file_policy(&path, failures)?; + if is_policy_source(&path) { + scan_policy_file(&path, failures)?; + } + } + } + Ok(()) +} + +fn should_skip_policy_path(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + matches!( + name, + ".git" | "target" | "testdata" | ".idea" | ".vscode" | ".DS_Store" + ) + }) +} + +fn is_policy_source(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| matches!(ext, "rs" | "toml")) +} + +fn is_forbidden_generic_crate_dir(path: &Path) -> bool { + path.parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "crates") + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| !name.starts_with("fparkan-")) +} + +fn scan_repository_file_policy(path: &Path, failures: &mut Vec) -> Result<(), String> { + if path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext == "py") + { + failures.push(format!( + "{}: Python source file is forbidden", + path.display() + )); + } + + let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?; + if bytes.starts_with(b"#!") { + let first_line = bytes + .split(|byte| *byte == b'\n') + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + if first_line + .windows("python".len()) + .any(|window| window == b"python") + { + failures.push(format!("{}: Python shebang is forbidden", path.display())); + } + } + if is_workflow_file(path) { + let text = String::from_utf8_lossy(&bytes).to_ascii_lowercase(); + if text.contains("python") { + failures.push(format!("{}: Python CI step is forbidden", path.display())); + } + } + Ok(()) +} + +fn is_workflow_file(path: &Path) -> bool { + let mut previous = None; + for component in path.components() { + let name = component.as_os_str().to_string_lossy(); + if previous.as_deref() == Some(".github") && name == "workflows" { + return true; + } + previous = Some(name.into_owned()); + } + false +} + +fn scan_policy_file(path: &Path, failures: &mut Vec) -> Result<(), String> { + let text = fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?; + let lower = text.to_ascii_lowercase(); + if lower.contains(concat!("app.", "notion.com")) || lower.contains(concat!("385e", "79f2")) { + failures.push(format!( + "{}: external knowledge-base reference in source", + path.display() + )); + } + for (index, line) in text.lines().enumerate() { + let trimmed = line.trim_start(); + if trimmed.starts_with("//") || trimmed.starts_with("//!") || trimmed.starts_with("///") { + continue; + } + if contains_unsafe_construct(trimmed) { + failures.push(format!( + "{}:{}: unsafe construct in workspace source", + path.display(), + index + 1 + )); + } + } + Ok(()) +} + +fn contains_unsafe_construct(line: &str) -> bool { + line.contains(concat!("un", "safe {")) + || line.contains(concat!("un", "safe fn")) + || line.contains(concat!("un", "safe impl")) + || line.contains(concat!("extern ", "\"C\"")) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Stage { + All, + Number(u8), +} + +const ALL_WORKSPACE_PACKAGES: &[&str] = &[ + "fparkan-animation", + "fparkan-assets", + "fparkan-binary", + "fparkan-corpus", + "fparkan-diagnostics", + "fparkan-fx", + "fparkan-material", + "fparkan-mission-format", + "fparkan-msh", + "fparkan-nres", + "fparkan-path", + "fparkan-platform", + "fparkan-prototype", + "fparkan-render", + "fparkan-resource", + "fparkan-rsli", + "fparkan-runtime", + "fparkan-terrain", + "fparkan-terrain-format", + "fparkan-test-support", + "fparkan-texm", + "fparkan-vfs", + "fparkan-world", + "fparkan-platform-sdl", + "fparkan-render-gl", + "fparkan-cli", + "fparkan-game", + "fparkan-headless", + "fparkan-viewer", + "xtask", +]; + +impl Stage { + fn parse(value: &str) -> Result { + if value == "all" { + return Ok(Self::All); + } + let stage = value + .parse::() + .map_err(|_| format!("invalid stage: {value}"))?; + if stage <= 5 { + Ok(Self::Number(stage)) + } else { + Err(format!("stage out of range: {stage}")) + } + } +} + +impl fmt::Display for Stage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::All => f.write_str("all"), + Self::Number(stage) => write!(f, "{stage}"), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct TestOptions { + stage: Stage, + root: PathBuf, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TestSuite { + Licensed, + Synthetic, +} + +impl TestSuite { + fn parse(value: &str) -> Result { + match value { + "licensed" => Ok(Self::Licensed), + "synthetic" => Ok(Self::Synthetic), + _ => Err(format!("unknown suite: {value}")), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Licensed => "licensed", + Self::Synthetic => "synthetic", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct AcceptanceOptions { + suite: TestSuite, + stage: Stage, + root: PathBuf, + out: PathBuf, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct AuditOptions { + roadmap: PathBuf, + coverage: PathBuf, + out: PathBuf, + strict: bool, +} + +fn parse_test_options(args: &[String], default_root: PathBuf) -> Result { + let mut options = TestOptions { + stage: Stage::All, + root: default_root, + }; + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--stage" => { + let value = iter + .next() + .ok_or_else(|| "--stage requires a value".to_string())?; + options.stage = Stage::parse(value)?; + } + "--root" => { + let value = iter + .next() + .ok_or_else(|| "--root requires a path".to_string())?; + options.root = PathBuf::from(value); + } + _ => return Err(format!("unknown test option: {arg}")), + } + } + Ok(options) +} + +fn parse_acceptance_options(args: &[String]) -> Result { + let mut suite = None; + let mut stage = Stage::All; + let mut root = PathBuf::from("testdata"); + let mut out = None; + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--suite" => { + let value = iter + .next() + .ok_or_else(|| "--suite requires a value".to_string())?; + suite = Some(TestSuite::parse(value)?); + } + "--stage" => { + let value = iter + .next() + .ok_or_else(|| "--stage requires a value".to_string())?; + stage = Stage::parse(value)?; + } + "--root" => { + let value = iter + .next() + .ok_or_else(|| "--root requires a path".to_string())?; + root = PathBuf::from(value); + } + "--out" => { + let value = iter + .next() + .ok_or_else(|| "--out requires a path".to_string())?; + out = Some(PathBuf::from(value)); + } + _ => return Err(format!("unknown acceptance option: {arg}")), + } + } + + let suite = suite.ok_or_else(|| "missing --suite".to_string())?; + let out = out.unwrap_or_else(|| { + PathBuf::from("target") + .join("fparkan") + .join("reports") + .join("acceptance") + .join(format!("{}-stage-{}.json", suite.as_str(), stage)) + }); + Ok(AcceptanceOptions { + suite, + stage, + root, + out, + }) +} + +fn parse_audit_options(args: &[String]) -> Result { + let mut roadmap = PathBuf::from("FPARKAN_ARCHITECTURE_ROADMAP_STAGES_0_5.md"); + let mut coverage = PathBuf::from("fixtures/acceptance/coverage.tsv"); + let mut out = PathBuf::from("target") + .join("fparkan") + .join("reports") + .join("acceptance") + .join("coverage-audit.json"); + let mut strict = false; + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--roadmap" => { + let value = iter + .next() + .ok_or_else(|| "--roadmap requires a path".to_string())?; + roadmap = PathBuf::from(value); + } + "--coverage" => { + let value = iter + .next() + .ok_or_else(|| "--coverage requires a path".to_string())?; + coverage = PathBuf::from(value); + } + "--out" => { + let value = iter + .next() + .ok_or_else(|| "--out requires a path".to_string())?; + out = PathBuf::from(value); + } + "--strict" => strict = true, + _ => return Err(format!("unknown audit option: {arg}")), + } + } + Ok(AuditOptions { + roadmap, + coverage, + out, + strict, + }) +} + +fn run_acceptance_audit(options: &AuditOptions) -> Result<(), String> { + let roadmap_text = fs::read_to_string(&options.roadmap) + .map_err(|err| format!("{}: {err}", options.roadmap.display()))?; + let required = extract_acceptance_ids(&roadmap_text); + let coverage = read_coverage_manifest(&options.coverage)?; + let audit = build_acceptance_audit(&required, &coverage); + if let Some(parent) = options.out.parent() { + fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?; + } + fs::write(&options.out, render_audit_json(&audit)) + .map_err(|err| format!("{}: {err}", options.out.display()))?; + println!("{}", options.out.display()); + let unverified = audit.unverified(); + if options.strict && (!unverified.is_empty() || !audit.unknown_coverage.is_empty()) { + Err(format!( + "acceptance coverage incomplete: {} unverified, {} unknown", + unverified.len(), + audit.unknown_coverage.len() + )) + } else { + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct CoverageEntry { + status: CoverageStatus, + evidence: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CoverageStatus { + Covered, + Partial, + Blocked, + Omitted, +} + +impl CoverageStatus { + fn parse(value: &str) -> Result { + match value { + "covered" => Ok(Self::Covered), + "partial" => Ok(Self::Partial), + "blocked" => Ok(Self::Blocked), + "omitted" => Ok(Self::Omitted), + _ => Err(format!("unknown coverage status: {value}")), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct AcceptanceAudit { + required_total: usize, + covered: Vec, + partial: Vec, + blocked: Vec, + omitted: Vec, + missing: Vec, + unknown_coverage: Vec, + coverage_evidence: BTreeMap, + by_stage: BTreeMap, +} + +impl AcceptanceAudit { + fn unverified(&self) -> Vec { + self.partial + .iter() + .chain(&self.blocked) + .chain(&self.missing) + .cloned() + .collect() + } +} + +fn extract_acceptance_ids(text: &str) -> BTreeSet { + let mut ids = BTreeSet::new(); + for segment in text.split('`') { + if is_acceptance_id(segment) { + ids.insert(segment.to_string()); + } + } + ids +} + +fn is_acceptance_id(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() >= 9 + && matches!(bytes[0], b'S' | b'L') + && matches!(bytes[1], b'0'..=b'5') + && bytes[2] == b'-' + && bytes.iter().all(|byte| { + byte.is_ascii_uppercase() || byte.is_ascii_digit() || *byte == b'-' || *byte == b'_' + }) +} + +fn read_coverage_manifest(path: &Path) -> Result, String> { + if !path.exists() { + return Ok(BTreeMap::new()); + } + let text = fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?; + let mut entries = BTreeMap::new(); + for (line_number, line) in text.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let mut fields = trimmed.splitn(3, '\t'); + let id = fields + .next() + .ok_or_else(|| format!("{}:{}: missing id", path.display(), line_number + 1))?; + let status = fields + .next() + .ok_or_else(|| format!("{}:{}: missing status", path.display(), line_number + 1))?; + let evidence = fields + .next() + .ok_or_else(|| format!("{}:{}: missing evidence", path.display(), line_number + 1))?; + if evidence.trim().is_empty() { + return Err(format!( + "{}:{}: empty evidence", + path.display(), + line_number + 1 + )); + } + if !is_acceptance_id(id) { + return Err(format!( + "{}:{}: invalid acceptance id: {id}", + path.display(), + line_number + 1 + )); + } + entries.insert( + id.to_string(), + CoverageEntry { + status: CoverageStatus::parse(status)?, + evidence: evidence.to_string(), + }, + ); + } + Ok(entries) +} + +fn build_acceptance_audit( + required: &BTreeSet, + coverage: &BTreeMap, +) -> AcceptanceAudit { + let mut covered = Vec::new(); + let mut partial = Vec::new(); + let mut blocked = Vec::new(); + let mut omitted = Vec::new(); + let mut missing = Vec::new(); + let mut by_stage = BTreeMap::new(); + let mut coverage_evidence = BTreeMap::new(); + + for id in required { + let stage = id + .get(0..2) + .map_or_else(|| "??".to_string(), ToString::to_string); + *by_stage.entry(stage).or_insert(0) += 1; + match coverage.get(id).map(|entry| entry.status) { + Some(CoverageStatus::Covered) => covered.push(id.clone()), + Some(CoverageStatus::Partial) => partial.push(id.clone()), + Some(CoverageStatus::Blocked) => blocked.push(id.clone()), + Some(CoverageStatus::Omitted) => omitted.push(id.clone()), + None => missing.push(id.clone()), + } + if let Some(entry) = coverage.get(id) { + coverage_evidence.insert(id.clone(), entry.evidence.clone()); + } + } + + let unknown_coverage = coverage + .keys() + .filter(|id| !required.contains(*id)) + .cloned() + .collect(); + + AcceptanceAudit { + required_total: required.len(), + covered, + partial, + blocked, + omitted, + missing, + unknown_coverage, + coverage_evidence, + by_stage, + } +} + +fn render_audit_json(audit: &AcceptanceAudit) -> String { + let unverified = audit.unverified(); + format!( + concat!( + "{{\n", + " \"schema_version\": \"fparkan-acceptance-coverage-v1\",\n", + " \"required_total\": {},\n", + " \"covered_total\": {},\n", + " \"partial_total\": {},\n", + " \"blocked_total\": {},\n", + " \"omitted_total\": {},\n", + " \"missing_total\": {},\n", + " \"unverified_total\": {},\n", + " \"unknown_coverage_total\": {},\n", + " \"by_stage\": {},\n", + " \"covered\": {},\n", + " \"partial\": {},\n", + " \"blocked\": {},\n", + " \"omitted\": {},\n", + " \"missing\": {},\n", + " \"unknown_coverage\": {},\n", + " \"coverage_evidence\": {}\n", + "}}\n" + ), + audit.required_total, + audit.covered.len(), + audit.partial.len(), + audit.blocked.len(), + audit.omitted.len(), + audit.missing.len(), + unverified.len(), + audit.unknown_coverage.len(), + render_string_usize_map(&audit.by_stage), + render_string_array(&audit.covered), + render_string_array(&audit.partial), + render_string_array(&audit.blocked), + render_string_array(&audit.omitted), + render_string_array(&audit.missing), + render_string_array(&audit.unknown_coverage), + render_string_string_map(&audit.coverage_evidence) + ) +} + +fn render_string_usize_map(values: &BTreeMap) -> String { + let pairs = values + .iter() + .map(|(key, value)| format!("\"{}\": {}", json_escape(key), value)) + .collect::>() + .join(", "); + format!("{{{pairs}}}") +} + +fn render_string_string_map(values: &BTreeMap) -> String { + let pairs = values + .iter() + .map(|(key, value)| format!("\"{}\": \"{}\"", json_escape(key), json_escape(value))) + .collect::>() + .join(", "); + format!("{{{pairs}}}") +} + +fn render_string_array(values: &[String]) -> String { + let items = values + .iter() + .map(|value| format!("\"{}\"", json_escape(value))) + .collect::>() + .join(", "); + format!("[{items}]") +} + +fn json_escape(value: &str) -> String { + let mut out = String::new(); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + ch if ch.is_control() => { + let _ = write!(out, "\\u{:04x}", ch as u32); + } + ch => out.push(ch), + } + } + out +} + +fn run_acceptance_report(options: &AcceptanceOptions) -> Result<(), String> { + if options.suite == TestSuite::Licensed { + validate_licensed_root(&options.root)?; + } + run_stage_tests(options.stage)?; + + if let Some(parent) = options.out.parent() { + fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?; + } + let report = render_acceptance_report(options); + fs::write(&options.out, report).map_err(|err| format!("{}: {err}", options.out.display()))?; + println!("{}", options.out.display()); + Ok(()) +} + +fn render_acceptance_report(options: &AcceptanceOptions) -> String { + let packages = stage_report_packages(options.stage) + .into_iter() + .map(|package| format!(" \"{package}\"")) + .collect::>() + .join(",\n"); + let corpus = if options.suite == TestSuite::Licensed { + "\n \"licensed_corpus\": {\n \"root\": \"redacted\",\n \"parts\": [\"IS\", \"IS2\"]\n }," + } else { + "" + }; + format!( + concat!( + "{{\n", + " \"schema_version\": \"fparkan-acceptance-report-v1\",\n", + " \"suite\": \"{}\",\n", + " \"stage\": \"{}\",\n", + " \"status\": \"passed\",", + "{}\n", + " \"packages\": [\n", + "{}\n", + " ]\n", + "}}\n" + ), + options.suite.as_str(), + options.stage, + corpus, + packages + ) +} + +fn stage_report_packages(stage: Stage) -> Vec<&'static str> { + match stage { + Stage::All => ALL_WORKSPACE_PACKAGES.to_vec(), + Stage::Number(number) => stage_packages(number).unwrap_or(&[]).to_vec(), + } +} + +fn run_stage_tests(stage: Stage) -> Result<(), String> { + match stage { + Stage::All => cargo(&["test", "--workspace", "--offline"]), + Stage::Number(number) => { + for package in stage_packages(number)? { + cargo(&["test", "-p", package, "--offline"])?; + } + Ok(()) + } + } +} + +fn stage_packages(stage: u8) -> Result<&'static [&'static str], String> { + match stage { + 0 => Ok(&[ + "fparkan-corpus", + "fparkan-diagnostics", + "fparkan-test-support", + ]), + 1 => Ok(&[ + "fparkan-binary", + "fparkan-path", + "fparkan-nres", + "fparkan-rsli", + "fparkan-resource", + "fparkan-vfs", + ]), + 2 => Ok(&["fparkan-prototype"]), + 3 => Ok(&[ + "fparkan-msh", + "fparkan-material", + "fparkan-texm", + "fparkan-assets", + "fparkan-render", + "fparkan-viewer", + ]), + 4 => Ok(&["fparkan-animation", "fparkan-fx"]), + 5 => Ok(&[ + "fparkan-terrain-format", + "fparkan-terrain", + "fparkan-mission-format", + "fparkan-world", + "fparkan-runtime", + "fparkan-headless", + "fparkan-game", + ]), + _ => Err(format!("stage out of range: {stage}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn parses_stage_and_root_options() { + let args = strings(&["--stage", "3", "--root", "fixtures"]); + let parsed = parse_test_options(&args, PathBuf::from("testdata")); + + assert_eq!( + parsed, + Ok(TestOptions { + stage: Stage::Number(3), + root: PathBuf::from("fixtures"), + }) + ); + } + + #[test] + fn parses_acceptance_report_options() { + let parsed = parse_acceptance_options(&strings(&[ + "--suite", + "licensed", + "--stage", + "5", + "--root", + "testdata", + "--out", + "target/report.json", + ])); + + assert_eq!( + parsed, + Ok(AcceptanceOptions { + suite: TestSuite::Licensed, + stage: Stage::Number(5), + root: PathBuf::from("testdata"), + out: PathBuf::from("target/report.json"), + }) + ); + } + + #[test] + fn acceptance_report_redacts_licensed_root() { + let options = AcceptanceOptions { + suite: TestSuite::Licensed, + stage: Stage::Number(0), + root: PathBuf::from("/private/game"), + out: PathBuf::from("target/report.json"), + }; + let report = render_acceptance_report(&options); + + assert!(report.contains("\"root\": \"redacted\"")); + assert!(!report.contains("/private/game")); + assert!(report.contains("\"fparkan-corpus\"")); + } + + #[test] + fn extracts_acceptance_ids_from_backticks_only() { + let ids = + extract_acceptance_ids("`S0-ARCH-001` text S0-ARCH-002 `L5-P1-MISSION-001` `bad`"); + + assert!(ids.contains("S0-ARCH-001")); + assert!(ids.contains("L5-P1-MISSION-001")); + assert!(!ids.contains("S0-ARCH-002")); + assert_eq!(ids.len(), 2); + } + + #[test] + fn builds_acceptance_audit_counts() { + let required = ["S0-ARCH-001", "S0-ARCH-002", "L3-DEVICE-001", "L5-RG40-001"] + .into_iter() + .map(str::to_string) + .collect::>(); + let coverage = [ + ( + "S0-ARCH-001".to_string(), + CoverageEntry { + status: CoverageStatus::Covered, + evidence: "cargo xtask policy".to_string(), + }, + ), + ( + "L3-DEVICE-001".to_string(), + CoverageEntry { + status: CoverageStatus::Omitted, + evidence: "outside macos scope".to_string(), + }, + ), + ( + "L5-RG40-001".to_string(), + CoverageEntry { + status: CoverageStatus::Blocked, + evidence: "device not attached".to_string(), + }, + ), + ( + "S9-UNKNOWN-001".to_string(), + CoverageEntry { + status: CoverageStatus::Partial, + evidence: "bad id".to_string(), + }, + ), + ] + .into_iter() + .collect::>(); + + let audit = build_acceptance_audit(&required, &coverage); + + assert_eq!(audit.covered, ["S0-ARCH-001"]); + assert_eq!(audit.blocked, ["L5-RG40-001"]); + assert_eq!(audit.omitted, ["L3-DEVICE-001"]); + assert_eq!(audit.missing, ["S0-ARCH-002"]); + assert_eq!(audit.unknown_coverage, ["S9-UNKNOWN-001"]); + assert_eq!(audit.by_stage.get("S0"), Some(&2)); + } + + #[test] + fn audit_json_escapes_evidence() { + let mut audit = AcceptanceAudit { + required_total: 1, + covered: vec!["S0-ARCH-001".to_string()], + partial: Vec::new(), + blocked: Vec::new(), + omitted: Vec::new(), + missing: Vec::new(), + unknown_coverage: Vec::new(), + coverage_evidence: BTreeMap::new(), + by_stage: BTreeMap::new(), + }; + audit + .coverage_evidence + .insert("S0-ARCH-001".to_string(), "quoted \"value\"".to_string()); + + let json = render_audit_json(&audit); + + assert!(json.contains("quoted \\\"value\\\"")); + } + + #[test] + fn defaults_to_all_stage_and_testdata_root() { + let args = Vec::new(); + let parsed = parse_test_options(&args, PathBuf::from("testdata")); + + assert_eq!( + parsed, + Ok(TestOptions { + stage: Stage::All, + root: PathBuf::from("testdata"), + }) + ); + } + + #[test] + fn rejects_unknown_stage() { + assert_eq!(Stage::parse("6"), Err("stage out of range: 6".to_string())); + assert_eq!( + Stage::parse("assets"), + Err("invalid stage: assets".to_string()) + ); + } + + #[test] + fn maps_stage_packages() { + assert!(stage_packages(3).is_ok_and(|packages| packages.contains(&"fparkan-assets"))); + assert!(stage_packages(3).is_ok_and(|packages| packages.contains(&"fparkan-viewer"))); + assert!(stage_packages(5).is_ok_and(|packages| packages.contains(&"fparkan-runtime"))); + assert!(stage_packages(5).is_ok_and(|packages| packages.contains(&"fparkan-game"))); + assert_eq!(stage_packages(9), Err("stage out of range: 9".to_string())); + } + + #[test] + fn parses_manifest_dependencies_for_arch_policy() { + let manifest = r#" +[package] +name = "fparkan-example" + +[dependencies] +fparkan-render = { path = "../fparkan-render" } +"quoted-dep" = "1" + +[dev-dependencies] +fparkan-render-gl = { path = "../../adapters/fparkan-render-gl" } +"#; + + assert_eq!( + parse_package_name(manifest), + Some("fparkan-example".to_string()) + ); + let deps = parse_manifest_dependencies(manifest); + assert!(deps.contains("fparkan-render")); + assert!(deps.contains("quoted-dep")); + assert!(deps.contains("fparkan-render-gl")); + } + + #[test] + fn detects_forbidden_domain_dependencies() { + assert!(is_forbidden_domain_dependency("fparkan-render-gl")); + assert!(is_forbidden_domain_dependency("sdl2")); + assert!(!is_forbidden_domain_dependency("fparkan-render")); + assert!(!is_forbidden_domain_dependency("fparkan-platform")); + } + + #[test] + fn parses_package_options() { + assert_eq!( + parse_package_options(&strings(&[ + "--target", + "aarch64-apple-darwin", + "--app", + "viewer" + ])), + Ok(PackageOptions { + target: "aarch64-apple-darwin".to_string(), + app: AppPackage::Viewer, + }) + ); + assert_eq!( + parse_package_options(&strings(&["--target", "x", "--app", "bad"])), + Err("unknown app: bad".to_string()) + ); + } + + #[test] + fn app_packages_map_to_cargo_packages() { + assert_eq!(AppPackage::Cli.package(), "fparkan-cli"); + assert_eq!(AppPackage::Game.package(), "fparkan-game"); + assert_eq!(AppPackage::Headless.package(), "fparkan-headless"); + assert_eq!(AppPackage::Viewer.package(), "fparkan-viewer"); + } + + #[test] + fn policy_source_detection_is_scoped_to_code_files() { + assert!(is_policy_source(Path::new("src/main.rs"))); + assert!(is_policy_source(Path::new("Cargo.toml"))); + assert!(!is_policy_source(Path::new("README.md"))); + assert!(should_skip_policy_path(Path::new("target"))); + assert!(should_skip_policy_path(Path::new("testdata"))); + assert!(!should_skip_policy_path(Path::new("crates/experimental"))); + assert!(!should_skip_policy_path(Path::new("crates/fparkan-render"))); + } + + #[test] + fn unsafe_construct_detector_ignores_lints_and_comments() { + assert!(contains_unsafe_construct(concat!( + "un", + "safe fn call() {}" + ))); + assert!(contains_unsafe_construct(concat!( + "let value = un", + "safe { call() };" + ))); + assert!(!contains_unsafe_construct("#![forbid(unsafe_code)]")); + } +} -- cgit v1.2.3