Compare commits
77 Commits
master
...
16027e7124
| Author | SHA1 | Date | |
|---|---|---|---|
| 16027e7124 | |||
| 27af3806b3 | |||
|
021b1c8dac
|
|||
|
278567d6de
|
|||
|
7eced77483
|
|||
|
d41add32c4
|
|||
|
159731664f
|
|||
|
e6b7fa1896
|
|||
|
0e127117e9
|
|||
|
4d19728c39
|
|||
|
54f07ee3be
|
|||
|
ed2b540abf
|
|||
|
00ae9067d8
|
|||
|
c71e706d69
|
|||
|
aa2133d82b
|
|||
|
71ead678c0
|
|||
|
f15ea95bf2
|
|||
|
99bcbf388f
|
|||
|
227d95fc49
|
|||
|
dceea70122
|
|||
|
fd452f6016
|
|||
|
1d0244c3e4
|
|||
|
5d9e1cbe38
|
|||
|
0e76c2ed7c
|
|||
|
4c1edef21b
|
|||
|
e6778d43af
|
|||
|
ec8f6599fc
|
|||
|
f5fae8e84a
|
|||
|
a0a4089e4b
|
|||
|
dc7e72961a
|
|||
|
8ea1fd5c18
|
|||
|
69c032acca
|
|||
|
9cc24e715d
|
|||
|
f8e447ffee
|
|||
|
83d763dd70
|
|||
|
162de8ccab
|
|||
|
0b23cf48e7
|
|||
|
7356238ffb
|
|||
|
42441082f0
|
|||
|
ccd61c05b0
|
|||
|
813beec7be
|
|||
|
91c7a8a14e
|
|||
|
8b91a0bfbf
|
|||
|
fb97405e0c
|
|||
|
d579b696e6
|
|||
|
aa1b809bd8
|
|||
|
f69c893a40
|
|||
|
5436727961
|
|||
|
be41fa839f
|
|||
|
8e5e46b7b3
|
|||
|
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
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: fparkan-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
msrv-backend-neutral:
|
||||
name: MSRV backend-neutral crates
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: 1.87.0
|
||||
- name: Test backend-neutral crates
|
||||
run: >
|
||||
cargo test
|
||||
-p fparkan-animation
|
||||
-p fparkan-binary
|
||||
-p fparkan-corpus
|
||||
-p fparkan-diagnostics
|
||||
-p fparkan-fx
|
||||
-p fparkan-inspection
|
||||
-p fparkan-material
|
||||
-p fparkan-mission-format
|
||||
-p fparkan-msh
|
||||
-p fparkan-nres
|
||||
-p fparkan-path
|
||||
-p fparkan-platform
|
||||
-p fparkan-prototype
|
||||
-p fparkan-render
|
||||
-p fparkan-resource
|
||||
-p fparkan-rsli
|
||||
-p fparkan-runtime
|
||||
-p fparkan-terrain
|
||||
-p fparkan-terrain-format
|
||||
-p fparkan-texm
|
||||
-p fparkan-vfs
|
||||
-p fparkan-world
|
||||
--all-targets
|
||||
--locked
|
||||
|
||||
stage0-matrix:
|
||||
name: Stage 0-2 CI (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
smoke_platform: linux
|
||||
- os: windows-latest
|
||||
smoke_platform: windows
|
||||
- os: macos-latest
|
||||
smoke_platform: macos
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain-file: rust-toolchain.toml
|
||||
- name: Install cargo-deny
|
||||
run: cargo install cargo-deny --locked
|
||||
- name: Run canonical CI gate
|
||||
run: cargo xtask ci
|
||||
- name: Record native Vulkan smoke status
|
||||
if: always()
|
||||
shell: bash
|
||||
run: >
|
||||
cargo run -p fparkan-vulkan-smoke --locked --
|
||||
--platform "${{ matrix.smoke_platform }}"
|
||||
--out "target/fparkan/native-smoke/${{ runner.os }}.json"
|
||||
--status blocked
|
||||
--probe-surface
|
||||
--reason "native Vulkan smoke runner is not enabled on this CI lane yet"
|
||||
- name: Upload acceptance evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: stage-0-2-acceptance-${{ matrix.os }}
|
||||
path: |
|
||||
target/fparkan/acceptance/stage-0-2-audit.json
|
||||
target/fparkan/native-smoke/*.json
|
||||
if-no-files-found: ignore
|
||||
@@ -69,10 +69,6 @@ $RECYCLE.BIN/
|
||||
debug/
|
||||
target/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
|
||||
Generated
+2233
File diff suppressed because it is too large
Load Diff
+60
-1
@@ -1,6 +1,65 @@
|
||||
[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-inspection",
|
||||
"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-winit",
|
||||
"adapters/fparkan-render-vulkan",
|
||||
"apps/fparkan-cli",
|
||||
"apps/fparkan-game",
|
||||
"apps/fparkan-headless",
|
||||
"apps/fparkan-vulkan-smoke",
|
||||
"apps/fparkan-viewer",
|
||||
"xtask",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.87"
|
||||
license = "GPL-2.0-only"
|
||||
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,51 @@ 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 использует некоммитимый manifest:
|
||||
|
||||
```bash
|
||||
cat > /private/tmp/fparkan-corpora.toml <<'EOF'
|
||||
schema = 1
|
||||
|
||||
[[corpus]]
|
||||
id = "part1-local"
|
||||
kind = "part1"
|
||||
root = "/absolute/path/to/IS"
|
||||
expected_profile = "parkan-is-part1"
|
||||
|
||||
[[corpus]]
|
||||
id = "part2-local"
|
||||
kind = "part2"
|
||||
root = "/absolute/path/to/IS2"
|
||||
expected_profile = "parkan-is-part2"
|
||||
EOF
|
||||
|
||||
FPARKAN_CORPORA_MANIFEST=/private/tmp/fparkan-corpora.toml \
|
||||
cargo xtask acceptance report --suite licensed --stage 5
|
||||
```
|
||||
|
||||
## Contributing & Support
|
||||
|
||||
Проект активно поддерживается и открыт для contribution. Issues и pull requests можно создавать в обоих репозиториях:
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "fparkan-platform-winit"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||
raw-window-handle = "0.6"
|
||||
winit = "0.30"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,509 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Minimal `winit`-backed platform adapter shim.
|
||||
|
||||
use fparkan_platform::{
|
||||
EventSource, MonotonicClock, MonotonicInstant, NativeWindowHandles, PhysicalSize,
|
||||
PlatformError, PlatformEvent, RenderRequest, WindowHandle, WindowPort,
|
||||
};
|
||||
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::dpi::PhysicalSize as WinitPhysicalSize;
|
||||
use winit::event::{Event, MouseButton, WindowEvent};
|
||||
use winit::event_loop::{ActiveEventLoop, EventLoop};
|
||||
use winit::platform::scancode::PhysicalKeyExtScancode;
|
||||
use winit::window::{Window, WindowId};
|
||||
|
||||
static NEXT_WINDOW_HANDLE_ID: AtomicU64 = AtomicU64::new(1);
|
||||
const DEFAULT_SMOKE_WIDTH: u32 = 1280;
|
||||
const DEFAULT_SMOKE_HEIGHT: u32 = 720;
|
||||
|
||||
fn next_window_id() -> u64 {
|
||||
NEXT_WINDOW_HANDLE_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Simple monotonic clock for windowing abstractions.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct WinitClock;
|
||||
|
||||
impl MonotonicClock for WinitClock {
|
||||
fn now(&self) -> MonotonicInstant {
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
MonotonicInstant(duration.as_millis().try_into().unwrap_or(u64::MAX))
|
||||
}
|
||||
}
|
||||
|
||||
/// Event source backed by pre-buffered platform events.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct WinitEventSource {
|
||||
queue: VecDeque<PlatformEvent>,
|
||||
}
|
||||
|
||||
impl WinitEventSource {
|
||||
/// Creates an empty source.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
queue: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes a synthetic event (used by tests and smoke stubs).
|
||||
pub fn push(&mut self, event: PlatformEvent) {
|
||||
self.queue.push_back(event);
|
||||
}
|
||||
|
||||
/// Pushes a mapped native window event.
|
||||
pub fn push_window_event(&mut self, event: &WindowEvent) {
|
||||
match event {
|
||||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
self.queue.push_back(PlatformEvent::KeyboardInput {
|
||||
scancode: event.physical_key.to_scancode().unwrap_or(0),
|
||||
pressed: event.state.is_pressed(),
|
||||
});
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
self.queue.push_back(PlatformEvent::MouseInput {
|
||||
button: mouse_button_code(*button),
|
||||
pressed: state.is_pressed(),
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
});
|
||||
}
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
self.queue.push_back(PlatformEvent::CursorMoved {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
});
|
||||
}
|
||||
WindowEvent::Resized(size) => {
|
||||
self.queue.push_back(PlatformEvent::Resize {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
});
|
||||
}
|
||||
WindowEvent::Focused(focused) => {
|
||||
self.queue
|
||||
.push_back(PlatformEvent::FocusChanged { focused: *focused });
|
||||
}
|
||||
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
|
||||
self.queue.push_back(PlatformEvent::DpiChanged {
|
||||
scale: *scale_factor,
|
||||
});
|
||||
}
|
||||
WindowEvent::CloseRequested => {
|
||||
self.queue.push_back(PlatformEvent::QuitRequested);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes events from an event loop event.
|
||||
pub fn push_event<T>(&mut self, event: &Event<T>) {
|
||||
if let Event::WindowEvent { event, .. } = event {
|
||||
self.push_window_event(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_button_code(button: MouseButton) -> u16 {
|
||||
match button {
|
||||
MouseButton::Left => 0,
|
||||
MouseButton::Right => 1,
|
||||
MouseButton::Middle => 2,
|
||||
MouseButton::Back => 3,
|
||||
MouseButton::Forward => 4,
|
||||
MouseButton::Other(index) => 100 + index,
|
||||
}
|
||||
}
|
||||
|
||||
impl EventSource for WinitEventSource {
|
||||
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError> {
|
||||
while let Some(event) = self.queue.pop_front() {
|
||||
out.push(event);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Window creation plan for native smoke entrypoints.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct WinitWindowPlan {
|
||||
/// Requested drawable width in physical pixels.
|
||||
pub width: u32,
|
||||
/// Requested drawable height in physical pixels.
|
||||
pub height: u32,
|
||||
/// Whether native window/display handles are required by the caller.
|
||||
pub requires_native_handles: bool,
|
||||
}
|
||||
|
||||
impl WinitWindowPlan {
|
||||
/// Returns the Stage 0 native smoke window plan.
|
||||
#[must_use]
|
||||
pub const fn smoke() -> Self {
|
||||
Self {
|
||||
width: DEFAULT_SMOKE_WIDTH,
|
||||
height: DEFAULT_SMOKE_HEIGHT,
|
||||
requires_native_handles: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the window plan before a native event loop is entered.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PlatformError`] when the drawable extent is zero.
|
||||
pub fn validate(self) -> Result<Self, PlatformError> {
|
||||
if self.width == 0 || self.height == 0 {
|
||||
return Err(PlatformError::Backend {
|
||||
context: "winit window plan",
|
||||
message: "drawable extent must be non-zero".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Native smoke window creation result.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct WinitSmokeWindowProbe {
|
||||
/// Validated creation plan.
|
||||
pub plan: WinitWindowPlan,
|
||||
/// Captured window descriptor.
|
||||
pub window: WinitWindow,
|
||||
}
|
||||
|
||||
impl WinitSmokeWindowProbe {
|
||||
/// Returns raw native handles captured from the native window.
|
||||
#[must_use]
|
||||
pub fn native_handles(&self) -> Option<NativeWindowHandles> {
|
||||
self.window.native_handles()
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a native smoke window, captures raw handles, then exits the event loop.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PlatformError`] when the plan is invalid, the event loop/window
|
||||
/// cannot be created, or raw native handles are unavailable.
|
||||
pub fn probe_smoke_window() -> Result<WinitSmokeWindowProbe, PlatformError> {
|
||||
let plan = WinitWindowPlan::smoke().validate()?;
|
||||
let event_loop = EventLoop::new().map_err(|err| PlatformError::Backend {
|
||||
context: "winit event loop",
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
let mut app = SmokeWindowApp::new(plan);
|
||||
event_loop
|
||||
.run_app(&mut app)
|
||||
.map_err(|err| PlatformError::Backend {
|
||||
context: "winit event loop",
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
app.into_probe()
|
||||
}
|
||||
|
||||
struct SmokeWindowApp {
|
||||
plan: WinitWindowPlan,
|
||||
window: Option<WinitWindow>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl SmokeWindowApp {
|
||||
const fn new(plan: WinitWindowPlan) -> Self {
|
||||
Self {
|
||||
plan,
|
||||
window: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_probe(self) -> Result<WinitSmokeWindowProbe, PlatformError> {
|
||||
if let Some(message) = self.error {
|
||||
return Err(PlatformError::Backend {
|
||||
context: "winit smoke window",
|
||||
message,
|
||||
});
|
||||
}
|
||||
let window = self.window.ok_or_else(|| PlatformError::Backend {
|
||||
context: "winit smoke window",
|
||||
message: "event loop exited before creating a window".to_string(),
|
||||
})?;
|
||||
if self.plan.requires_native_handles && window.native_handles().is_none() {
|
||||
return Err(PlatformError::Backend {
|
||||
context: "winit smoke window",
|
||||
message: "native window/display handles are unavailable".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(WinitSmokeWindowProbe {
|
||||
plan: self.plan,
|
||||
window,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler for SmokeWindowApp {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() || self.error.is_some() {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
let attributes = Window::default_attributes()
|
||||
.with_title("FParkan Vulkan smoke")
|
||||
.with_inner_size(WinitPhysicalSize::new(self.plan.width, self.plan.height));
|
||||
match event_loop.create_window(attributes) {
|
||||
Ok(window) => {
|
||||
self.window = Some(WinitWindow::from_window(&window));
|
||||
}
|
||||
Err(err) => {
|
||||
self.error = Some(err.to_string());
|
||||
}
|
||||
}
|
||||
event_loop.exit();
|
||||
}
|
||||
|
||||
fn window_event(
|
||||
&mut self,
|
||||
_event_loop: &ActiveEventLoop,
|
||||
_window_id: WindowId,
|
||||
_event: WindowEvent,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal window view over a `winit` window.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct WinitWindow {
|
||||
handle: WindowHandle,
|
||||
width: u32,
|
||||
height: u32,
|
||||
scale: f64,
|
||||
focused: bool,
|
||||
minimized: bool,
|
||||
occluded: bool,
|
||||
native_handles: Option<NativeWindowHandles>,
|
||||
}
|
||||
|
||||
impl WinitWindow {
|
||||
/// Builds a stable descriptor from a `winit` window.
|
||||
#[must_use]
|
||||
pub fn from_window(window: &Window) -> Self {
|
||||
let scale = window.scale_factor();
|
||||
let size = window.inner_size();
|
||||
Self {
|
||||
handle: WindowHandle {
|
||||
id: next_window_id(),
|
||||
},
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
scale,
|
||||
focused: true,
|
||||
minimized: false,
|
||||
occluded: false,
|
||||
native_handles: native_handles(window),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns conservative defaults if a native window is not available yet.
|
||||
#[must_use]
|
||||
pub fn synthetic(width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
handle: WindowHandle {
|
||||
id: next_window_id(),
|
||||
},
|
||||
width,
|
||||
height,
|
||||
scale: 1.0,
|
||||
focused: true,
|
||||
minimized: false,
|
||||
occluded: false,
|
||||
native_handles: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns requested default render profile for integration points.
|
||||
#[must_use]
|
||||
pub const fn default_render_request() -> RenderRequest {
|
||||
RenderRequest::conservative()
|
||||
}
|
||||
}
|
||||
|
||||
impl WindowPort for WinitWindow {
|
||||
fn drawable_size(&self) -> PhysicalSize {
|
||||
PhysicalSize {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
}
|
||||
}
|
||||
|
||||
fn dpi_scale(&self) -> f64 {
|
||||
self.scale
|
||||
}
|
||||
|
||||
fn has_focus(&self) -> bool {
|
||||
self.focused
|
||||
}
|
||||
|
||||
fn is_minimized(&self) -> bool {
|
||||
self.minimized
|
||||
}
|
||||
|
||||
fn is_occluded(&self) -> bool {
|
||||
self.occluded
|
||||
}
|
||||
|
||||
fn handle(&self) -> WindowHandle {
|
||||
self.handle
|
||||
}
|
||||
|
||||
fn native_handles(&self) -> Option<NativeWindowHandles> {
|
||||
self.native_handles
|
||||
}
|
||||
}
|
||||
|
||||
fn native_handles(window: &Window) -> Option<NativeWindowHandles> {
|
||||
let display = window.display_handle().ok()?.as_raw();
|
||||
let window = window.window_handle().ok()?.as_raw();
|
||||
Some(NativeWindowHandles { display, window })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn event_source_buffers_synthetic_events() -> Result<(), PlatformError> {
|
||||
let mut source = WinitEventSource::new();
|
||||
source.push(PlatformEvent::Resumed);
|
||||
source.push(PlatformEvent::QuitRequested);
|
||||
let mut events = Vec::new();
|
||||
source.poll(&mut events)?;
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![PlatformEvent::Resumed, PlatformEvent::QuitRequested]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_port_reports_default_request_profile() {
|
||||
let window = WinitWindow::synthetic(640, 360);
|
||||
let request = WinitWindow::default_render_request();
|
||||
assert_eq!(
|
||||
request.presentation,
|
||||
fparkan_platform::PresentationMode::Fifo
|
||||
);
|
||||
assert_eq!(
|
||||
window.drawable_size(),
|
||||
PhysicalSize {
|
||||
width: 640,
|
||||
height: 360
|
||||
}
|
||||
);
|
||||
assert!(window.native_handles().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoke_window_plan_requires_native_handles_and_nonzero_extent() -> Result<(), PlatformError> {
|
||||
let plan = WinitWindowPlan::smoke().validate()?;
|
||||
|
||||
assert_eq!(plan.width, DEFAULT_SMOKE_WIDTH);
|
||||
assert_eq!(plan.height, DEFAULT_SMOKE_HEIGHT);
|
||||
assert!(plan.requires_native_handles);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoke_window_plan_rejects_zero_extent() {
|
||||
let plan = WinitWindowPlan {
|
||||
width: 0,
|
||||
height: DEFAULT_SMOKE_HEIGHT,
|
||||
requires_native_handles: true,
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
plan.validate(),
|
||||
Err(PlatformError::Backend {
|
||||
context: "winit window plan",
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoke_window_app_requires_created_native_window() {
|
||||
let app = SmokeWindowApp::new(WinitWindowPlan::smoke());
|
||||
|
||||
assert!(matches!(
|
||||
app.into_probe(),
|
||||
Err(PlatformError::Backend {
|
||||
context: "winit smoke window",
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoke_window_app_rejects_synthetic_window_without_native_handles() {
|
||||
let mut app = SmokeWindowApp::new(WinitWindowPlan::smoke());
|
||||
app.window = Some(WinitWindow::synthetic(
|
||||
DEFAULT_SMOKE_WIDTH,
|
||||
DEFAULT_SMOKE_HEIGHT,
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
app.into_probe(),
|
||||
Err(PlatformError::Backend {
|
||||
context: "winit smoke window",
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_events_push_expected_platform_events() {
|
||||
let mut source = WinitEventSource::new();
|
||||
let size = winit::dpi::PhysicalSize::new(1024u32, 768u32);
|
||||
|
||||
source.push_window_event(&WindowEvent::Resized(size));
|
||||
source.push_window_event(&WindowEvent::Focused(false));
|
||||
source.push_window_event(&WindowEvent::CloseRequested);
|
||||
|
||||
let mut events = Vec::new();
|
||||
source
|
||||
.poll(&mut events)
|
||||
.expect("platform event pump should never fail");
|
||||
|
||||
assert!(events.contains(&PlatformEvent::Resize {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
}));
|
||||
assert!(events.contains(&PlatformEvent::FocusChanged { focused: false }));
|
||||
assert!(events.contains(&PlatformEvent::QuitRequested));
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: no unsafe usage in this crate.
|
||||
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "fparkan-render-vulkan"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ash = "0.38"
|
||||
ash-window = "0.13"
|
||||
fparkan-binary = { path = "../../crates/fparkan-binary" }
|
||||
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||
fparkan-render = { path = "../../crates/fparkan-render" }
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "allow"
|
||||
missing_docs = "warn"
|
||||
unreachable_pub = "warn"
|
||||
unused_must_use = "deny"
|
||||
|
||||
[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"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,26 @@
|
||||
# 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; local Objective-C/CGL/SDL/OpenGL FFI
|
||||
inside FParkan is not an acceptable implementation strategy.
|
||||
|
||||
The current adapter crates are safe boundary stubs. They compile the intended
|
||||
ports and deterministic command contracts, but they do not create SDL windows,
|
||||
GL contexts, GPU resources, shaders, draw calls, swapchains, or presents. They
|
||||
must not be treated as backend readiness evidence.
|
||||
|
||||
To close the macOS backend requirement, choose and vendor/lock a maintained
|
||||
safe facade stack, then implement:
|
||||
|
||||
- SDL event source, window creation, GL context lifecycle, drawable size and
|
||||
present;
|
||||
- GL shader compile/link, buffer/texture upload, render state, draw calls and
|
||||
diagnostics;
|
||||
- game/viewer composition roots using those adapters;
|
||||
- hidden-window/offscreen macOS smoke tests and licensed local model/terrain
|
||||
frame captures.
|
||||
|
||||
Until those are implemented, Desktop GL evidence may document external probes
|
||||
only; it does not satisfy the permanent adapter requirement.
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "fparkan-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-assets = { path = "../../crates/fparkan-assets" }
|
||||
fparkan-corpus = { path = "../../crates/fparkan-corpus" }
|
||||
fparkan-prototype = { path = "../../crates/fparkan-prototype" }
|
||||
fparkan-inspection = { path = "../../crates/fparkan-inspection" }
|
||||
fparkan-resource = { path = "../../crates/fparkan-resource" }
|
||||
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
||||
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,363 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||
//! `FParkan` command-line tools.
|
||||
|
||||
use fparkan_assets::extend_graph_report_with_visual_dependencies;
|
||||
use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
|
||||
use fparkan_inspection::inspect_archive_file;
|
||||
use fparkan_inspection::ArchiveInspection;
|
||||
use fparkan_prototype::build_prototype_graph_report;
|
||||
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).map_err(|e| e.to_string())?;
|
||||
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).map_err(|e| e.to_string())?;
|
||||
if report.casefold_collisions > 0 {
|
||||
return Err("casefold collisions found".to_string());
|
||||
}
|
||||
if report.failures > 0 {
|
||||
return Err(format!("corpus report found {} failures", report.failures));
|
||||
}
|
||||
println!("{}", render_report_json(&report));
|
||||
Ok(())
|
||||
}
|
||||
[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, &graph, &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 inspection = inspect_archive_file(&path, 0).map_err(|err| err.to_string())?;
|
||||
|
||||
match inspection {
|
||||
ArchiveInspection::Nres {
|
||||
entries,
|
||||
lookup_order_valid,
|
||||
..
|
||||
} => {
|
||||
println!(
|
||||
"{}",
|
||||
archive_inspect_json(
|
||||
&path.display().to_string(),
|
||||
"NRes",
|
||||
entries,
|
||||
Some(lookup_order_valid),
|
||||
)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
ArchiveInspection::Rsli { entries } => {
|
||||
println!(
|
||||
"{}",
|
||||
archive_inspect_json(&path.display().to_string(), "RsLi", entries, None)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
ArchiveInspection::Unsupported => {
|
||||
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}", c as u32);
|
||||
}
|
||||
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,19 @@
|
||||
[package]
|
||||
name = "fparkan-game"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-assets = { path = "../../crates/fparkan-assets" }
|
||||
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||
fparkan-render = { path = "../../crates/fparkan-render" }
|
||||
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
|
||||
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
|
||||
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
||||
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
||||
fparkan-world = { path = "../../crates/fparkan-world" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,389 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||
//! `FParkan` rendered game composition root.
|
||||
|
||||
use fparkan_assets::PreparedVisual;
|
||||
use fparkan_platform::WindowPort;
|
||||
use fparkan_platform_winit::WinitWindow;
|
||||
use fparkan_render::{
|
||||
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderBackend, RenderCommand,
|
||||
RenderCommandList, RenderPhase,
|
||||
};
|
||||
use fparkan_render_vulkan::VulkanBackend;
|
||||
use fparkan_runtime::{
|
||||
create, frame, load_mission, loaded_mission_assets, EngineConfig, EngineMode, EngineServices,
|
||||
MissionAssets, 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 = VulkanBackend::new();
|
||||
let _request = WinitWindow::default_render_request();
|
||||
let window = WinitWindow::synthetic(1280, 720);
|
||||
let _ = window.drawable_size();
|
||||
let _ = window.handle();
|
||||
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 mission_assets = loaded_mission_assets(&engine);
|
||||
let commands = render_snapshot_commands_with_assets(&result.snapshot, mission_assets);
|
||||
last_draw_count = commands
|
||||
.commands
|
||||
.iter()
|
||||
.filter(|command| matches!(command, RenderCommand::Draw(_)))
|
||||
.count();
|
||||
backend
|
||||
.execute(&commands)
|
||||
.map_err(|err| format!("render backend: {err}"))?;
|
||||
}
|
||||
|
||||
let capture_report = backend.report();
|
||||
|
||||
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,
|
||||
capture_report.submissions,
|
||||
capture_report.last_capture_size,
|
||||
json_hash(&last_hash)
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn render_snapshot_commands(snapshot: &WorldSnapshot) -> RenderCommandList {
|
||||
render_snapshot_commands_with_assets(snapshot, None)
|
||||
}
|
||||
|
||||
fn render_snapshot_commands_with_assets(
|
||||
snapshot: &WorldSnapshot,
|
||||
mission_assets: Option<&MissionAssets>,
|
||||
) -> 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 prepared = mission_assets.and_then(|assets| {
|
||||
assets
|
||||
.visual_for_object(index)
|
||||
.and_then(|visual_id| assets.visual_by_id(visual_id))
|
||||
});
|
||||
let mesh = if let Some(visual) = prepared {
|
||||
visual.mesh.as_ref().map_or_else(
|
||||
|| GpuMeshId(u64::from(handle.slot) + 1),
|
||||
|_| GpuMeshId(visual.id.raw()),
|
||||
)
|
||||
} else {
|
||||
GpuMeshId(u64::from(handle.slot) + 1)
|
||||
};
|
||||
let material = prepared
|
||||
.and_then(PreparedVisual::primary_material_id)
|
||||
.map_or(GpuMaterialId(1), |material_id| {
|
||||
GpuMaterialId(material_id.raw())
|
||||
});
|
||||
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,
|
||||
material,
|
||||
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}", c as u32);
|
||||
}
|
||||
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]
|
||||
#[ignore = "requires licensed corpus"]
|
||||
fn selected_is_and_is2_missions_produce_approved_render_captures() {
|
||||
for case in [
|
||||
RenderCase {
|
||||
root: "IS",
|
||||
mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma",
|
||||
expected: "{\"mission\":\"MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma\",\"objects\":33,\"frames\":1,\"tick\":1,\"draws\":33,\"captures\":1,\"last_capture_bytes\":810,\"hash\":\"ca17cc76e55c45e83c1c9c1c088e84bf1a698be91a7730943210fe27596af841\"}",
|
||||
},
|
||||
RenderCase {
|
||||
root: "IS2",
|
||||
mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
|
||||
expected: "{\"mission\":\"MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma\",\"objects\":10,\"frames\":1,\"tick\":1,\"draws\":10,\"captures\":1,\"last_capture_bytes\":235,\"hash\":\"5d720b3ab690076a398a79a404850bbeaee2e33811b5bb570ec8a96d4a7a2fc4\"}",
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
run(&render_args(&licensed_root(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 licensed_root(name: &str) -> PathBuf {
|
||||
let variable = match name {
|
||||
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
||||
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
||||
_ => panic!("unknown licensed corpus part: {name}"),
|
||||
};
|
||||
let root = std::env::var_os(variable)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
|
||||
assert!(
|
||||
root.is_dir(),
|
||||
"licensed corpus root is missing: {}",
|
||||
root.display()
|
||||
);
|
||||
root
|
||||
}
|
||||
}
|
||||
@@ -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,133 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
#![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,13 @@
|
||||
[package]
|
||||
name = "fparkan-viewer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-inspection = { path = "../../crates/fparkan-inspection" }
|
||||
fparkan-render = { path = "../../crates/fparkan-render" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,349 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||
//! `FParkan` asset viewer composition root.
|
||||
|
||||
use fparkan_inspection::{
|
||||
inspect_land_file, inspect_model_from_root, inspect_texture_from_root, ArchiveInspection,
|
||||
LandFileKind, MapInspection, NresEntrySummary,
|
||||
};
|
||||
use fparkan_render::{
|
||||
build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderPhase,
|
||||
RenderProfile, RenderSnapshot, RenderSnapshotDraw,
|
||||
};
|
||||
use std::fmt::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
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 inspection = fparkan_inspection::inspect_archive_file(&file, limit)?;
|
||||
|
||||
match inspection {
|
||||
ArchiveInspection::Nres {
|
||||
entries,
|
||||
lookup_order_valid,
|
||||
sample,
|
||||
} => Ok(format!(
|
||||
"{{\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}",
|
||||
json_string(&file.display().to_string()),
|
||||
entries,
|
||||
lookup_order_valid,
|
||||
render_nres_entries(&sample)
|
||||
)),
|
||||
ArchiveInspection::Rsli { entries } => Ok(format!(
|
||||
"{{\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}",
|
||||
json_string(&file.display().to_string()),
|
||||
entries
|
||||
)),
|
||||
ArchiveInspection::Unsupported => 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 inspection = inspect_model_from_root(&query.root, &query.archive, &query.name)?;
|
||||
|
||||
Ok(format!(
|
||||
"{{\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}",
|
||||
json_string(&query.archive),
|
||||
json_string(&query.name),
|
||||
inspection.streams,
|
||||
inspection.nodes,
|
||||
inspection.slots,
|
||||
inspection.positions,
|
||||
inspection.indices,
|
||||
inspection.batches
|
||||
))
|
||||
}
|
||||
|
||||
#[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 inspection = inspect_texture_from_root(&query.root, &query.archive, &query.name)?;
|
||||
|
||||
Ok(format!(
|
||||
"{{\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}",
|
||||
json_string(&query.archive),
|
||||
json_string(&query.name),
|
||||
inspection.width,
|
||||
inspection.height,
|
||||
json_string(&inspection.format),
|
||||
inspection.mips,
|
||||
inspection.pages
|
||||
))
|
||||
}
|
||||
|
||||
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 inspection = inspect_land_file(
|
||||
&file,
|
||||
match kind.as_str() {
|
||||
"land-msh" => LandFileKind::LandMsh,
|
||||
"land-map" => LandFileKind::LandMap,
|
||||
_ => return Err(format!("unknown map kind: {kind}")),
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(render_map_inspection_json(
|
||||
&file.display().to_string(),
|
||||
&kind,
|
||||
&inspection,
|
||||
))
|
||||
}
|
||||
|
||||
fn render_map_inspection_json(path: &str, kind: &str, inspection: &MapInspection) -> String {
|
||||
match kind {
|
||||
"land-msh" => format!(
|
||||
"{{\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}",
|
||||
json_string(path),
|
||||
inspection.streams,
|
||||
inspection.positions,
|
||||
inspection.faces,
|
||||
inspection.slots
|
||||
),
|
||||
"land-map" => format!(
|
||||
"{{\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}",
|
||||
json_string(path),
|
||||
inspection.areals,
|
||||
inspection.declared_areals,
|
||||
inspection.grid_width,
|
||||
inspection.grid_height
|
||||
),
|
||||
_ => unreachable!("invalid land 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 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(entries: &[NresEntrySummary]) -> String {
|
||||
let mut out = String::new();
|
||||
for (index, entry) in entries.iter().enumerate() {
|
||||
if index > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
let name = &entry.name;
|
||||
let _ = write!(
|
||||
out,
|
||||
"{{\"name\":{},\"type\":{},\"size\":{}}}",
|
||||
json_string(name),
|
||||
entry.type_id,
|
||||
entry.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}", c as u32);
|
||||
}
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "fparkan-vulkan-smoke"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
|
||||
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,24 @@
|
||||
[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-mission-format = { path = "../fparkan-mission-format" }
|
||||
fparkan-prototype = { path = "../fparkan-prototype" }
|
||||
fparkan-resource = { path = "../fparkan-resource" }
|
||||
fparkan-texm = { path = "../fparkan-texm" }
|
||||
fparkan-terrain = { path = "../fparkan-terrain" }
|
||||
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,519 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Bounded little-endian binary cursor and checked layout helpers.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// SHA-256 digest bytes.
|
||||
pub type Sha256Digest = [u8; 32];
|
||||
|
||||
/// Parser limits shared by binary formats.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Limits {
|
||||
/// 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())
|
||||
}
|
||||
|
||||
/// Computes a SHA-256 content digest without external dependencies.
|
||||
#[must_use]
|
||||
pub fn sha256(bytes: &[u8]) -> Sha256Digest {
|
||||
const K: [u32; 64] = [
|
||||
0x428a_2f98,
|
||||
0x7137_4491,
|
||||
0xb5c0_fbcf,
|
||||
0xe9b5_dba5,
|
||||
0x3956_c25b,
|
||||
0x59f1_11f1,
|
||||
0x923f_82a4,
|
||||
0xab1c_5ed5,
|
||||
0xd807_aa98,
|
||||
0x1283_5b01,
|
||||
0x2431_85be,
|
||||
0x550c_7dc3,
|
||||
0x72be_5d74,
|
||||
0x80de_b1fe,
|
||||
0x9bdc_06a7,
|
||||
0xc19b_f174,
|
||||
0xe49b_69c1,
|
||||
0xefbe_4786,
|
||||
0x0fc1_9dc6,
|
||||
0x240c_a1cc,
|
||||
0x2de9_2c6f,
|
||||
0x4a74_84aa,
|
||||
0x5cb0_a9dc,
|
||||
0x76f9_88da,
|
||||
0x983e_5152,
|
||||
0xa831_c66d,
|
||||
0xb003_27c8,
|
||||
0xbf59_7fc7,
|
||||
0xc6e0_0bf3,
|
||||
0xd5a7_9147,
|
||||
0x06ca_6351,
|
||||
0x1429_2967,
|
||||
0x27b7_0a85,
|
||||
0x2e1b_2138,
|
||||
0x4d2c_6dfc,
|
||||
0x5338_0d13,
|
||||
0x650a_7354,
|
||||
0x766a_0abb,
|
||||
0x81c2_c92e,
|
||||
0x9272_2c85,
|
||||
0xa2bf_e8a1,
|
||||
0xa81a_664b,
|
||||
0xc24b_8b70,
|
||||
0xc76c_51a3,
|
||||
0xd192_e819,
|
||||
0xd699_0624,
|
||||
0xf40e_3585,
|
||||
0x106a_a070,
|
||||
0x19a4_c116,
|
||||
0x1e37_6c08,
|
||||
0x2748_774c,
|
||||
0x34b0_bcb5,
|
||||
0x391c_0cb3,
|
||||
0x4ed8_aa4a,
|
||||
0x5b9c_ca4f,
|
||||
0x682e_6ff3,
|
||||
0x748f_82ee,
|
||||
0x78a5_636f,
|
||||
0x84c8_7814,
|
||||
0x8cc7_0208,
|
||||
0x90be_fffa,
|
||||
0xa450_6ceb,
|
||||
0xbef9_a3f7,
|
||||
0xc671_78f2,
|
||||
];
|
||||
let mut h = [
|
||||
0x6a09_e667,
|
||||
0xbb67_ae85,
|
||||
0x3c6e_f372,
|
||||
0xa54f_f53a,
|
||||
0x510e_527f,
|
||||
0x9b05_688c,
|
||||
0x1f83_d9ab,
|
||||
0x5be0_cd19,
|
||||
];
|
||||
|
||||
let bit_len = (bytes.len() as u64).wrapping_mul(8);
|
||||
let mut chunks = bytes.chunks_exact(64);
|
||||
for chunk in &mut chunks {
|
||||
compress_sha256_chunk(&mut h, chunk, &K);
|
||||
}
|
||||
|
||||
let tail = chunks.remainder();
|
||||
let mut block = [0u8; 128];
|
||||
block[..tail.len()].copy_from_slice(tail);
|
||||
block[tail.len()] = 0x80;
|
||||
let padded_len = if tail.len() < 56 { 64 } else { 128 };
|
||||
block[padded_len - 8..padded_len].copy_from_slice(&bit_len.to_be_bytes());
|
||||
for chunk in block[..padded_len].chunks_exact(64) {
|
||||
compress_sha256_chunk(&mut h, chunk, &K);
|
||||
}
|
||||
|
||||
let mut out = [0u8; 32];
|
||||
for (idx, word) in h.iter().enumerate() {
|
||||
out[idx * 4..idx * 4 + 4].copy_from_slice(&word.to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Renders a SHA-256 digest as lowercase hexadecimal.
|
||||
#[must_use]
|
||||
pub fn sha256_hex(digest: &Sha256Digest) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut out = String::with_capacity(64);
|
||||
for byte in digest {
|
||||
out.push(char::from(HEX[usize::from(byte >> 4)]));
|
||||
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[allow(clippy::many_single_char_names)]
|
||||
fn compress_sha256_chunk(h: &mut [u32; 8], chunk: &[u8], k: &[u32; 64]) {
|
||||
let mut w = [0u32; 64];
|
||||
for (idx, word) in w.iter_mut().take(16).enumerate() {
|
||||
let base = idx * 4;
|
||||
*word = u32::from_be_bytes([
|
||||
chunk[base],
|
||||
chunk[base + 1],
|
||||
chunk[base + 2],
|
||||
chunk[base + 3],
|
||||
]);
|
||||
}
|
||||
for idx in 16..64 {
|
||||
let s0 = w[idx - 15].rotate_right(7) ^ w[idx - 15].rotate_right(18) ^ (w[idx - 15] >> 3);
|
||||
let s1 = w[idx - 2].rotate_right(17) ^ w[idx - 2].rotate_right(19) ^ (w[idx - 2] >> 10);
|
||||
w[idx] = w[idx - 16]
|
||||
.wrapping_add(s0)
|
||||
.wrapping_add(w[idx - 7])
|
||||
.wrapping_add(s1);
|
||||
}
|
||||
|
||||
let mut a = h[0];
|
||||
let mut b = h[1];
|
||||
let mut c = h[2];
|
||||
let mut d = h[3];
|
||||
let mut e = h[4];
|
||||
let mut f = h[5];
|
||||
let mut g = h[6];
|
||||
let mut hh = h[7];
|
||||
|
||||
for idx in 0..64 {
|
||||
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
|
||||
let ch = (e & f) ^ ((!e) & g);
|
||||
let temp1 = hh
|
||||
.wrapping_add(s1)
|
||||
.wrapping_add(ch)
|
||||
.wrapping_add(k[idx])
|
||||
.wrapping_add(w[idx]);
|
||||
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
|
||||
let maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
let temp2 = s0.wrapping_add(maj);
|
||||
|
||||
hh = g;
|
||||
g = f;
|
||||
f = e;
|
||||
e = d.wrapping_add(temp1);
|
||||
d = c;
|
||||
c = b;
|
||||
b = a;
|
||||
a = temp1.wrapping_add(temp2);
|
||||
}
|
||||
|
||||
h[0] = h[0].wrapping_add(a);
|
||||
h[1] = h[1].wrapping_add(b);
|
||||
h[2] = h[2].wrapping_add(c);
|
||||
h[3] = h[3].wrapping_add(d);
|
||||
h[4] = h[4].wrapping_add(e);
|
||||
h[5] = h[5].wrapping_add(f);
|
||||
h[6] = h[6].wrapping_add(g);
|
||||
h[7] = h[7].wrapping_add(hh);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_matches_known_vectors() {
|
||||
assert_eq!(
|
||||
sha256_hex(&sha256(b"")),
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
assert_eq!(
|
||||
sha256_hex(&sha256(b"abc")),
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "fparkan-corpus"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
fparkan-fx = { path = "../fparkan-fx" }
|
||||
fparkan-material = { path = "../fparkan-material" }
|
||||
fparkan-msh = { path = "../fparkan-msh" }
|
||||
fparkan-mission-format = { path = "../fparkan-mission-format" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-prototype = { path = "../fparkan-prototype" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-rsli = { path = "../fparkan-rsli" }
|
||||
fparkan-texm = { path = "../fparkan-texm" }
|
||||
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "fparkan-diagnostics"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,249 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Structured diagnostics shared by `FParkan` crates.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Diagnostic severity.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
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, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
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, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
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, Serialize)]
|
||||
pub struct SourceSpan {
|
||||
/// Start offset.
|
||||
pub offset: u64,
|
||||
/// Length in bytes.
|
||||
pub length: u64,
|
||||
}
|
||||
|
||||
/// Stable diagnostic code.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
|
||||
pub struct DiagnosticCode(pub &'static str);
|
||||
|
||||
/// Context attached to a diagnostic.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
|
||||
pub struct DiagnosticContext {
|
||||
/// Phase.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phase: Option<Phase>,
|
||||
/// Redacted or logical path.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
/// Archive entry name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub archive_entry: Option<String>,
|
||||
/// Object/prototype key.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_key: Option<String>,
|
||||
/// Input span.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub span: Option<SourceSpan>,
|
||||
}
|
||||
|
||||
/// Structured diagnostic with cause chain.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
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 using the typed diagnostic schema.
|
||||
#[must_use]
|
||||
pub fn render_json(diagnostic: &Diagnostic) -> String {
|
||||
match serde_json::to_string(diagnostic) {
|
||||
Ok(json) => json,
|
||||
Err(err) => format!("{{\"error\":\"diagnostic serialization failed: {err}\"}}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[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}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_escapes_all_control_characters() {
|
||||
let value = diagnostic(DiagnosticCode("S1-H01"), "quote\"\u{0000}tab\tline\r\n");
|
||||
let json = render_json(&value);
|
||||
assert!(json.contains("\\u0000"));
|
||||
assert!(json.contains("\\t"));
|
||||
assert!(!json.contains('\t'));
|
||||
assert!(!json.contains('\r'));
|
||||
}
|
||||
}
|
||||
@@ -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-inspection"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-msh = { path = "../fparkan-msh" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-rsli = { path = "../fparkan-rsli" }
|
||||
fparkan-resource = { path = "../fparkan-resource" }
|
||||
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||
fparkan-texm = { path = "../fparkan-texm" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,327 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Shared inspection helpers for format-backed tooling.
|
||||
|
||||
use fparkan_msh::{decode_msh, validate_msh};
|
||||
use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile};
|
||||
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
|
||||
use fparkan_rsli::decode as decode_rsli;
|
||||
use fparkan_terrain_format::{decode_land_map, decode_land_msh};
|
||||
use fparkan_texm::decode_texm;
|
||||
use fparkan_vfs::DirectoryVfs;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Archive inspection variants.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ArchiveInspection {
|
||||
/// `NRes` inspection summary.
|
||||
Nres {
|
||||
/// Archive entry count.
|
||||
entries: usize,
|
||||
/// Lookup order validity.
|
||||
lookup_order_valid: bool,
|
||||
/// Entry samples (subject to request limit).
|
||||
sample: Vec<NresEntrySummary>,
|
||||
},
|
||||
/// `RsLi` inspection summary.
|
||||
Rsli {
|
||||
/// Archive entry count.
|
||||
entries: usize,
|
||||
},
|
||||
/// Unknown/unsupported archive magic.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// `NRes` entry summary.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct NresEntrySummary {
|
||||
/// ASCII/legacy resource name.
|
||||
pub name: String,
|
||||
/// Entry type identifier.
|
||||
pub type_id: u32,
|
||||
/// Declared entry payload size.
|
||||
pub data_size: u32,
|
||||
}
|
||||
|
||||
/// Model inspection payload.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ModelInspection {
|
||||
/// Terrain stream/document stream count.
|
||||
pub streams: usize,
|
||||
/// Node count.
|
||||
pub nodes: usize,
|
||||
/// Slot count.
|
||||
pub slots: usize,
|
||||
/// Position count.
|
||||
pub positions: usize,
|
||||
/// Index count.
|
||||
pub indices: usize,
|
||||
/// Batch count.
|
||||
pub batches: usize,
|
||||
}
|
||||
|
||||
/// Texture inspection payload.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct TextureInspection {
|
||||
/// Width.
|
||||
pub width: u32,
|
||||
/// Height.
|
||||
pub height: u32,
|
||||
/// Texture format debug text.
|
||||
pub format: String,
|
||||
/// Mip level count.
|
||||
pub mips: usize,
|
||||
/// Total page rectangles.
|
||||
pub pages: usize,
|
||||
}
|
||||
|
||||
/// Land map/msh inspection payload.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MapInspection {
|
||||
/// Mapped mesh stream count.
|
||||
pub streams: usize,
|
||||
/// Slot count.
|
||||
pub slots: usize,
|
||||
/// Position count.
|
||||
pub positions: usize,
|
||||
/// Face count.
|
||||
pub faces: usize,
|
||||
/// Terrain areals.
|
||||
pub areals: usize,
|
||||
/// Declared areal count from map metadata.
|
||||
pub declared_areals: u32,
|
||||
/// Map grid width.
|
||||
pub grid_width: u32,
|
||||
/// Map grid height.
|
||||
pub grid_height: u32,
|
||||
}
|
||||
|
||||
/// Supported land file kinds.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum LandFileKind {
|
||||
/// `land.msh` payload.
|
||||
LandMsh,
|
||||
/// `land.map` payload.
|
||||
LandMap,
|
||||
}
|
||||
|
||||
/// Inspects a format archive.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a string error when the archive cannot be read or decoded.
|
||||
pub fn inspect_archive_file(path: &Path, sample_limit: usize) -> Result<ArchiveInspection, String> {
|
||||
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
||||
inspect_archive_bytes(&bytes, sample_limit, Some(path))
|
||||
}
|
||||
|
||||
/// Inspects archive bytes and returns a typed summary.
|
||||
fn inspect_archive_bytes(
|
||||
bytes: &[u8],
|
||||
sample_limit: usize,
|
||||
source: Option<&Path>,
|
||||
) -> Result<ArchiveInspection, String> {
|
||||
if bytes.starts_with(b"NRes") {
|
||||
let document = decode_nres(
|
||||
Arc::from(bytes.to_vec().into_boxed_slice()),
|
||||
ReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let mut sample = Vec::new();
|
||||
for entry in document.entries().iter().take(sample_limit) {
|
||||
sample.push(NresEntrySummary {
|
||||
name: String::from_utf8_lossy(entry.name_bytes()).to_string(),
|
||||
type_id: entry.meta().type_id,
|
||||
data_size: entry.meta().data_size,
|
||||
});
|
||||
}
|
||||
Ok(ArchiveInspection::Nres {
|
||||
entries: document.entries().len(),
|
||||
lookup_order_valid: document.lookup_order_valid(),
|
||||
sample,
|
||||
})
|
||||
} else if bytes.get(0..4) == Some(b"NL\0\x01") {
|
||||
let document = decode_rsli(
|
||||
Arc::from(bytes.to_vec().into_boxed_slice()),
|
||||
fparkan_rsli::ReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(ArchiveInspection::Rsli {
|
||||
entries: document.entries().len(),
|
||||
})
|
||||
} else {
|
||||
match source {
|
||||
Some(path) => Err(format!("{}: unsupported archive magic", path.display())),
|
||||
None => Err("unsupported archive magic".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspects a model through repository-backed resource lookup.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a string error when the resource cannot be resolved or parsed as a
|
||||
/// valid model payload.
|
||||
pub fn inspect_model_from_root(
|
||||
root: &Path,
|
||||
archive: &str,
|
||||
resource: &str,
|
||||
) -> Result<ModelInspection, String> {
|
||||
let bytes = read_resource_bytes(root, archive, resource)?;
|
||||
let document = decode_nres(bytes, ReadProfile::Compatible).map_err(|err| err.to_string())?;
|
||||
let msh = decode_msh(&document).map_err(|err| err.to_string())?;
|
||||
let validated = validate_msh(&msh).map_err(|err| err.to_string())?;
|
||||
Ok(ModelInspection {
|
||||
streams: msh.streams().len(),
|
||||
nodes: validated.node_count,
|
||||
slots: validated.slots.len(),
|
||||
positions: validated.positions.len(),
|
||||
indices: validated.indices.len(),
|
||||
batches: validated.batches.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Inspects a texture through repository-backed resource lookup.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a string error when the resource cannot be resolved or parsed as a
|
||||
/// valid texture payload.
|
||||
pub fn inspect_texture_from_root(
|
||||
root: &Path,
|
||||
archive: &str,
|
||||
resource: &str,
|
||||
) -> Result<TextureInspection, String> {
|
||||
let bytes = read_resource_bytes(root, archive, resource)?;
|
||||
let document = decode_texm(bytes).map_err(|err| err.to_string())?;
|
||||
Ok(TextureInspection {
|
||||
width: document.width(),
|
||||
height: document.height(),
|
||||
format: format!("{:?}", document.format()),
|
||||
mips: document.mip_count(),
|
||||
pages: document.page_rects().len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Inspects a terrain land file by path.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a string error when the file cannot be read or parsed as the
|
||||
/// requested terrain payload kind.
|
||||
pub fn inspect_land_file(path: &Path, kind: LandFileKind) -> Result<MapInspection, String> {
|
||||
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
||||
let document = decode_nres(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
|
||||
.map_err(|err| err.to_string())?;
|
||||
match kind {
|
||||
LandFileKind::LandMsh => inspect_land_msh(&document),
|
||||
LandFileKind::LandMap => inspect_land_map(&document),
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_land_msh(document: &NresDocument) -> Result<MapInspection, String> {
|
||||
let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?;
|
||||
Ok(MapInspection {
|
||||
streams: land_msh.streams.len(),
|
||||
slots: land_msh.slots.slots_raw.len(),
|
||||
positions: land_msh.positions.len(),
|
||||
faces: land_msh.faces.len(),
|
||||
areals: 0,
|
||||
declared_areals: 0,
|
||||
grid_width: 0,
|
||||
grid_height: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn inspect_land_map(document: &NresDocument) -> Result<MapInspection, String> {
|
||||
let land_map = decode_land_map(document).map_err(|err| err.to_string())?;
|
||||
Ok(MapInspection {
|
||||
streams: 0,
|
||||
slots: 0,
|
||||
positions: 0,
|
||||
faces: 0,
|
||||
areals: land_map.areals.len(),
|
||||
declared_areals: land_map.areal_count,
|
||||
grid_width: land_map.grid.cells_x,
|
||||
grid_height: land_map.grid.cells_y,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_resource_bytes(root: &Path, archive: &str, name: &str) -> Result<Arc<[u8]>, String> {
|
||||
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
|
||||
let archive_path = archive_path(archive.as_bytes()).map_err(|err| err.to_string())?;
|
||||
let resource_name = resource_name(name.as_bytes());
|
||||
let archive_handle = repository
|
||||
.open_archive(&archive_path)
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
let Some(handle) = repository
|
||||
.find(archive_handle, &resource_name)
|
||||
.map_err(|err| format!("{err}"))?
|
||||
else {
|
||||
return Err(format!(
|
||||
"resource not found: {archive}/{}",
|
||||
String::from_utf8_lossy(name.as_bytes())
|
||||
));
|
||||
};
|
||||
let bytes = repository.read(handle).map_err(|err| format!("{err}"))?;
|
||||
Ok(Arc::from(bytes.into_owned()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn inspect_rsli_rejects_malformed_archive() {
|
||||
let dir = temp_dir("inspect");
|
||||
let path = dir.join("test.rsli");
|
||||
let mut file = fs::File::create(&path).expect("file");
|
||||
file.write_all(b"NL\0\x01").expect("magic");
|
||||
drop(file);
|
||||
|
||||
let error = inspect_archive_file(&path, 0).expect_err("malformed archive");
|
||||
assert!(error.contains("entry table out of bounds"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_entry_summary_fields_are_readable() {
|
||||
let dir = temp_dir("inspect-nres");
|
||||
let archive = dir.join("test.nres");
|
||||
let payload = Vec::from("NRes\x00\x00\x00\x00");
|
||||
fs::write(&archive, &payload).expect("nres");
|
||||
|
||||
let _ = inspect_archive_file(&archive, 2);
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let base = PathBuf::from("/tmp")
|
||||
.join("fparkan-inspection-tests")
|
||||
.join(name);
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
fs::create_dir_all(&base).expect("tmp dir");
|
||||
base
|
||||
}
|
||||
}
|
||||
@@ -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,367 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! 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 {
|
||||
raw: Vec<u8>,
|
||||
display: String,
|
||||
}
|
||||
|
||||
impl NormalizedPath {
|
||||
/// Returns string view.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.display
|
||||
}
|
||||
|
||||
/// Returns normalized byte view.
|
||||
#[must_use]
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.raw
|
||||
}
|
||||
|
||||
/// Returns an OS path owned path buffer.
|
||||
#[must_use]
|
||||
pub fn as_path(&self) -> PathBuf {
|
||||
as_os_path_from_bytes(&self.raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl fmt::Display for PathError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Empty => write!(f, "path is empty"),
|
||||
Self::EmbeddedNul => write!(f, "path contains an embedded NUL byte"),
|
||||
Self::Absolute => write!(f, "path must be relative and cannot be absolute"),
|
||||
Self::ParentTraversal => write!(f, "path attempts to traverse outside its root"),
|
||||
Self::EscapesRoot => write!(f, "normalized path escapes the configured root"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 has an invalid drive prefix.
|
||||
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);
|
||||
}
|
||||
if raw.starts_with(b"/") || raw.starts_with(b"\\") || has_drive_prefix(raw) {
|
||||
return Err(PathError::Absolute);
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
for part in raw.split(|byte| *byte == b'/' || *byte == b'\\') {
|
||||
if part.is_empty() || part == b"." {
|
||||
if policy == PathPolicy::StrictLegacy {
|
||||
return Err(PathError::ParentTraversal);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if part == b".." {
|
||||
return Err(PathError::ParentTraversal);
|
||||
}
|
||||
if policy == PathPolicy::StrictLegacy && part.contains(&b':') {
|
||||
return Err(PathError::Absolute);
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
if parts.is_empty() {
|
||||
return Err(PathError::Empty);
|
||||
}
|
||||
let mut normalized = Vec::new();
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
if index > 0 {
|
||||
normalized.push(b'/');
|
||||
}
|
||||
normalized.extend_from_slice(part);
|
||||
}
|
||||
let display = String::from_utf8_lossy(&normalized).into_owned();
|
||||
Ok(NormalizedPath {
|
||||
raw: normalized,
|
||||
display,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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(bytes: &[u8]) -> bool {
|
||||
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
|
||||
.as_bytes()
|
||||
.split(|byte| *byte == b'/')
|
||||
.any(|part| part == b"..")
|
||||
{
|
||||
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_path()))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
PathBuf::from(OsString::from_vec(raw.to_vec()))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf {
|
||||
PathBuf::from(String::from_utf8_lossy(raw).into_owned())
|
||||
}
|
||||
|
||||
#[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 path_error_display_is_actionable() {
|
||||
assert_eq!(
|
||||
PathError::ParentTraversal.to_string(),
|
||||
"path attempts to traverse outside its root"
|
||||
);
|
||||
assert_eq!(
|
||||
PathError::EmbeddedNul.to_string(),
|
||||
"path contains an embedded NUL byte"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_legacy_rejects_host_only_segments() {
|
||||
assert_eq!(
|
||||
normalize_relative(b"./DATA/MAPS", PathPolicy::StrictLegacy),
|
||||
Err(PathError::ParentTraversal)
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_relative(b"DATA//MAPS", PathPolicy::StrictLegacy),
|
||||
Err(PathError::ParentTraversal)
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_relative(b"DATA/stream:name", PathPolicy::StrictLegacy),
|
||||
Err(PathError::Absolute)
|
||||
);
|
||||
|
||||
let host = normalize_relative(b"./DATA//MAPS", PathPolicy::HostCompatible).expect("host");
|
||||
assert_eq!(host.as_str(), "DATA/MAPS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_under_keeps_normalized_path_below_root() {
|
||||
let rel = normalize_relative(b"DATA/MAPS/Land.map", PathPolicy::StrictLegacy)
|
||||
.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 accepts_non_utf8_legacy_bytes() {
|
||||
let path = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
|
||||
.expect("raw legacy bytes");
|
||||
|
||||
assert_eq!(path.as_str(), "DATA/\u{FFFD}.bin");
|
||||
}
|
||||
|
||||
#[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,12 @@
|
||||
[package]
|
||||
name = "fparkan-platform"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
raw-window-handle = "0.6"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,233 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Platform ports for clocks, event sources and window descriptors.
|
||||
|
||||
use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
||||
|
||||
/// Monotonic instant measured in milliseconds since process start.
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub struct MonotonicInstant(pub u64);
|
||||
|
||||
/// Platform clock.
|
||||
pub trait MonotonicClock {
|
||||
/// Current instant.
|
||||
fn now(&self) -> MonotonicInstant;
|
||||
}
|
||||
|
||||
/// Platform event.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PlatformEvent {
|
||||
/// Window/application requested to quit.
|
||||
QuitRequested,
|
||||
/// Window focus changed.
|
||||
FocusChanged {
|
||||
/// Whether the window is focused.
|
||||
focused: bool,
|
||||
},
|
||||
/// Window resize or move to a new drawable size.
|
||||
Resize {
|
||||
/// Drawable width in physical pixels.
|
||||
width: u32,
|
||||
/// Drawable height in physical pixels.
|
||||
height: u32,
|
||||
},
|
||||
/// Device pixel ratio changed.
|
||||
DpiChanged {
|
||||
/// Logical-to-physical scale factor.
|
||||
scale: f64,
|
||||
},
|
||||
/// Window minimized/hidden.
|
||||
Minimized {
|
||||
/// Whether the window is minimized.
|
||||
minimized: bool,
|
||||
},
|
||||
/// Window occlusion state changed.
|
||||
Occluded {
|
||||
/// Whether the window is occluded.
|
||||
occluded: bool,
|
||||
},
|
||||
/// Window is being suspended.
|
||||
Suspended,
|
||||
/// Window resumed from suspend.
|
||||
Resumed,
|
||||
/// Keyboard/scancode input.
|
||||
KeyboardInput {
|
||||
/// Platform scancode.
|
||||
scancode: u32,
|
||||
/// Pressed state.
|
||||
pressed: bool,
|
||||
},
|
||||
/// Mouse button input.
|
||||
MouseInput {
|
||||
/// Mouse button code.
|
||||
button: u16,
|
||||
/// Pressed state.
|
||||
pressed: bool,
|
||||
/// X position in window coordinates.
|
||||
x: f64,
|
||||
/// Y position in window coordinates.
|
||||
y: f64,
|
||||
},
|
||||
/// Mouse cursor movement.
|
||||
CursorMoved {
|
||||
/// Cursor x.
|
||||
x: f64,
|
||||
/// Cursor y.
|
||||
y: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Platform error with optional source detail.
|
||||
#[derive(Debug)]
|
||||
pub enum PlatformError {
|
||||
/// Backend/backend-specific failure.
|
||||
Backend {
|
||||
/// Operation or subsystem.
|
||||
context: &'static str,
|
||||
/// Human-readable details.
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PlatformError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Backend { context, message } => {
|
||||
write!(f, "{context}: {message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PlatformError {}
|
||||
|
||||
/// Event source contract for polling platform events.
|
||||
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 window size.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct PhysicalSize {
|
||||
/// Width.
|
||||
pub width: u32,
|
||||
/// Height.
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// Window identity as a stable opaque handle token.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||
pub struct WindowHandle {
|
||||
/// Opaque integer token.
|
||||
pub id: u64,
|
||||
}
|
||||
|
||||
/// Native raw window/display handles for render surface creation.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct NativeWindowHandles {
|
||||
/// Raw display handle.
|
||||
pub display: RawDisplayHandle,
|
||||
/// Raw window handle.
|
||||
pub window: RawWindowHandle,
|
||||
}
|
||||
|
||||
/// Window presentation and lifecycle port.
|
||||
///
|
||||
/// Presentation is not owned by the window abstraction. Render adapters
|
||||
/// own swapchain and present lifecycle.
|
||||
pub trait WindowPort {
|
||||
/// Current drawable size.
|
||||
fn drawable_size(&self) -> PhysicalSize;
|
||||
/// DPI scale for this window.
|
||||
fn dpi_scale(&self) -> f64;
|
||||
/// Whether the window is focused.
|
||||
fn has_focus(&self) -> bool;
|
||||
/// Whether the window is minimized.
|
||||
fn is_minimized(&self) -> bool;
|
||||
/// Whether the window is occluded.
|
||||
fn is_occluded(&self) -> bool;
|
||||
/// Opaque window identity.
|
||||
fn handle(&self) -> WindowHandle;
|
||||
/// Raw native handles for render surface creation, when backed by a native window.
|
||||
fn native_handles(&self) -> Option<NativeWindowHandles> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Render backend request contract.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct RenderRequest {
|
||||
/// Preferred color-space profile.
|
||||
pub color_space: ColorSpace,
|
||||
/// Preferred presentation mode.
|
||||
pub presentation: PresentationMode,
|
||||
/// Requested depth/stencil format.
|
||||
pub depth: DepthStencilSupport,
|
||||
}
|
||||
|
||||
/// Color-space profile.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ColorSpace {
|
||||
/// sRGB nonlinear.
|
||||
Srgb,
|
||||
/// Linear color-space.
|
||||
Linear,
|
||||
}
|
||||
|
||||
/// Presentation mode.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PresentationMode {
|
||||
/// `VSync`.
|
||||
Fifo,
|
||||
/// No `VSync`.
|
||||
Immediate,
|
||||
/// Triple-buffer mailbox fallback.
|
||||
Mailbox,
|
||||
}
|
||||
|
||||
/// Depth/stencil support profile requested by the composition root.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct DepthStencilSupport {
|
||||
/// Depth bits.
|
||||
pub depth_bits: u8,
|
||||
/// Stencil bits.
|
||||
pub stencil_bits: u8,
|
||||
}
|
||||
|
||||
impl RenderRequest {
|
||||
/// Returns a conservative default request.
|
||||
#[must_use]
|
||||
pub const fn conservative() -> Self {
|
||||
Self {
|
||||
color_space: ColorSpace::Srgb,
|
||||
presentation: PresentationMode::Fifo,
|
||||
depth: DepthStencilSupport {
|
||||
depth_bits: 24,
|
||||
stencil_bits: 8,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[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-path = { path = "../fparkan-path" }
|
||||
fparkan-resource = { path = "../fparkan-resource" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "fparkan-resource"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-rsli = { path = "../fparkan-rsli" }
|
||||
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-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,19 @@
|
||||
[package]
|
||||
name = "fparkan-runtime"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-assets = { path = "../fparkan-assets" }
|
||||
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-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,44 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! 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,13 @@
|
||||
[package]
|
||||
name = "fparkan-vfs"
|
||||
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
|
||||
@@ -0,0 +1,723 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Virtual filesystem ports for resource loading.
|
||||
|
||||
use fparkan_binary::{sha256, Sha256Digest};
|
||||
use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// VFS metadata.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VfsMetadata {
|
||||
/// Byte length.
|
||||
pub len: u64,
|
||||
/// SHA-256 content fingerprint for cache invalidation.
|
||||
pub fingerprint: Sha256Digest,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
fingerprint_cache: Arc<Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>>,
|
||||
}
|
||||
|
||||
impl DirectoryVfs {
|
||||
/// Creates a directory VFS.
|
||||
#[must_use]
|
||||
pub fn new(root: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
root: root.as_ref().to_path_buf(),
|
||||
fingerprint_cache: Arc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> {
|
||||
join_under(&self.root, path).map_err(|_| VfsError::Path)?;
|
||||
resolve_casefolded(&self.root, path.as_str())
|
||||
}
|
||||
|
||||
fn metadata_from_host_file(&self, path: &Path) -> Result<VfsMetadata, VfsError> {
|
||||
let metadata = fs::symlink_metadata(path).map_err(VfsError::Io)?;
|
||||
metadata_from_host_file_with_cache(path, &metadata, &self.fingerprint_cache)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct CachedHostFingerprint {
|
||||
len: u64,
|
||||
modified: Option<SystemTime>,
|
||||
identity: Option<u64>,
|
||||
fingerprint: Sha256Digest,
|
||||
}
|
||||
|
||||
impl Vfs for DirectoryVfs {
|
||||
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
||||
self.metadata_from_host_file(&self.host_path(path)?)
|
||||
}
|
||||
|
||||
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
||||
let host = self.host_path(path)?;
|
||||
let pre_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
|
||||
if pre_metadata.file_type().is_symlink() || !pre_metadata.is_file() {
|
||||
return Err(VfsError::Path);
|
||||
}
|
||||
let pre_identity = file_identity(&pre_metadata);
|
||||
let pre_len = pre_metadata.len();
|
||||
let pre_modified = pre_metadata.modified().ok();
|
||||
let bytes = fs::read(&host).map_err(VfsError::Io)?;
|
||||
let post_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
|
||||
if post_metadata.file_type().is_symlink()
|
||||
|| !post_metadata.is_file()
|
||||
|| post_metadata.len() != pre_len
|
||||
|| post_metadata.modified().ok() != pre_modified
|
||||
|| file_identity(&post_metadata) != pre_identity
|
||||
{
|
||||
return Err(VfsError::Path);
|
||||
}
|
||||
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::symlink_metadata(&base).map_err(VfsError::Io)?;
|
||||
entries.push(VfsEntry {
|
||||
path: prefix.clone(),
|
||||
metadata: metadata_from_host_file_with_cache(
|
||||
&base,
|
||||
&metadata,
|
||||
&self.fingerprint_cache,
|
||||
)?,
|
||||
});
|
||||
return Ok(entries);
|
||||
}
|
||||
list_recursive(&self.root, &base, &self.fingerprint_cache, &mut entries)?;
|
||||
entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
|
||||
Ok(entries)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if entry.file_type().map_err(VfsError::Io)?.is_symlink() {
|
||||
return Err(VfsError::Path);
|
||||
}
|
||||
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,
|
||||
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
|
||||
out: &mut Vec<VfsEntry>,
|
||||
) -> Result<(), VfsError> {
|
||||
let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?;
|
||||
let mut children = Vec::new();
|
||||
for entry in read_dir {
|
||||
let entry = entry.map_err(VfsError::Io)?;
|
||||
children.push(entry.path());
|
||||
}
|
||||
children.sort();
|
||||
for child in children {
|
||||
let metadata = fs::symlink_metadata(&child).map_err(VfsError::Io)?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(VfsError::Path);
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
list_recursive(root, &child, fingerprint_cache, 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_host_file_with_cache(&child, &metadata, fingerprint_cache)?,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn metadata_from_host_file_with_cache(
|
||||
path: &Path,
|
||||
metadata: &fs::Metadata,
|
||||
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
|
||||
) -> Result<VfsMetadata, VfsError> {
|
||||
if !metadata.is_file() {
|
||||
return Err(VfsError::Path);
|
||||
}
|
||||
let len = metadata.len();
|
||||
let modified = metadata.modified().ok();
|
||||
if let Some(cached) = fingerprint_cache
|
||||
.lock()
|
||||
.map_err(|_| VfsError::Path)?
|
||||
.get(path)
|
||||
.cloned()
|
||||
.filter(|cached| {
|
||||
cached.len == len
|
||||
&& cached.modified == modified
|
||||
&& cached.identity == file_identity(metadata)
|
||||
})
|
||||
{
|
||||
return Ok(VfsMetadata {
|
||||
len,
|
||||
fingerprint: cached.fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
let bytes = fs::read(path).map_err(VfsError::Io)?;
|
||||
let fingerprint = sha256(&bytes);
|
||||
fingerprint_cache
|
||||
.lock()
|
||||
.map_err(|_| VfsError::Path)?
|
||||
.insert(
|
||||
path.to_path_buf(),
|
||||
CachedHostFingerprint {
|
||||
len,
|
||||
modified,
|
||||
identity: file_identity(metadata),
|
||||
fingerprint,
|
||||
},
|
||||
);
|
||||
Ok(VfsMetadata { len, fingerprint })
|
||||
}
|
||||
|
||||
/// In-memory VFS.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MemoryVfs {
|
||||
files: BTreeMap<Vec<u8>, Arc<[u8]>>,
|
||||
lookup: BTreeMap<Vec<u8>, Vec<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl MemoryVfs {
|
||||
/// Inserts a file.
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) {
|
||||
let path = path.as_bytes().to_vec();
|
||||
self.files.insert(path, bytes);
|
||||
self.rebuild_lookup();
|
||||
}
|
||||
|
||||
fn rebuild_lookup(&mut self) {
|
||||
self.lookup.clear();
|
||||
for path in self.files.keys() {
|
||||
self.lookup
|
||||
.entry(ascii_lookup_key(path).0)
|
||||
.or_default()
|
||||
.push(path.clone());
|
||||
}
|
||||
for paths in self.lookup.values_mut() {
|
||||
paths.sort();
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_path(&self, path: &NormalizedPath) -> Result<&[u8], VfsError> {
|
||||
let key = ascii_lookup_key(path.as_bytes()).0;
|
||||
let matches = self
|
||||
.lookup
|
||||
.get(&key)
|
||||
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
|
||||
match matches.as_slice() {
|
||||
[single] => Ok(single.as_slice()),
|
||||
[] => Err(VfsError::NotFound(path.as_str().to_string())),
|
||||
_ => Err(VfsError::Ambiguous(path.as_str().to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
|
||||
Some(metadata.dev().rotate_left(32) ^ metadata.ino())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
|
||||
Some(
|
||||
(metadata.volume_serial_number() as u64).rotate_left(40)
|
||||
^ ((metadata.file_index_high() as u64) << 32)
|
||||
^ metadata.file_index_low() as u64,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn file_identity(_metadata: &fs::Metadata) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
impl Vfs for MemoryVfs {
|
||||
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
||||
let resolved = self.resolve_path(path)?;
|
||||
let bytes = self
|
||||
.files
|
||||
.get(resolved)
|
||||
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
|
||||
Ok(VfsMetadata {
|
||||
len: bytes.len() as u64,
|
||||
fingerprint: sha256(bytes),
|
||||
})
|
||||
}
|
||||
|
||||
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
||||
let resolved = self.resolve_path(path)?;
|
||||
self.files
|
||||
.get(resolved)
|
||||
.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 has_segment_boundary_prefix_bytes(path, prefix.as_bytes()) {
|
||||
let normalized =
|
||||
fparkan_path::normalize_relative(path, fparkan_path::PathPolicy::StrictLegacy)
|
||||
.map_err(|_| VfsError::Path)?;
|
||||
out.push(VfsEntry {
|
||||
path: normalized,
|
||||
metadata: VfsMetadata {
|
||||
len: bytes.len() as u64,
|
||||
fingerprint: sha256(bytes),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn has_segment_boundary_prefix_bytes(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
if haystack.len() < needle.len() {
|
||||
return false;
|
||||
}
|
||||
if haystack.len() == needle.len() {
|
||||
return haystack
|
||||
.iter()
|
||||
.zip(needle.iter())
|
||||
.all(|(left, right)| left.eq_ignore_ascii_case(right));
|
||||
}
|
||||
if haystack[needle.len()] != b'/' {
|
||||
return false;
|
||||
}
|
||||
haystack[..needle.len()]
|
||||
.iter()
|
||||
.zip(needle.iter())
|
||||
.all(|(left, right)| left.eq_ignore_ascii_case(right))
|
||||
}
|
||||
|
||||
/// 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_reports_casefold_ambiguity_even_for_exact_host_path() {
|
||||
let root = unique_test_dir("casefold-ambiguous");
|
||||
std::fs::create_dir_all(root.join("Data")).expect("mkdir first");
|
||||
std::fs::create_dir_all(root.join("data")).expect("mkdir second");
|
||||
std::fs::write(root.join("Data").join("File.bin"), b"first").expect("write first");
|
||||
std::fs::write(root.join("data").join("File.bin"), b"second").expect("write second");
|
||||
let collision_count = std::fs::read_dir(&root)
|
||||
.expect("read root")
|
||||
.flatten()
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case("data"))
|
||||
})
|
||||
.count();
|
||||
if collision_count < 2 {
|
||||
std::fs::remove_dir_all(root).expect("cleanup");
|
||||
return;
|
||||
}
|
||||
|
||||
let vfs = DirectoryVfs::new(&root);
|
||||
let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path");
|
||||
|
||||
assert!(matches!(vfs.read(&path), Err(VfsError::Ambiguous(_))));
|
||||
|
||||
std::fs::remove_dir_all(root).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_vfs_lists_files_below_prefix() {
|
||||
let root = unique_test_dir("list");
|
||||
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 memory_vfs_list_prefix_is_boundary_safe() {
|
||||
let mut vfs = MemoryVfs::default();
|
||||
let exact = normalize_relative(b"DATA/Land.map", PathPolicy::StrictLegacy).expect("path");
|
||||
let sibling =
|
||||
normalize_relative(b"DATA2/Land.map", PathPolicy::StrictLegacy).expect("path");
|
||||
vfs.insert(exact.clone(), Arc::from(b"exact".as_slice()));
|
||||
vfs.insert(sibling, Arc::from(b"sibling".as_slice()));
|
||||
|
||||
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
|
||||
let entries = vfs.list(&prefix).expect("list");
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].path.as_str(), exact.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_vfs_fingerprint_changes_for_same_length_content() {
|
||||
let root = unique_test_dir("content-fingerprint");
|
||||
std::fs::create_dir_all(root.join("DATA")).expect("mkdir");
|
||||
std::fs::write(root.join("DATA").join("File.bin"), b"before").expect("write before");
|
||||
|
||||
let vfs = DirectoryVfs::new(&root);
|
||||
let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path");
|
||||
let before = vfs.metadata(&path).expect("before metadata");
|
||||
std::fs::write(root.join("DATA").join("File.bin"), b"after!").expect("write after");
|
||||
let after = vfs.metadata(&path).expect("after metadata");
|
||||
|
||||
assert_eq!(before.len, after.len);
|
||||
assert_ne!(before.fingerprint, after.fingerprint);
|
||||
|
||||
std::fs::remove_dir_all(root).expect("cleanup");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn directory_vfs_rejects_symlink_escape() {
|
||||
let root = unique_test_dir("symlink-escape");
|
||||
let outside = unique_test_dir("symlink-outside");
|
||||
std::fs::create_dir_all(&root).expect("mkdir root");
|
||||
std::fs::create_dir_all(&outside).expect("mkdir outside");
|
||||
std::fs::write(outside.join("secret.bin"), b"secret").expect("write outside");
|
||||
std::os::unix::fs::symlink(&outside, root.join("DATA")).expect("symlink");
|
||||
|
||||
let vfs = DirectoryVfs::new(&root);
|
||||
let path = normalize_relative(b"DATA/secret.bin", PathPolicy::StrictLegacy).expect("path");
|
||||
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
|
||||
|
||||
assert!(matches!(vfs.read(&path), Err(VfsError::Path)));
|
||||
assert!(matches!(vfs.list(&prefix), Err(VfsError::Path)));
|
||||
|
||||
std::fs::remove_dir_all(root).expect("cleanup root");
|
||||
std::fs::remove_dir_all(outside).expect("cleanup outside");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn casefold_selector_reports_ambiguous_segments() {
|
||||
let err = select_casefolded_match(
|
||||
"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_ascii_casefold_lookup() {
|
||||
let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path");
|
||||
let mut vfs = MemoryVfs::default();
|
||||
vfs.insert(path.clone(), Arc::from(b"payload".as_slice()));
|
||||
|
||||
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_eq!(
|
||||
vfs.read(&other_case).expect("casefold read").as_ref(),
|
||||
b"payload"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_vfs_reports_casefold_ambiguity() {
|
||||
let first = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("first");
|
||||
let second =
|
||||
normalize_relative(b"DATA/file.BIN", PathPolicy::StrictLegacy).expect("second");
|
||||
let query = normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("query");
|
||||
let mut vfs = MemoryVfs::default();
|
||||
vfs.insert(first, Arc::from(b"first".as_slice()));
|
||||
vfs.insert(second, Arc::from(b"second".as_slice()));
|
||||
|
||||
assert!(matches!(vfs.read(&query), Err(VfsError::Ambiguous(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_vfs_distinguishes_non_utf8_path_bytes() {
|
||||
let mut vfs = MemoryVfs::default();
|
||||
let ascii =
|
||||
normalize_relative(b"DATA/normal.bin", PathPolicy::HostCompatible).expect("ascii path");
|
||||
let binary =
|
||||
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("binary path");
|
||||
vfs.insert(ascii.clone(), Arc::from(b"ascii".as_slice()));
|
||||
vfs.insert(binary.clone(), Arc::from(b"binary".as_slice()));
|
||||
|
||||
let binary_query =
|
||||
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("binary query");
|
||||
|
||||
assert_eq!(
|
||||
vfs.read(&binary_query).expect("read binary").as_ref(),
|
||||
b"binary"
|
||||
);
|
||||
assert_eq!(vfs.read(&ascii).expect("read ascii").as_ref(), b"ascii");
|
||||
}
|
||||
|
||||
#[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,12 @@
|
||||
[package]
|
||||
name = "fparkan-world"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
Для локально воспроизводимого Desktop backend подтверждено только command/state trace
|
||||
в существующем GL-воркфлоу:
|
||||
|
||||
- `fixtures/acceptance/macos-gl33-triangle-capture.json`;
|
||||
|
||||
`S3-GL-001` пока не закрыт: текущая evidence не отражает полноценный
|
||||
`winit`+`fparkan-render-vulkan` path с real surface/present pipeline.
|
||||
Для закрытия требования требуется постоянный workspace-владельческий backend на
|
||||
`winit`/`fparkan-platform-winit` + `fparkan-render-vulkan` с реальным
|
||||
surface/present pipeline, command/state parity и licensed frame capture.
|
||||
|
||||
Для повышения `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,15 @@
|
||||
# Current Project Audit
|
||||
|
||||
Baseline command:
|
||||
|
||||
```text
|
||||
cargo xtask ci
|
||||
```
|
||||
|
||||
Result on 2026-06-23:
|
||||
|
||||
- canonical pipeline now uses a fixed MSRV/toolchain, policy checks,
|
||||
full-format workspace test command, `clippy`/`doc`/`cargo deny` gates and
|
||||
typed manifest parsing in `xtask`;
|
||||
- `rpath`/offline mode is still useful for synthetic local checks;
|
||||
- full online dependency resolution remains 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
|
||||
сохранение.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user