Compare commits
27
Commits
master
..
d0bdbaa1ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0bdbaa1ed
|
||
|
|
7416fdc7e9 | ||
|
|
78fc5f1deb
|
||
|
|
50c2cf4686
|
||
|
|
a63290fbc8 | ||
|
|
96a25b6c0e | ||
|
|
f4262cf369 | ||
|
|
9b100b8fc3 | ||
|
|
9fceeb9a0a | ||
|
|
4b7f1a16b9 | ||
|
|
ada3b903ad
|
||
|
|
31d849ddbf | ||
|
|
4ef08d0bf6 | ||
|
|
598137ed13
|
||
|
|
cb0ca2f2f0
|
||
|
|
7346e695c4
|
||
|
|
bb827c3928
|
||
|
|
efab61a45c
|
||
|
|
0d7ae6a017 | ||
|
|
a281ffa32e | ||
|
|
18d4c6cf9f | ||
|
|
0e19660eb5 | ||
|
|
8a69872576
|
||
|
|
aa68906a3d
|
||
|
|
8bf3b7b209
|
||
|
|
669fb40a70
|
||
|
|
9c0df3d299
|
@@ -0,0 +1,2 @@
|
||||
[alias]
|
||||
xtask = "run -p xtask --"
|
||||
@@ -3,7 +3,7 @@ name: Docs Deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- devel
|
||||
|
||||
jobs:
|
||||
deploy-docs:
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Run renovate
|
||||
run: |
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
@@ -21,7 +21,35 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Cargo test
|
||||
run: cargo test --workspace --all-features -- --nocapture
|
||||
|
||||
render-parity:
|
||||
name: Render parity
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install headless GL runtime
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y xvfb libgl1-mesa-dri libgles2-mesa-dev mesa-utils
|
||||
- name: Build render-demo binary
|
||||
run: cargo build -p render-demo --features demo
|
||||
- name: Run frame parity suite
|
||||
run: |
|
||||
xvfb-run -s "-screen 0 1280x720x24" cargo run -p render-parity -- \
|
||||
--manifest parity/cases.toml \
|
||||
--output-dir target/render-parity/current \
|
||||
--demo-bin target/debug/parkan-render-demo \
|
||||
--keep-going
|
||||
- name: Upload parity artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: render-parity-artifacts
|
||||
path: target/render-parity/current
|
||||
if-no-files-found: ignore
|
||||
|
||||
+57
-1
@@ -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
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
# FParkan
|
||||
|
||||
Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»** и набором [вспомогательных инструментов](tools) для исследования.
|
||||
Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»**.
|
||||
|
||||
## Описание
|
||||
|
||||
Проект находится в активной разработке и включает:
|
||||
|
||||
- библиотеки для работы с форматами игровых архивов;
|
||||
- инструменты для валидации/подготовки тестовых данных;
|
||||
- спецификации форматов и сопутствующую документацию.
|
||||
|
||||
## Установка
|
||||
@@ -19,28 +18,34 @@ Open source проект с реализацией компонентов игр
|
||||
- локально: каталог [`docs/`](docs)
|
||||
- сайт: <https://fparkan.popov.link>
|
||||
|
||||
## Инструменты
|
||||
|
||||
Вспомогательные инструменты находятся в каталоге [`tools/`](tools).
|
||||
|
||||
- [tools/archive_roundtrip_validator.py](tools/archive_roundtrip_validator.py) — инструмент верификации документации по архивам `NRes`/`RsLi` на реальных файлах (включая `unpack -> repack -> byte-compare`).
|
||||
- [tools/init_testdata.py](tools/init_testdata.py) — подготовка тестовых данных по сигнатурам с раскладкой по каталогам.
|
||||
|
||||
## Библиотеки
|
||||
|
||||
- [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
|
||||
```
|
||||
|
||||
Для дополнительного тестирования на реальных игровых ресурсах:
|
||||
|
||||
- используйте [tools/init_testdata.py](tools/init_testdata.py) для подготовки локального набора;
|
||||
- используйте оригинальную копию игры (диск или [GOG-версия](https://www.gog.com/en/game/parkan_iron_strategy));
|
||||
- разместите игровые каталоги в [`testdata/`](testdata);
|
||||
- игровые ресурсы в репозиторий не включаются, так как защищены авторским правом.
|
||||
|
||||
Локальный licensed gate:
|
||||
|
||||
```bash
|
||||
cargo xtask acceptance report --suite licensed --stage 5 --root testdata
|
||||
```
|
||||
|
||||
## Contributing & Support
|
||||
|
||||
Проект активно поддерживается и открыт для contribution. Issues и pull requests можно создавать в обоих репозиториях:
|
||||
|
||||
@@ -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
|
||||
@@ -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<GraphicsContextRequest>,
|
||||
/// 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<PlatformEvent>,
|
||||
}
|
||||
|
||||
impl SdlEventSourceProof {
|
||||
/// Creates an event source with deterministic pending events.
|
||||
#[must_use]
|
||||
pub fn new(pending: Vec<PlatformEvent>) -> Self {
|
||||
Self { pending }
|
||||
}
|
||||
}
|
||||
|
||||
impl EventSource for SdlEventSourceProof {
|
||||
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError> {
|
||||
out.append(&mut self.pending);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe window-port proof with SDL-compatible drawable-size semantics.
|
||||
#[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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<GlProfile>,
|
||||
/// 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<Vec<u8>>,
|
||||
}
|
||||
|
||||
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<u8>] {
|
||||
&self.captures
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderBackend for SafeGlCommandBackend {
|
||||
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
|
||||
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,
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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<String> = 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<Vec<String>, 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<PathBuf, String> {
|
||||
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<PathBuf, String> {
|
||||
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<String, String> {
|
||||
parse_option(args, names).ok_or_else(|| format!("missing {label}"))
|
||||
}
|
||||
|
||||
fn parse_option(args: &[String], names: &[&str]) -> Option<String> {
|
||||
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<bool>,
|
||||
) -> 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<PathBuf, String> {
|
||||
match args {
|
||||
[path] => Ok(PathBuf::from(path)),
|
||||
[flag, path] if flag == "--file" => Ok(PathBuf::from(path)),
|
||||
_ => Err("archive inspect requires <file> or --file <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 <path> [--format json] | archive inspect <file> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph --root <path> --mission <path> [--format json]".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn strings(values: &[&str]) -> Vec<String> {
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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::<Vec<_>>();
|
||||
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<String, String> {
|
||||
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<Self, String> {
|
||||
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 <path> --mission <path> [--frames <n>]".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fparkan_world::{ObjectHandle, StateHash, Tick};
|
||||
use std::path::Path;
|
||||
|
||||
fn strings(values: &[&str]) -> Vec<String> {
|
||||
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<String> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<String> = 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<PathBuf>,
|
||||
mission: Option<String>,
|
||||
ticks: u64,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse(args: &[String]) -> Result<Self, String> {
|
||||
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 <path> --mission <path>] [--ticks <n>]".to_string()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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::<Vec<_>>();
|
||||
let code = match run(&args) {
|
||||
Ok(json) => {
|
||||
println!("{json}");
|
||||
0
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("{err}");
|
||||
2
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<ResourceQuery, String> {
|
||||
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<Arc<[u8]>, 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<PathBuf, String> {
|
||||
parse_path_option(args, &["--file"], "--file")
|
||||
}
|
||||
|
||||
fn parse_limit(args: &[String]) -> Result<usize, String> {
|
||||
parse_option(args, &["--limit"])
|
||||
.map(|value| {
|
||||
value
|
||||
.parse::<usize>()
|
||||
.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<PathBuf, String> {
|
||||
parse_option(args, names)
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| format!("missing {label}"))
|
||||
}
|
||||
|
||||
fn parse_option(args: &[String], names: &[&str]) -> Option<String> {
|
||||
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 <archive> [--limit N] | model --root <game-root> --archive <archive> --name <msh> | model --fixture synthetic/model-basic | texture --root <game-root> --archive <archive> --name <texm> | map --file <Land.msh|Land.map> --kind land-msh|land-map".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn strings(values: &[&str]) -> Vec<String> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
[package]
|
||||
name = "common"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -1,44 +0,0 @@
|
||||
use std::io;
|
||||
|
||||
/// Resource payload that can be either borrowed from mapped bytes or owned.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ResourceData<'a> {
|
||||
Borrowed(&'a [u8]),
|
||||
Owned(Vec<u8>),
|
||||
}
|
||||
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
fn write_exact(&mut self, data: &[u8]) -> io::Result<()> {
|
||||
self.clear();
|
||||
self.extend_from_slice(data);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "fparkan-animation"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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<T> {
|
||||
raw: u64,
|
||||
marker: PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<T> AssetId<T> {
|
||||
/// 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<PreparedVisual>,
|
||||
/// Optional mesh resource backing the visual.
|
||||
pub mesh: Option<ResourceKey>,
|
||||
/// 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<T> {
|
||||
/// 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<Arc<T>, AssetError>;
|
||||
}
|
||||
|
||||
/// Minimal asset manager façade over an immutable resource repository.
|
||||
#[derive(Debug)]
|
||||
pub struct AssetManager<R> {
|
||||
repository: R,
|
||||
}
|
||||
|
||||
impl<R> AssetManager<R> {
|
||||
/// 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<R: ResourceRepository> AssetManager<R> {
|
||||
/// 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<PreparedVisual, AssetError> {
|
||||
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<Item = &'a EffectivePrototype>,
|
||||
) -> Result<MissionAssetPlan, AssetError> {
|
||||
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<Item = &'a EffectivePrototype>,
|
||||
) -> Result<MissionAssetPlan, AssetError> {
|
||||
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<PreparedVisual, AssetError> {
|
||||
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<R: ResourceRepository>(
|
||||
repository: &R,
|
||||
proto: &EffectivePrototype,
|
||||
) -> Result<PreparedVisual, AssetError> {
|
||||
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<R: ResourceRepository>(
|
||||
repository: &R,
|
||||
key: &ResourceKey,
|
||||
label: Option<&str>,
|
||||
) -> Result<Arc<[u8]>, 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<R: ResourceRepository>(
|
||||
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<ResourceName, AssetError> {
|
||||
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<NormalizedPath, AssetError> {
|
||||
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<dyn Vfs> = 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<dyn Vfs> = 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "fparkan-binary"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -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<u16, DecodeError> {
|
||||
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<u32, DecodeError> {
|
||||
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<i32, DecodeError> {
|
||||
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<f32, DecodeError> {
|
||||
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<usize, DecodeError> {
|
||||
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<usize, DecodeError> {
|
||||
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<Vec<u8>, 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<ManifestEntry>,
|
||||
/// Casefold collisions.
|
||||
pub casefold_collisions: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
/// 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<String, u64>,
|
||||
/// 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<CorpusManifest, CorpusError> {
|
||||
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<ManifestEntry>,
|
||||
) -> 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<Vec<String>> {
|
||||
let mut grouped: BTreeMap<Vec<u8>, BTreeSet<String>> = 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<String, u64>, 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<Vec<NresEntryBrief>> {
|
||||
if bytes.len() < 16 || !bytes.starts_with(b"NRes") {
|
||||
return None;
|
||||
}
|
||||
let count = i32::from_le_bytes(bytes.get(8..12)?.try_into().ok()?);
|
||||
if count < 0 {
|
||||
return None;
|
||||
}
|
||||
let count = usize::try_from(count).ok()?;
|
||||
let directory_len = count.checked_mul(64)?;
|
||||
let directory_offset = bytes.len().checked_sub(directory_len)?;
|
||||
let mut names = Vec::with_capacity(count);
|
||||
for index in 0..count {
|
||||
let base = directory_offset.checked_add(index.checked_mul(64)?)?;
|
||||
let kind = u32::from_le_bytes(bytes.get(base..base + 4)?.try_into().ok()?);
|
||||
let raw = bytes.get(base + 20..base + 56)?;
|
||||
let len = raw.iter().position(|b| *b == 0).unwrap_or(raw.len());
|
||||
names.push(NresEntryBrief {
|
||||
kind,
|
||||
name: String::from_utf8_lossy(&raw[..len]).to_string(),
|
||||
});
|
||||
}
|
||||
Some(names)
|
||||
}
|
||||
|
||||
/// 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "fparkan-diagnostics"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -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<Phase>,
|
||||
/// Redacted or logical path.
|
||||
pub path: Option<String>,
|
||||
/// Archive entry name.
|
||||
pub archive_entry: Option<String>,
|
||||
/// Object/prototype key.
|
||||
pub object_key: Option<String>,
|
||||
/// Input span.
|
||||
pub span: Option<SourceSpan>,
|
||||
}
|
||||
|
||||
/// 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<Diagnostic>,
|
||||
}
|
||||
|
||||
/// Creates a diagnostic with default error severity.
|
||||
#[must_use]
|
||||
pub fn diagnostic(code: DiagnosticCode, message: impl Into<String>) -> 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}"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "fparkan-path"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -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<u8>);
|
||||
|
||||
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<u8> {
|
||||
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<u8>);
|
||||
|
||||
/// Resource name bytes.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct ResourceName(pub Vec<u8>);
|
||||
|
||||
/// 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<NormalizedPath, PathError> {
|
||||
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<NormalizedPathWithOriginal, PathError> {
|
||||
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<PathBuf, PathError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "fparkan-platform"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -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<PlatformEvent>) -> 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,
|
||||
}
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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<OriginalObjectId>,
|
||||
/// Mesh.
|
||||
pub mesh: GpuMeshId,
|
||||
/// Material table after WEAR/MAT0 fallback resolution.
|
||||
pub material_slots: Vec<GpuMaterialId>,
|
||||
/// 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<RenderSnapshotDraw>,
|
||||
}
|
||||
|
||||
/// 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<OriginalObjectId>,
|
||||
/// 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<RenderCommand>,
|
||||
}
|
||||
|
||||
/// 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<RenderCommandList, RenderError> {
|
||||
let mut draws = snapshot
|
||||
.draws
|
||||
.iter()
|
||||
.filter(|draw| profile.include_ui || draw.phase != RenderPhase::Ui)
|
||||
.collect::<Vec<_>>();
|
||||
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<FrameOutput, RenderError>;
|
||||
}
|
||||
|
||||
/// 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<FrameOutput, RenderError> {
|
||||
validate_commands(commands)?;
|
||||
Ok(FrameOutput)
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend that stores deterministic command captures for verification.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct RecordingBackend {
|
||||
captures: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl RecordingBackend {
|
||||
/// Returns all captures in submission order.
|
||||
#[must_use]
|
||||
pub fn captures(&self) -> &[Vec<u8>] {
|
||||
&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<FrameOutput, RenderError> {
|
||||
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<Vec<u8>, 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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<u32>,
|
||||
}
|
||||
|
||||
/// 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<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
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<u8> {
|
||||
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<ArchiveId, ResourceError>;
|
||||
/// Finds entry.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ResourceError`] when `archive` is not a valid opened archive.
|
||||
fn find(
|
||||
&self,
|
||||
archive: ArchiveId,
|
||||
name: &ResourceName,
|
||||
) -> Result<Option<EntryHandle>, ResourceError>;
|
||||
/// Reads bytes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ResourceError`] when `entry` is stale, invalid, or cannot be
|
||||
/// decoded.
|
||||
fn read(&self, entry: EntryHandle) -> Result<ResourceBytes, ResourceError>;
|
||||
/// Reads entry metadata.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ResourceError`] when `entry` is stale or invalid.
|
||||
fn entry_info(&self, entry: EntryHandle) -> Result<ResourceEntryInfo, ResourceError>;
|
||||
}
|
||||
|
||||
/// Cached archive repository over a [`Vfs`].
|
||||
pub struct CachedResourceRepository {
|
||||
vfs: Arc<dyn Vfs>,
|
||||
state: Mutex<RepositoryState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RepositoryState {
|
||||
paths: BTreeMap<String, ArchiveId>,
|
||||
archives: Vec<ArchiveSlot>,
|
||||
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<EntryHandle, PayloadCacheEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PayloadCacheEntry {
|
||||
bytes: Arc<[u8]>,
|
||||
last_access: u64,
|
||||
}
|
||||
|
||||
impl CachedResourceRepository {
|
||||
/// Creates a cached repository.
|
||||
#[must_use]
|
||||
pub fn new(vfs: Arc<dyn Vfs>) -> 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<dyn Vfs>, 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<ArchiveKind, ResourceError> {
|
||||
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<NormalizedPath, ResourceError> {
|
||||
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<ArchiveId, ResourceError> {
|
||||
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<Option<EntryHandle>, 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<ResourceBytes, ResourceError> {
|
||||
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<ResourceEntryInfo, ResourceError> {
|
||||
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<Option<ArchiveId>, 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<Arc<[u8]>> {
|
||||
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<ResourceKey, ResourceError> {
|
||||
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<Vec<u8>, 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<ArchiveSlot, ResourceError> {
|
||||
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<NormalizedPath, ResourceError> {
|
||||
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<u8> {
|
||||
let mut out = vec![0; 16];
|
||||
let mut offsets = Vec::with_capacity(entries.len());
|
||||
for (_, payload) in entries {
|
||||
offsets.push(u32::try_from(out.len()).expect("offset"));
|
||||
out.extend_from_slice(payload);
|
||||
let padding = (8 - (out.len() % 8)) % 8;
|
||||
out.resize(out.len() + padding, 0);
|
||||
}
|
||||
let mut order: Vec<usize> = (0..entries.len()).collect();
|
||||
order.sort_by(|left, right| {
|
||||
entries[*left]
|
||||
.0
|
||||
.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<u8>, 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<u8> {
|
||||
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<u8> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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<RenderCommandList>,
|
||||
}
|
||||
|
||||
impl RenderBackend for RecordingRenderBackend {
|
||||
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
|
||||
self.captures.push(commands.clone());
|
||||
Ok(FrameOutput)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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<VfsMetadata, VfsError>;
|
||||
/// 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<Arc<[u8]>, 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<Vec<VfsEntry>, 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<Path>) -> Self {
|
||||
Self {
|
||||
root: root.as_ref().to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> {
|
||||
let exact = join_under(&self.root, path).map_err(|_| VfsError::Path)?;
|
||||
if exact.exists() {
|
||||
return Ok(exact);
|
||||
}
|
||||
resolve_casefolded(&self.root, path.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl Vfs for DirectoryVfs {
|
||||
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
||||
let meta = fs::metadata(self.host_path(path)?).map_err(VfsError::Io)?;
|
||||
Ok(metadata_from_fs(&meta))
|
||||
}
|
||||
|
||||
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, 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<Vec<VfsEntry>, 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<PathBuf, VfsError> {
|
||||
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<PathBuf>,
|
||||
) -> Result<PathBuf, VfsError> {
|
||||
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<VfsEntry>) -> 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<String, Arc<[u8]>>,
|
||||
}
|
||||
|
||||
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<VfsMetadata, VfsError> {
|
||||
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<Arc<[u8]>, VfsError> {
|
||||
self.files
|
||||
.get(path.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))
|
||||
}
|
||||
|
||||
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, 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<Arc<dyn Vfs>>,
|
||||
}
|
||||
|
||||
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<Arc<dyn Vfs>>) -> Self {
|
||||
Self { layers }
|
||||
}
|
||||
|
||||
/// Appends a lower-priority layer.
|
||||
pub fn push_layer(&mut self, layer: Arc<dyn Vfs>) {
|
||||
self.layers.push(layer);
|
||||
}
|
||||
}
|
||||
|
||||
impl Vfs for OverlayVfs {
|
||||
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
||||
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<Arc<[u8]>, 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<Vec<VfsEntry>, 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "fparkan-world"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -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<OriginalObjectId>,
|
||||
}
|
||||
|
||||
/// Distinct object identity metadata.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct IdentityMetadata {
|
||||
/// Original mission object id.
|
||||
pub original_id: Option<OriginalObjectId>,
|
||||
/// Mirrored original id.
|
||||
pub mirror_id: Option<OriginalObjectId>,
|
||||
/// Local owner id.
|
||||
pub owner_id: Option<OwnerId>,
|
||||
}
|
||||
|
||||
/// World command.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WorldCommand {
|
||||
/// Sequence.
|
||||
pub sequence: u64,
|
||||
/// Target.
|
||||
pub target: Option<ObjectHandle>,
|
||||
}
|
||||
|
||||
/// World event.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WorldEvent {
|
||||
/// Sequence.
|
||||
pub sequence: u64,
|
||||
/// Target object, if any.
|
||||
pub target: Option<ObjectHandle>,
|
||||
}
|
||||
|
||||
/// 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<ObjectHandle>,
|
||||
/// Commands processed during this step.
|
||||
pub events: Vec<WorldEvent>,
|
||||
/// 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<ObjectHandle>,
|
||||
/// Whether managers were released after objects.
|
||||
pub managers_released: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Slot {
|
||||
generation: u32,
|
||||
live: bool,
|
||||
registered: bool,
|
||||
original_id: Option<OriginalObjectId>,
|
||||
owner_id: Option<OwnerId>,
|
||||
mirror_id: Option<OriginalObjectId>,
|
||||
registration_sequence: Option<u64>,
|
||||
}
|
||||
|
||||
/// World.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct World {
|
||||
slots: Vec<Slot>,
|
||||
queue: VecDeque<WorldCommand>,
|
||||
deferred_delete: Vec<ObjectHandle>,
|
||||
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<ObjectHandle, WorldError> {
|
||||
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<OwnerId>,
|
||||
) -> 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<OriginalObjectId>,
|
||||
) -> 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<Option<u64>, 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<IdentityMetadata, WorldError> {
|
||||
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<WorldSnapshot, WorldError> {
|
||||
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<F>(
|
||||
world: &mut World,
|
||||
_input: &InputSnapshot,
|
||||
mut handler: F,
|
||||
) -> Result<WorldSnapshot, WorldError>
|
||||
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<FixedStepClock, WorldError> {
|
||||
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<u32, WorldError> {
|
||||
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<WorldEvent>) -> Vec<u64> {
|
||||
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<ObjectHandle> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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
|
||||
```
|
||||
@@ -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<std::io::Error> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,702 +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<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[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(Debug)]
|
||||
pub struct Archive {
|
||||
bytes: Arc<[u8]>,
|
||||
entries: Vec<EntryRecord>,
|
||||
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(Clone, Debug)]
|
||||
struct EntryRecord {
|
||||
meta: EntryMeta,
|
||||
name_raw: [u8; 36],
|
||||
}
|
||||
|
||||
impl Archive {
|
||||
pub fn open_path(path: impl AsRef<Path>) -> Result<Self> {
|
||||
Self::open_path_with(path, OpenMode::ReadOnly, OpenOptions::default())
|
||||
}
|
||||
|
||||
pub fn open_path_with(
|
||||
path: impl AsRef<Path>,
|
||||
_mode: OpenMode,
|
||||
opts: OpenOptions,
|
||||
) -> Result<Self> {
|
||||
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<Self> {
|
||||
let (entries, _) = parse_archive(&bytes, opts.raw_mode)?;
|
||||
if opts.prefetch_pages {
|
||||
prefetch_pages(&bytes);
|
||||
}
|
||||
Ok(Self {
|
||||
bytes,
|
||||
entries,
|
||||
raw_mode: opts.raw_mode,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
||||
self.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, entry)| EntryRef {
|
||||
id: EntryId(u32::try_from(idx).expect("entry count validated at parse")),
|
||||
meta: &entry.meta,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find(&self, name: &str) -> Option<EntryId> {
|
||||
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 => {
|
||||
return Some(EntryId(
|
||||
u32::try_from(target_idx).expect("entry count validated at parse"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.entries.iter().enumerate().find_map(|(idx, entry)| {
|
||||
if cmp_name_case_insensitive(name.as_bytes(), entry_name_bytes(&entry.name_raw))
|
||||
== Ordering::Equal
|
||||
{
|
||||
Some(EntryId(
|
||||
u32::try_from(idx).expect("entry count validated at parse"),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(&self, id: EntryId) -> Option<EntryRef<'_>> {
|
||||
let idx = usize::try_from(id.0).ok()?;
|
||||
let entry = self.entries.get(idx)?;
|
||||
Some(EntryRef {
|
||||
id,
|
||||
meta: &entry.meta,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read(&self, id: EntryId) -> Result<ResourceData<'_>> {
|
||||
let range = self.entry_range(id)?;
|
||||
Ok(ResourceData::Borrowed(&self.bytes[range]))
|
||||
}
|
||||
|
||||
pub fn read_into(&self, id: EntryId, out: &mut dyn OutputBuffer) -> Result<usize> {
|
||||
let range = self.entry_range(id)?;
|
||||
out.write_exact(&self.bytes[range.clone()])?;
|
||||
Ok(range.len())
|
||||
}
|
||||
|
||||
pub fn raw_slice(&self, id: EntryId) -> Result<Option<&[u8]>> {
|
||||
let range = self.entry_range(id)?;
|
||||
Ok(Some(&self.bytes[range]))
|
||||
}
|
||||
|
||||
pub fn edit_path(path: impl AsRef<Path>) -> Result<Editor> {
|
||||
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<Range<usize>> {
|
||||
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: self.entries.len().try_into().unwrap_or(u32::MAX),
|
||||
});
|
||||
};
|
||||
checked_range(
|
||||
entry.meta.data_offset,
|
||||
entry.meta.data_size,
|
||||
self.bytes.len(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Editor {
|
||||
path: PathBuf,
|
||||
source: Arc<[u8]>,
|
||||
entries: Vec<EditableEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum EntryData {
|
||||
Borrowed(Range<usize>),
|
||||
Modified(Vec<u8>),
|
||||
}
|
||||
|
||||
#[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<Item = EntryRef<'_>> {
|
||||
self.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, entry)| EntryRef {
|
||||
id: EntryId(u32::try_from(idx).expect("entry count validated at add")),
|
||||
meta: &entry.meta,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add(&mut self, entry: NewEntry<'_>) -> Result<EntryId> {
|
||||
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: self.entries.len().try_into().unwrap_or(u32::MAX),
|
||||
});
|
||||
};
|
||||
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: self.entries.len().try_into().unwrap_or(u32::MAX),
|
||||
});
|
||||
}
|
||||
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<usize> = (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() {
|
||||
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<EntryRecord>, u64)> {
|
||||
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],
|
||||
u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
|
||||
));
|
||||
}
|
||||
|
||||
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, directory_offset))
|
||||
}
|
||||
|
||||
fn checked_range(offset: u64, size: u32, bytes_len: usize) -> Result<Range<usize>> {
|
||||
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<u32> {
|
||||
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<u8>, 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 prefetch_pages(bytes: &[u8]) {
|
||||
use std::sync::atomic::{compiler_fence, Ordering};
|
||||
|
||||
let mut cursor = 0usize;
|
||||
let mut sink = 0u8;
|
||||
while cursor < bytes.len() {
|
||||
sink ^= bytes[cursor];
|
||||
cursor = cursor.saturating_add(4096);
|
||||
}
|
||||
compiler_fence(Ordering::SeqCst);
|
||||
let _ = 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<u16> = src.as_os_str().encode_wide().chain(iter::once(0)).collect();
|
||||
let dst_wide: Vec<u16> = dst.as_os_str().encode_wide().chain(iter::once(0)).collect();
|
||||
|
||||
// Replace destination in one OS call, avoiding remove+rename gaps on Windows.
|
||||
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;
|
||||
@@ -1,996 +0,0 @@
|
||||
use super::*;
|
||||
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 collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn nres_test_files() -> Vec<PathBuf> {
|
||||
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<dyn Any + Send>) -> String {
|
||||
let any = payload.as_ref();
|
||||
if let Some(message) = any.downcast_ref::<String>() {
|
||||
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<u8> {
|
||||
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<usize> = (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<usize> = (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<u8> = (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<usize> = (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);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
[package]
|
||||
name = "rsli"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
@@ -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
|
||||
```
|
||||
@@ -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<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let mut decoder = DeflateDecoder::new(packed);
|
||||
decoder
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|_| Error::DecompressionFailed("deflate"))?;
|
||||
Ok(out)
|
||||
}
|
||||
@@ -1,298 +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<u16>,
|
||||
) -> Result<Vec<u8>> {
|
||||
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<u16>) -> 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<Vec<u8>> {
|
||||
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<usize> {
|
||||
let mut node = self.son[LZH_R];
|
||||
while node < LZH_T {
|
||||
let bit = usize::from(self.bit_reader.read_bit()?);
|
||||
node = self.son[node + bit];
|
||||
}
|
||||
|
||||
let c = node - LZH_T;
|
||||
self.update(c);
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
fn decode_position(&mut self) -> Result<usize> {
|
||||
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<XorState>,
|
||||
}
|
||||
|
||||
impl<'a> BitReader<'a> {
|
||||
fn new(data: &'a [u8], xor_key: Option<u16>) -> 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<u8> {
|
||||
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<u32> {
|
||||
let mut value = 0u32;
|
||||
for _ in 0..bits {
|
||||
value = (value << 1) | u32::from(self.read_bit()?);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
@@ -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<u16>,
|
||||
) -> Result<Vec<u8>> {
|
||||
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<XorState>| -> Option<u8> {
|
||||
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)
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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<u8> {
|
||||
let mut state = XorState::new(key16);
|
||||
data.iter().map(|&b| state.decrypt_byte(b)).collect()
|
||||
}
|
||||
@@ -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<std::io::Error> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,411 +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<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[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(Debug)]
|
||||
pub struct Library {
|
||||
bytes: Arc<[u8]>,
|
||||
entries: Vec<EntryRecord>,
|
||||
#[cfg(test)]
|
||||
pub(crate) header_raw: [u8; 32],
|
||||
#[cfg(test)]
|
||||
pub(crate) table_plain_original: Vec<u8>,
|
||||
#[cfg(test)]
|
||||
pub(crate) xor_seed: u32,
|
||||
#[cfg(test)]
|
||||
pub(crate) source_size: usize,
|
||||
#[cfg(test)]
|
||||
pub(crate) trailer_raw: Option<[u8; 6]>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
pub struct PackedResource {
|
||||
pub meta: EntryMeta,
|
||||
pub packed: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct EntryRecord {
|
||||
pub(crate) meta: EntryMeta,
|
||||
pub(crate) name_raw: [u8; 12],
|
||||
pub(crate) sort_to_original: i16,
|
||||
pub(crate) key16: u16,
|
||||
#[cfg(test)]
|
||||
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<Path>) -> Result<Self> {
|
||||
Self::open_path_with(path, OpenOptions::default())
|
||||
}
|
||||
|
||||
pub fn open_path_with(path: impl AsRef<Path>, opts: OpenOptions) -> Result<Self> {
|
||||
let bytes = fs::read(path.as_ref())?;
|
||||
let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice());
|
||||
parse_library(arc, opts)
|
||||
}
|
||||
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
||||
self.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, entry)| EntryRef {
|
||||
id: EntryId(u32::try_from(idx).expect("entry count validated at parse")),
|
||||
meta: &entry.meta,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find(&self, name: &str) -> Option<EntryId> {
|
||||
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<EntryId> {
|
||||
// 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 => {
|
||||
return Some(EntryId(
|
||||
u32::try_from(idx).expect("entry count validated at parse"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Some(EntryId(
|
||||
u32::try_from(idx).expect("entry count validated at parse"),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(&self, id: EntryId) -> Option<EntryRef<'_>> {
|
||||
let idx = usize::try_from(id.0).ok()?;
|
||||
let entry = self.entries.get(idx)?;
|
||||
Some(EntryRef {
|
||||
id,
|
||||
meta: &entry.meta,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load(&self, id: EntryId) -> Result<Vec<u8>> {
|
||||
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<usize> {
|
||||
let decoded = self.load(id)?;
|
||||
out.write_exact(&decoded)?;
|
||||
Ok(decoded.len())
|
||||
}
|
||||
|
||||
pub fn load_packed(&self, id: EntryId) -> Result<PackedResource> {
|
||||
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<Vec<u8>> {
|
||||
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<ResourceData<'_>> {
|
||||
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: self.entries.len().try_into().unwrap_or(u32::MAX),
|
||||
})
|
||||
}
|
||||
|
||||
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<u16> {
|
||||
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<Vec<u8>> {
|
||||
let trailer_len = usize::from(self.trailer_raw.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.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 packed = self
|
||||
.load_packed(EntryId(
|
||||
u32::try_from(idx).expect("entry count validated at parse"),
|
||||
))?
|
||||
.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: u32::try_from(idx).expect("entry count validated at parse"),
|
||||
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.trailer_raw {
|
||||
out.extend_from_slice(&trailer);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_payload(
|
||||
packed: &[u8],
|
||||
method: PackMethod,
|
||||
key16: u16,
|
||||
unpacked_size: u32,
|
||||
) -> Result<Vec<u8>> {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,267 +0,0 @@
|
||||
use crate::compress::xor::xor_stream;
|
||||
use crate::error::Error;
|
||||
use crate::{EntryMeta, EntryRecord, Library, OpenOptions, PackMethod, Result};
|
||||
use std::cmp::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
|
||||
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]);
|
||||
|
||||
if &bytes[0..2] != b"NL" {
|
||||
let mut got = [0u8; 2];
|
||||
got.copy_from_slice(&bytes[0..2]);
|
||||
return Err(Error::InvalidMagic { got });
|
||||
}
|
||||
if bytes[3] != 0x01 {
|
||||
return Err(Error::UnsupportedVersion { got: bytes[3] });
|
||||
}
|
||||
|
||||
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 xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
||||
|
||||
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)?;
|
||||
#[cfg(not(test))]
|
||||
let _ = trailer_raw;
|
||||
|
||||
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 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).expect("entry count validated at parse"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return Err(Error::PackedSizePastEof {
|
||||
id: u32::try_from(idx).expect("entry count validated at parse"),
|
||||
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).expect("entry count validated at parse"),
|
||||
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,
|
||||
sort_to_original,
|
||||
key16: sort_to_original as u16,
|
||||
#[cfg(test)]
|
||||
data_offset_raw,
|
||||
packed_size_declared,
|
||||
packed_size_available,
|
||||
effective_offset,
|
||||
});
|
||||
}
|
||||
|
||||
let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
|
||||
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<usize> = (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,
|
||||
#[cfg(test)]
|
||||
header_raw,
|
||||
#[cfg(test)]
|
||||
table_plain_original,
|
||||
#[cfg(test)]
|
||||
xor_seed,
|
||||
#[cfg(test)]
|
||||
source_size,
|
||||
#[cfg(test)]
|
||||
trailer_raw,
|
||||
})
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
||||
# Глоссарий
|
||||
|
||||
Глоссарий объясняет термины в том смысле, в котором они используются в этой
|
||||
книге. Короткое определение не заменяет профильную главу: практический контракт
|
||||
понятия раскрывается в соответствующем томе или справочной странице.
|
||||
|
||||
## Бинарные файлы и ABI
|
||||
|
||||
**PE (Portable Executable)** -- формат исполняемых файлов Windows: EXE и DLL.
|
||||
Он содержит заголовки, секции, таблицы импортов и экспортов, relocations и
|
||||
адрес точки входа.
|
||||
|
||||
**Image base** -- предпочтительный адрес начала загруженного PE-образа.
|
||||
**VA** -- виртуальный адрес в процессе. **RVA** -- адрес относительно image
|
||||
base.
|
||||
|
||||
**Import** -- внешняя функция или переменная, которую модуль получает из другой
|
||||
DLL. **Export** -- символ, предоставляемый другим модулям. Имя, ordinal и
|
||||
calling convention вместе образуют часть binary contract.
|
||||
|
||||
**ABI** -- соглашение о двоичном взаимодействии: размещение аргументов, возврат
|
||||
значений, очистка stack, layout структур, порядок virtual methods и правила
|
||||
владения.
|
||||
|
||||
**Calling convention** -- часть ABI, определяющая передачу аргументов и очистку
|
||||
stack. Для исследованного 32-bit code важны `__cdecl`, `__stdcall` и
|
||||
`__thiscall`.
|
||||
|
||||
**Vtable** -- массив указателей на virtual methods C++-объекта. Запись
|
||||
`vtable +0x34` означает вызов указателя по байтовому смещению `0x34` от начала
|
||||
таблицы.
|
||||
|
||||
**Static analysis** исследует файл без исполнения: disassembly, strings,
|
||||
imports, call graph и data flow. **Dynamic analysis** наблюдает работающую
|
||||
программу: breakpoints, traces, API hooks, memory state и packet/frame captures.
|
||||
|
||||
**Evidence** -- повторяемое наблюдение. **Inference** -- вывод, объединяющий
|
||||
несколько наблюдений. **Hypothesis** -- рабочее предположение, ещё не
|
||||
подтверждённое достаточным экспериментом.
|
||||
|
||||
## Форматы данных
|
||||
|
||||
**Archive** -- контейнер, объединяющий множество ресурсов. **Entry** -- запись
|
||||
его каталога. **Payload** -- полезные bytes конкретной записи.
|
||||
|
||||
**Magic** -- короткая сигнатура формата, например `NRes` или `Texm`.
|
||||
**Version** -- номер варианта layout. Проверка одной magic без проверки version
|
||||
и размеров недостаточна.
|
||||
|
||||
**Offset** -- положение данных относительно начала файла или структуры.
|
||||
**Size** -- число bytes. **Stride** -- размер одного элемента массива.
|
||||
**Alignment** -- требование начинать данные на offset, кратном заданному числу.
|
||||
|
||||
**Little-endian** -- порядок, в котором младший byte многобайтного числа
|
||||
расположен первым. Основные числовые поля форматов Iron3D используют этот
|
||||
порядок.
|
||||
|
||||
**Fixed-size string** -- поле заранее известной длины. Полезная строка
|
||||
заканчивается первым NUL, но оставшиеся bytes могут содержать служебный хвост и
|
||||
должны сохраняться.
|
||||
|
||||
**Opaque field** -- поле с доказанными offset и size, но не установленным
|
||||
предметным смыслом. Его безопасно читать и копировать, но нельзя очищать или
|
||||
переосмысливать без эксперимента.
|
||||
|
||||
**Invariant** -- условие, которое обязано выполняться: range лежит внутри
|
||||
payload, индекс указывает на существующий элемент, count соответствует размеру
|
||||
секции.
|
||||
|
||||
**Strict reader** отклоняет любое нарушение контракта. **Compatibility reader**
|
||||
дополнительно воспроизводит только известные особенности оригинала.
|
||||
|
||||
**Fallback** -- явно предписанный запасной путь, например material `DEFAULT`,
|
||||
затем entry 0. **Heuristic** -- догадка по похожим данным; она не должна
|
||||
незаметно заменять доказанный fallback.
|
||||
|
||||
**Roundtrip** -- последовательность decode -> encode. **Byte-identical
|
||||
roundtrip** создаёт файл, полностью совпадающий с исходным. **Lossless editor**
|
||||
может изменить известное поле, сохранив все остальные bytes и порядок записей.
|
||||
|
||||
## Ресурсы
|
||||
|
||||
**NRes** -- основной контейнер ресурсов с каталогом в конце файла.
|
||||
|
||||
**RsLi** -- библиотечный архив с каталогом в начале файла и несколькими методами
|
||||
упаковки payload.
|
||||
|
||||
**TMA** -- mission data: paths, clans, placed objects, properties, land path и
|
||||
extras.
|
||||
|
||||
**MSH** -- модель Iron3D, представленная как NRes с entries для geometry,
|
||||
nodes, slots, batches, animation и auxiliary streams.
|
||||
|
||||
**WEAR** -- таблица внешнего вида модели, переводящая material index в MAT0
|
||||
name и lightmap slots.
|
||||
|
||||
**MAT0** -- материал: phases, parameters, animation blocks и texture references.
|
||||
|
||||
**Texm** -- texture payload с header, palette, mip chain и optional Page atlas.
|
||||
|
||||
**FXID** -- ресурс эффектов: команды, references, lifetime, random/time modes и
|
||||
runtime instances.
|
||||
|
||||
## Игровой runtime
|
||||
|
||||
**Engine** -- программная среда, которая загружает данные, ведёт время,
|
||||
исполняет мир и формирует изображение/звук. **Game** -- правила, миссии и
|
||||
content поверх engine services.
|
||||
|
||||
**World** -- долгоживущее состояние миссии: objects, terrain, время, кланы и
|
||||
managers. **Scene** -- представление части мира для конкретной обработки,
|
||||
обычно текущей камеры.
|
||||
|
||||
**Game object** -- сущность с идентичностью, transform, properties и lifecycle.
|
||||
**Component/controller** -- специализированная часть поведения: animation,
|
||||
physics, AI или rendering representation.
|
||||
|
||||
**Simulation** отвечает за изменение мира. **Tick** -- один расчётный шаг.
|
||||
**Frame** -- одно подготовленное изображение. Число ticks и frames за единицу
|
||||
времени не обязано совпадать.
|
||||
|
||||
**Event/message** -- типизированное сообщение между objects или subsystems.
|
||||
**Queue traversal** -- стабильный обход зарегистрированных объектов.
|
||||
**Deferred deletion** -- перенос фактического удаления до безопасной границы.
|
||||
|
||||
**Snapshot** -- согласованное состояние, которое renderer читает без изменения
|
||||
simulation. **Determinism** -- одинаковый результат при одинаковом initial
|
||||
state, input, времени и порядке событий.
|
||||
|
||||
**Authority** -- subsystem или network peer, которому разрешено окончательно
|
||||
менять состояние объекта. **Mirror object** -- локальное представление объекта,
|
||||
authority которого находится у другого player.
|
||||
|
||||
## Геометрия и рендеринг
|
||||
|
||||
**Vertex** -- вершина geometry. **Index** -- номер вершины. **Triangle** --
|
||||
примитив из трёх индексов.
|
||||
|
||||
**Node** -- элемент hierarchy модели со своим local transform. **Slot** в MSH
|
||||
-- выбранная геометрическая группа для комбинации node, LOD и group. **Batch**
|
||||
-- непрерывный индексный диапазон с material slot и render state.
|
||||
|
||||
**Transform** переводит данные между coordinate spaces. **Matrix** задаёт
|
||||
линейное преобразование и translation. Порядок умножения matrices является
|
||||
частью контракта.
|
||||
|
||||
**Bounds** -- упрощённый объём для быстрых тестов. **AABB** -- min/max по осям.
|
||||
**Bounding sphere** -- center и radius.
|
||||
|
||||
**Renderer** преобразует подготовленную сцену в изображение. **Backend** --
|
||||
реализация поверх конкретного API или устройства.
|
||||
|
||||
**Draw call** -- команда нарисовать диапазон primitives. **Indexed draw**
|
||||
использует index buffer и base vertex.
|
||||
|
||||
**Material phase** -- одно временное состояние анимированного материала.
|
||||
**Texture** -- двумерный массив texels. **Mip chain** -- последовательность
|
||||
уменьшенных уровней texture. **Atlas** -- texture с несколькими под-
|
||||
изображениями.
|
||||
|
||||
**Fixed-function pipeline** -- старый graphics pipeline, где приложение
|
||||
выбирает predefined transform, lighting, texture-stage и blend states вместо
|
||||
пользовательских shaders.
|
||||
|
||||
**Depth test**, **culling**, **alpha test** и **blending** -- render states,
|
||||
которые влияют на порядок и видимость fragments.
|
||||
|
||||
**Pixel parity** -- совпадение конечного изображения при фиксированных camera,
|
||||
time, seed, resolution и device profile.
|
||||
|
||||
## Навигация, звук и сеть
|
||||
|
||||
**Areal** -- логическая область карты с границей, class/flags и связями с
|
||||
соседями. **Areal graph** -- граф областей и переходов. **Cell grid** --
|
||||
пространственный индекс для быстрых candidate queries.
|
||||
|
||||
**Pathfinding** -- поиск маршрута по graph. **Corridor** -- локальная полоса,
|
||||
построенная из последовательности areals. **Local steering** корректирует
|
||||
ближайший шаг внутри corridor.
|
||||
|
||||
**Collision proxy** -- упрощённое представление объекта для столкновений.
|
||||
**Broad phase** быстро находит потенциальные пары. **Narrow phase** выполняет
|
||||
точную проверку и вычисляет contact.
|
||||
|
||||
**Sample** -- декодированные звуковые данные. **Source** -- конкретный
|
||||
экземпляр воспроизведения с position, gain, loop state и временем. **Listener**
|
||||
-- положение и ориентация слушателя для 3D spatialization.
|
||||
|
||||
**Transport** -- механизм доставки bytes между peers. **Protocol** -- framing,
|
||||
message types, порядок и правила подтверждения. **Wire compatibility** --
|
||||
способность обмениваться данными с оригинальным клиентом.
|
||||
|
||||
**Serialization** -- преобразование typed state в byte sequence. **Framing** --
|
||||
способ отделить одно сообщение от следующего. **Reliable delivery** гарантирует
|
||||
доставку/порядок в пределах выбранной модели; **unreliable delivery** допускает
|
||||
потери ради задержки.
|
||||
|
||||
**Player ID** транспорта и **game player number** -- разные идентичности.
|
||||
**Ownership transfer** меняет authority объекта. **Replication** передаёт
|
||||
состояние или события remote mirrors.
|
||||
@@ -0,0 +1,153 @@
|
||||
# Границы знания
|
||||
|
||||
Этот раздел перечисляет области, где контракт ещё не закрыт полностью. Они не
|
||||
мешают безопасному чтению и lossless сохранению, но не должны превращаться в
|
||||
authoring API без динамического подтверждения.
|
||||
|
||||
## Render state
|
||||
|
||||
Доказаны frame boundaries, world traversal, material resolve и крупные проходы.
|
||||
Не доказаны символами точные имена renderer vtable slots, полный набор CShade
|
||||
state transitions и окончательный порядок части transparent/FX/shadow subpasses.
|
||||
|
||||
Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX
|
||||
среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state
|
||||
log на минимальных сценах с одним типом материала.
|
||||
|
||||
## FXID field-level semantics
|
||||
|
||||
Размеры команд, resource references, lifecycle, flags families и используемые
|
||||
time modes известны. Не закрыто значение каждого поля body opcodes 1--10,
|
||||
отсутствующий во всех проверенных каталогах opcode 6 и точные формулы редких
|
||||
time modes.
|
||||
|
||||
Закрывающий эксперимент: изменять по одному полю копии эффекта, воспроизводить
|
||||
его в контролируемой сцене и логировать runtime command object, emitted
|
||||
primitives, sound events и reads в `Effect.dll`.
|
||||
|
||||
## Script VM
|
||||
|
||||
Доступны packages, symbols, event sections, variable declarations и version
|
||||
checks. Полная instruction grammar `.scr`, semantics opcodes и serialization
|
||||
state ещё не восстановлены.
|
||||
|
||||
Закрывающий эксперимент: найти dispatcher loop в `ai.dll`, сопоставить jump
|
||||
table с instruction sizes, построить disassembler и сравнить выполнение
|
||||
коротких scripts с оригиналом.
|
||||
|
||||
## Saves and campaign state
|
||||
|
||||
Найдены `saveslots.cfg` и `missions/dispatcher.ini`, но binary savegame payload,
|
||||
serialization World3D/AI/script/RNG и migration rules не закрыты.
|
||||
|
||||
Нужны сохранения оригинала в контролируемых состояниях: старт миссии, изменение
|
||||
позиции, здоровья, order/path, FX/timer, script variable, research/economy,
|
||||
mission completion, pause и non-default game time.
|
||||
|
||||
## Physical/control formats
|
||||
|
||||
CTLD и связанные resources структурно читаются, count patterns и variants
|
||||
известны. Не названы все секции, shape types, coefficients и точный contact
|
||||
solver. То же относится к редким MSH auxiliary streams и части CTPT/NDPR flags.
|
||||
|
||||
Закрывающий эксперимент: трассировать `LoadControlSystem`,
|
||||
`LoadPhysicalModel`, `CreateCollManager` и создание collision objects; связать
|
||||
каждый изменяемый field с созданным shape, contact или реакцией на движение.
|
||||
|
||||
## DirectPlay wire
|
||||
|
||||
DirectPlay lifecycle и имена игровых messages известны. Wire framing, payload
|
||||
schema, reliability flags и `netZipData` требуют записи обмена двух
|
||||
оригинальных клиентов.
|
||||
|
||||
Native interoperability подтверждается только успешным обменом original client
|
||||
<-> compatibility implementation в обе стороны.
|
||||
|
||||
## Shell, HUD, шрифты и локализация
|
||||
|
||||
Граница shell подтверждена exports `createShell/getIShell`, `IGUIServer`,
|
||||
верхнеуровневым UI-pass и файлами `ui/*.cfg`, `DATA/TextRes.cfg`,
|
||||
`gamefont.rlb` и `sprites.lib`. RsLi framing библиотек закрыт, но widget tree,
|
||||
layout rules, glyph metrics, sprite command semantics, focus/navigation и HUD
|
||||
state machine пока не восстановлены до field-level спецификации.
|
||||
|
||||
Закрывающий эксперимент: трассировать загрузку `shell_ctrls.cfg`,
|
||||
`menu_resources.cfg`, `cursor.cfg`, `game_resources.cfg` и `hq.cfg`, сопоставить
|
||||
GUI object factories и снять command/event captures для меню, HUD, briefing и
|
||||
диалогов.
|
||||
|
||||
## Research, economy and properties
|
||||
|
||||
Экспорты `LoadResearch`, `CalcFullResearchCost`, TRF/preload resources и TMA
|
||||
properties доказывают отдельный слой исследований, стоимости, добычи и
|
||||
производственных параметров. Формулы стоимости, dependency graph технологий,
|
||||
inventory/economy transitions и точная типизация всех 16-byte property values
|
||||
не закрыты.
|
||||
|
||||
Закрывающий эксперимент: сопоставить research functions с ресурсами и UI,
|
||||
снять изменения state на контролируемых покупках/исследованиях и построить
|
||||
typed schema свойств по consumers, а не по одному имени.
|
||||
|
||||
## Rare branches
|
||||
|
||||
- `Land.map poly_count > 0`;
|
||||
- RsLi adaptive methods `0x080` и `0x0A0`;
|
||||
- Texm formats 556 и 88;
|
||||
- FX opcode 6;
|
||||
- редкие material flags и MSH auxiliary streams.
|
||||
|
||||
Такие ветки реализуются по бинарному коду и synthetic tests, а статус
|
||||
corpus-verified получают только после реального файла или runtime trace.
|
||||
|
||||
## Dynamic-stage requirements
|
||||
|
||||
Оставшиеся вопросы нельзя закрыть только статическими архивами. Нужна
|
||||
изолированная 32-bit Windows-среда, неизменённые игровые каталоги, manifest
|
||||
SHA-256, debugger, API/vtable hooks, controlled clocks/input и автоматический
|
||||
launcher, который восстанавливает snapshot, запускает один test case, собирает
|
||||
логи и завершает процесс без ручного вмешательства.
|
||||
|
||||
Для каждого capture сохраняются build profile, module hashes, mission/resource
|
||||
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,
|
||||
parser trace-а, минимального воспроизводимого input/resource/save/message,
|
||||
формального контракта или явно ограниченной гипотезы, differential test для
|
||||
изменённых DLL, обновления тематической главы и regression case, запускаемого
|
||||
без ручного анализа.
|
||||
@@ -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.
|
||||
+46
-12
@@ -1,17 +1,51 @@
|
||||
# Welcome to MkDocs
|
||||
# FParkan
|
||||
|
||||
For full documentation visit [mkdocs.org](https://www.mkdocs.org).
|
||||
FParkan -- самостоятельная техническая книга о восстановлении игрового движка
|
||||
Iron3D из *Parkan: Iron Strategy*. Она ведёт от запуска оригинальной программы
|
||||
и карты DLL к форматам ресурсов, загрузке миссии, геометрии, материалам,
|
||||
рендеру, поведению, звуку, сети и плану чистой совместимой реализации.
|
||||
|
||||
## Commands
|
||||
Сайт оформлен как онлайн-книга: тома читаются последовательно, а справочник
|
||||
используется как быстрый доступ к форматам, проверочным правилам и границам
|
||||
доказанного знания.
|
||||
|
||||
* `mkdocs new [dir-name]` - Create a new project.
|
||||
* `mkdocs serve` - Start the live-reloading docs server.
|
||||
* `mkdocs build` - Build the documentation site.
|
||||
* `mkdocs -h` - Print help message and exit.
|
||||
## Как читать
|
||||
|
||||
## Project layout
|
||||
Если вы впервые разбираете игровой движок, начните с тома I и II. Там вводится
|
||||
лексика, доказательная политика, модульная архитектура и жизненный цикл кадра.
|
||||
|
||||
mkdocs.yml # The configuration file.
|
||||
docs/
|
||||
index.md # The documentation homepage.
|
||||
... # Other markdown pages, images and other files.
|
||||
Если нужна реализация совместимого движка, читайте тома III--VII линейно:
|
||||
ресурсы, миссии, мир, рендер, интерактивные подсистемы и порядок работ.
|
||||
|
||||
Если вы проверяете выводы, переходите к тому VIII и приложениям. Там собраны
|
||||
уровни уверенности, corpus gates, открытые вопросы и критерии закрытия.
|
||||
|
||||
## Восемь томов
|
||||
|
||||
1. **Путеводитель и методика** -- назначение книги, маршруты чтения, язык
|
||||
предметной области и правила проверки.
|
||||
2. **Запуск, архитектура и игровой цикл** -- `iron_3d.exe`, пятнадцать DLL,
|
||||
сервисы, World3D, очередь объектов и границы кадра.
|
||||
3. **Ресурсная система и форматы** -- NRes, RsLi, кэши, имена, `objects.rlb`,
|
||||
unit DAT и сквозное разрешение ресурсов.
|
||||
4. **Мир, миссии и runtime** -- TMA, ландшафт, ареалы, маршруты, создание мира
|
||||
и свойства размещённых объектов.
|
||||
5. **Геометрия, материалы и рендер** -- MSH, анимация, WEAR, MAT0, Texm, FXID,
|
||||
свет, атмосфера и полный render frame.
|
||||
6. **Поведение, управление, звук и сеть** -- AI, Behavior, Wizard, Control,
|
||||
ввод, камера, звук и DirectPlay-слой.
|
||||
7. **Руководство по полной реализации** -- целевая архитектура, этапы работ,
|
||||
тестовый контур, точность, скорость и критерий совместимости.
|
||||
8. **Справочник и доказательная база** -- ABI, конфигурация, статистика
|
||||
корпусов, границы знания и глоссарий.
|
||||
|
||||
## Политика доказательств
|
||||
|
||||
Специфические утверждения об Iron3D принимаются только после локальной проверки
|
||||
на исполняемых файлах, DLL, демоверсии, полных каталогах Частей 1 и 2 или на
|
||||
взаимных инвариантах реальных ресурсов. Внешние описания и текущий код FParkan
|
||||
могут подсказывать вопросы, но не заменяют проверку.
|
||||
|
||||
Неизвестные поля не получают правдоподобных имён. Пока смысл не закрыт,
|
||||
документация фиксирует raw layout, границы, безопасное чтение и lossless
|
||||
сохранение.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# WEAR и MAT0
|
||||
|
||||
MSH batch хранит только `material_index`. WEAR переводит этот индекс в имя
|
||||
материала, а MAT0 по этому имени описывает phases, parameters и texture
|
||||
references.
|
||||
|
||||
```text
|
||||
Batch20.material_index
|
||||
-> WEAR row
|
||||
-> MAT0 entry
|
||||
-> active phase
|
||||
-> textureName
|
||||
```
|
||||
|
||||
## WEAR
|
||||
|
||||
WEAR -- текстовый ресурс type ID `0x52414557`, обычно `*.wea` рядом с моделью.
|
||||
|
||||
```text
|
||||
<wearCount>
|
||||
<legacyId> <materialName>
|
||||
...
|
||||
|
||||
[empty line]
|
||||
[LIGHTMAPS
|
||||
<lightmapCount>
|
||||
<legacyId> <lightmapName>
|
||||
...]
|
||||
```
|
||||
|
||||
`legacyId` сохраняется, но выбор выполняется по позиции строки и имени. Между
|
||||
основной таблицей и `LIGHTMAPS` нужен пустой разделитель.
|
||||
|
||||
## MAT0
|
||||
|
||||
MAT0 имеет type ID `0x3054414D`, обычно расположен в `Material.lib`. `attr1`
|
||||
содержит runtime flags, `attr2` -- версию payload.
|
||||
|
||||
```c
|
||||
#pragma pack(push, 1)
|
||||
struct Mat0PrefixV4Plus {
|
||||
uint16_t phase_count;
|
||||
uint16_t animation_block_count;
|
||||
uint8_t metadata_a;
|
||||
uint8_t metadata_b;
|
||||
uint32_t metadata_c_raw;
|
||||
uint32_t metadata_d_raw;
|
||||
};
|
||||
|
||||
struct Phase34 {
|
||||
uint8_t parameters[18];
|
||||
char texture_name[16];
|
||||
};
|
||||
#pragma pack(pop)
|
||||
```
|
||||
|
||||
Versioned fields читаются только если версия их содержит. Для старых версий
|
||||
используются runtime defaults, а raw values сохраняются.
|
||||
|
||||
## Fallback
|
||||
|
||||
Material resolve:
|
||||
|
||||
1. имя из WEAR;
|
||||
2. `DEFAULT`;
|
||||
3. entry с индексом 0.
|
||||
|
||||
Пустое texture name означает намеренно нетекстурированную поверхность. Lightmap
|
||||
fallback отдельный: отсутствующий lightmap даёт slot `-1`.
|
||||
@@ -0,0 +1,82 @@
|
||||
# MSH
|
||||
|
||||
Файл `*.msh` является NRes-контейнером. Geometry, узлы, slots, batches,
|
||||
animation и служебные streams лежат в entries с разными `type_id`.
|
||||
|
||||
## Entry map
|
||||
|
||||
```text
|
||||
type 1 nodes and slot selection
|
||||
type 2 header 0x8C + Slot68 records
|
||||
type 3 positions float3
|
||||
type 4 packed normals
|
||||
type 5 packed UV0
|
||||
type 6 index buffer u16
|
||||
type 7 triangle descriptors
|
||||
type 8 animation keys
|
||||
type 9 service stream
|
||||
type 10 strings and node names
|
||||
type 13 Batch20 records
|
||||
type 15 auxiliary stream
|
||||
type 17 auxiliary data
|
||||
type 18 rare stream
|
||||
type 19 animation frame map
|
||||
type 20 rare auxiliary table
|
||||
```
|
||||
|
||||
Reader ищет entries по type, но сохраняет исходный порядок для roundtrip.
|
||||
|
||||
## Node and slot selection
|
||||
|
||||
Type 1 обычно состоит из records по 38 bytes:
|
||||
|
||||
```c
|
||||
struct Node38 {
|
||||
uint16_t hdr0;
|
||||
uint16_t parent_or_link;
|
||||
uint16_t anim_map_start;
|
||||
uint16_t fallback_key;
|
||||
uint16_t slot_index[15];
|
||||
};
|
||||
```
|
||||
|
||||
`slot_index[lod * 5 + group]` выбирает geometry slot. `0xFFFF` означает
|
||||
отсутствие геометрии для комбинации LOD/group.
|
||||
|
||||
## Slot and batch
|
||||
|
||||
Type 2 содержит header `0x8C`, затем `Slot68`:
|
||||
|
||||
```c
|
||||
struct Slot68 {
|
||||
uint16_t tri_start;
|
||||
uint16_t tri_count;
|
||||
uint16_t batch_start;
|
||||
uint16_t batch_count;
|
||||
float aabb_min[3];
|
||||
float aabb_max[3];
|
||||
float sphere_center[3];
|
||||
float sphere_radius;
|
||||
uint32_t opaque[5];
|
||||
};
|
||||
```
|
||||
|
||||
Type 13 задаёт draw ranges:
|
||||
|
||||
```c
|
||||
#pragma pack(push, 1)
|
||||
struct Batch20 {
|
||||
uint16_t batch_flags;
|
||||
uint16_t material_index;
|
||||
uint16_t opaque4;
|
||||
uint16_t opaque6;
|
||||
uint16_t index_count;
|
||||
uint32_t index_start;
|
||||
uint16_t opaque14;
|
||||
uint32_t base_vertex;
|
||||
};
|
||||
#pragma pack(pop)
|
||||
```
|
||||
|
||||
Index check выполняется как `base_vertex + index < vertex_count` для всего
|
||||
используемого slice.
|
||||
@@ -0,0 +1,61 @@
|
||||
# NRes
|
||||
|
||||
`NRes` -- основной контейнер ресурсов Iron3D. Он используется как внешний
|
||||
архив и как внутренний контейнер модели `*.msh`.
|
||||
|
||||
```text
|
||||
[Header: 16 bytes]
|
||||
[Data region: payload with alignment]
|
||||
[Directory: entry_count * 64 bytes]
|
||||
```
|
||||
|
||||
## Header
|
||||
|
||||
```c
|
||||
struct NResHeader16 {
|
||||
char magic[4]; // "NRes"
|
||||
uint32_t version; // 0x00000100
|
||||
int32_t entry_count; // >= 0
|
||||
uint32_t total_size; // equals file size
|
||||
};
|
||||
```
|
||||
|
||||
`directory_offset = total_size - entry_count * 64`. Reader проверяет отсутствие
|
||||
переполнений, `directory_offset >= 16` и точное окончание каталога на
|
||||
`total_size`.
|
||||
|
||||
## Entry
|
||||
|
||||
```c
|
||||
#pragma pack(push, 1)
|
||||
struct NResEntry64 {
|
||||
uint32_t type_id;
|
||||
uint32_t attr1;
|
||||
uint32_t attr2;
|
||||
uint32_t size;
|
||||
uint32_t attr3;
|
||||
char name[36];
|
||||
uint32_t data_offset;
|
||||
uint32_t sort_index;
|
||||
};
|
||||
#pragma pack(pop)
|
||||
```
|
||||
|
||||
Имя содержит bounded C-string до 35 полезных bytes. `sort_index` задаёт
|
||||
отображение из sorted position в original entry index. В строгом режиме все
|
||||
`sort_index` образуют перестановку `0..N-1`.
|
||||
|
||||
## Data region
|
||||
|
||||
Payload каждой записи лежит после header и до начала каталога. Игровые архивы
|
||||
выравнивают следующий payload до 8 bytes нулями, но reader не должен требовать
|
||||
плотного покрытия data region.
|
||||
|
||||
Различаются:
|
||||
|
||||
- active payload -- диапазон, на который указывает entry;
|
||||
- gap/padding -- bytes между активными диапазонами;
|
||||
- unindexed preserved region -- произвольные bytes, не принадлежащие entry.
|
||||
|
||||
Lossless editor сохраняет все три категории. Compact writer может исключить
|
||||
unindexed regions только при явной операции repack.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Render frame
|
||||
|
||||
Кадр является последней стадией цикла, а не самостоятельной функцией renderer-а.
|
||||
До draw calls уже накоплен input, рассчитан tick, применены отложенные операции,
|
||||
выбрана камера и обновлён 3D sound listener.
|
||||
|
||||
## Frame skeleton
|
||||
|
||||
```text
|
||||
system messages and input
|
||||
-> simulation calculation
|
||||
-> deferred object operations
|
||||
-> animation and transforms
|
||||
-> camera and sound listener
|
||||
-> visibility and render queues
|
||||
-> materials and draw passes
|
||||
-> renderer completion
|
||||
-> end-of-render callbacks and UI
|
||||
```
|
||||
|
||||
В `World3D::stdRenderGame` доказан крупный порядок: camera передаётся Terrain,
|
||||
настраиваются viewport/matrices, вызываются renderer boundary slots,
|
||||
устанавливается `in_render`, выполняется traversal мира, закрывается world/shade
|
||||
pass, вызывается renderer completion, снимается `in_render`, рассылается
|
||||
end-of-render.
|
||||
|
||||
## Draw item
|
||||
|
||||
Подготовленный draw item содержит:
|
||||
|
||||
- node world matrix;
|
||||
- batch flags and index range;
|
||||
- WEAR material handle;
|
||||
- MAT0 active phase and coefficients;
|
||||
- texture handle;
|
||||
- optional lightmap handle;
|
||||
- render phase and sorting key;
|
||||
- legacy pipeline state.
|
||||
|
||||
Подготовленный item должен ссылаться на immutable данные кадра. Изменение phase
|
||||
или texture cache посреди прохода не должно менять уже собранную очередь.
|
||||
|
||||
## Parity risks
|
||||
|
||||
- x87 precision and rounding;
|
||||
- scalar/SIMD `g_FastProc` differences;
|
||||
- object, batch and transparent primitive order;
|
||||
- depth, cull, alpha test and blend transitions;
|
||||
- mip-skip, palette and Page coordinates;
|
||||
- material fallback and phase selection;
|
||||
- RNG sequence for FX and atmosphere;
|
||||
- device capability fallback;
|
||||
- simulation time quantization.
|
||||
|
||||
Для отладки нужен deterministic frame capture: camera state, visible object IDs,
|
||||
draw-item list, pipeline keys, matrices и hashes промежуточных buffers.
|
||||
@@ -0,0 +1,69 @@
|
||||
# RsLi
|
||||
|
||||
`RsLi` -- библиотечный архив Iron3D с каталогом в начале файла и payloads после
|
||||
него.
|
||||
|
||||
```text
|
||||
[Header: 32 bytes]
|
||||
[Entry table: entry_count * 32 bytes]
|
||||
[Payloads]
|
||||
[optional trailer]
|
||||
```
|
||||
|
||||
## Header fields
|
||||
|
||||
```text
|
||||
+0x00 char[2] "NL"
|
||||
+0x02 u8 reserved
|
||||
+0x03 u8 version = 1
|
||||
+0x04 i16 entry_count
|
||||
+0x0E u16 presorted_flag = 0xABBA
|
||||
+0x14 u32 xor_seed
|
||||
```
|
||||
|
||||
Остальные bytes сохраняются без нормализации.
|
||||
|
||||
## Entry
|
||||
|
||||
```c
|
||||
struct RsLiEntry32 {
|
||||
char name[12];
|
||||
uint8_t service[4];
|
||||
int16_t flags;
|
||||
int16_t sort_to_original;
|
||||
uint32_t unpacked_size;
|
||||
uint32_t data_offset_raw;
|
||||
uint32_t packed_size;
|
||||
};
|
||||
```
|
||||
|
||||
Имя обычно хранится в uppercase ASCII. `sort_to_original` связывает sorted
|
||||
position с исходной записью.
|
||||
|
||||
## Table transform
|
||||
|
||||
Entry table проходит обратимое потоковое XOR-преобразование. Начальное
|
||||
состояние берётся из младших 16 bits `xor_seed` и продолжается через всю
|
||||
таблицу, не сбрасываясь на границе записи.
|
||||
|
||||
## Storage methods
|
||||
|
||||
```text
|
||||
0x000 raw block
|
||||
0x020 byte transform only
|
||||
0x040 LZSS
|
||||
0x060 transform + LZSS
|
||||
0x080 adaptive Huffman + LZSS
|
||||
0x0A0 transform + adaptive Huffman + LZSS
|
||||
0x100 raw Deflate
|
||||
```
|
||||
|
||||
После любого пути должно получиться ровно `unpacked_size` bytes. Методы
|
||||
`0x080` и `0x0A0` подтверждены decoder-кодом, но не живыми payload демоверсии
|
||||
или обеих частей.
|
||||
|
||||
## Compatibility quirk
|
||||
|
||||
`sprites.lib::INTERF8.TEX` объявляет Deflate range на один byte дальше EOF.
|
||||
Совместимый reader допускает `packed_size - 1` только для этого именованного
|
||||
случая. Строгий режим сообщает `deflate_eof_plus_one`.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Texm
|
||||
|
||||
`Texm` -- основной формат изображений Iron3D. Payload содержит header,
|
||||
необязательную палитру, mip chain и иногда `Page` chunk.
|
||||
|
||||
```c
|
||||
struct TexmHeader32 {
|
||||
uint32_t magic; // 'Texm'
|
||||
uint32_t width;
|
||||
uint32_t height;
|
||||
uint32_t mip_count;
|
||||
uint32_t flags4;
|
||||
uint32_t flags5;
|
||||
uint32_t unknown6;
|
||||
uint32_t format;
|
||||
};
|
||||
```
|
||||
|
||||
## Pixel formats
|
||||
|
||||
```text
|
||||
0 Indexed8 + palette 256 * 4 bytes
|
||||
565 R5 G6 B5
|
||||
556 R5 G5 B6
|
||||
4444 A4 R4 G4 B4
|
||||
88 L8 A8
|
||||
888 RGB8 in four-byte element
|
||||
8888 A8 R8 G8 B8
|
||||
```
|
||||
|
||||
Короткие каналы расширяются до 8 bits повторением значимых bits. Для 888
|
||||
служебный четвёртый byte сохраняется при roundtrip.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
TexmHeader32
|
||||
[palette 1024 bytes, only for format 0]
|
||||
level 0 pixels
|
||||
level 1 pixels
|
||||
...
|
||||
level mip_count-1 pixels
|
||||
[optional Page chunk]
|
||||
```
|
||||
|
||||
Размер mip level вычисляется через `max(1, width >> i)` и
|
||||
`max(1, height >> i)`. Parser суммирует размеры с проверкой переполнения до
|
||||
чтения данных.
|
||||
|
||||
## Page chunk
|
||||
|
||||
```c
|
||||
struct PageHeader8 {
|
||||
uint32_t magic; // 'Page'
|
||||
uint32_t rect_count;
|
||||
};
|
||||
|
||||
struct PageRect8 {
|
||||
int16_t x;
|
||||
int16_t width;
|
||||
int16_t y;
|
||||
int16_t height;
|
||||
};
|
||||
```
|
||||
|
||||
Chunk обязан иметь размер `8 + rect_count * 8`. Rectangles находятся в pixel
|
||||
space базового mip и масштабируются после mip-skip.
|
||||
@@ -0,0 +1,64 @@
|
||||
# TMA
|
||||
|
||||
`data.tma` -- основное описание расстановки и логической конфигурации миссии.
|
||||
Файл перечисляет paths, clans, objects, свойства, ссылку на ландшафт и extras.
|
||||
|
||||
## String primitive
|
||||
|
||||
```c
|
||||
struct LpString {
|
||||
uint32_t byte_length;
|
||||
uint8_t bytes[byte_length];
|
||||
};
|
||||
```
|
||||
|
||||
Reader продвигается ровно на `4 + byte_length`. Завершающий NUL не является
|
||||
обязательной частью framing. Для человекочитаемого вида используется legacy
|
||||
ANSI/CP1251 view, но исходные bytes сохраняются.
|
||||
|
||||
## Top level
|
||||
|
||||
```text
|
||||
u32 format_version
|
||||
u32 path_count
|
||||
PathRecord paths[path_count]
|
||||
u32 clan_section_version
|
||||
u32 clan_count
|
||||
ClanRecord clans[clan_count]
|
||||
u32 object_section_version
|
||||
u32 object_count
|
||||
PlacedObject objects[object_count]
|
||||
LpString land_path
|
||||
u32 mission_flag
|
||||
LpString description_raw
|
||||
u32 extra_section_version
|
||||
u32 extra_count
|
||||
ExtraRecord28 extras[extra_count]
|
||||
```
|
||||
|
||||
Все 60 TMA Частей 1 и 2 проходят parser до точного EOF. Версии стабильны:
|
||||
верхний уровень `1`, clan section `6`, object section `10`, property schema
|
||||
`1`, trailing section `1`.
|
||||
|
||||
## PlacedObject
|
||||
|
||||
```text
|
||||
u32 raw_kind
|
||||
u32 class_or_flags
|
||||
LpString resource_name
|
||||
u32 raw_after_resource
|
||||
u32 identity_or_clan_raw
|
||||
f32 position[3]
|
||||
f32 orientation[3]
|
||||
f32 scale[3]
|
||||
LpString instance_name
|
||||
u32 raw_after_name
|
||||
i32 link0
|
||||
i32 link1
|
||||
u32 property_schema_version
|
||||
u32 property_count
|
||||
Property properties[property_count]
|
||||
```
|
||||
|
||||
`Property` состоит из четырёх raw `u32` и имени. Typed views разрешены только
|
||||
для доказанных property names и consumers.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user