24 Commits
Author SHA1 Message Date
Valentin Popov 78fc5f1deb docs: rewrite MkDocs documentation
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
Test / Lint (push) Failing after 1m7s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-22 01:58:51 +04:00
Valentin Popov 50c2cf4686 chore: remove Python tooling and resource viewer
Docs Deploy / Build and Deploy MkDocs (push) Successful in 2m6s
Test / Lint (push) Failing after 1m10s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-22 00:35:19 +04:00
renovate[bot] 96a25b6c0e fix(deps): update rust crate glow to 0.17
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
Test / Lint (pull_request) Failing after 1m1s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
Docs Deploy / Build and Deploy MkDocs (push) Successful in 46s
Test / Lint (push) Failing after 1m5s
RenovateBot / renovate (push) Successful in 25s
2026-03-08 00:02:26 +00:00
Valentin Popov f4262cf369 Merge pull request 'fix(deps): update rust crate toml to v1' (#14) from renovate/toml-1.x into devel
Docs Deploy / Build and Deploy MkDocs (push) Successful in 33s
Test / Lint (push) Failing after 1m3s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
RenovateBot / renovate (push) Successful in 28s
Reviewed-on: #14
2026-03-02 18:42:03 +04:00
renovate[bot] 9b100b8fc3 chore(deps): update actions/upload-artifact action to v7
Test / Lint (pull_request) Failing after 1m6s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
Test / Lint (push) Has been cancelled
Test / Test (push) Has been cancelled
Test / Render parity (push) Has been cancelled
Docs Deploy / Build and Deploy MkDocs (push) Has been cancelled
2026-02-27 00:01:47 +00:00
renovate[bot] 9fceeb9a0a fix(deps): update rust crate toml to v1
Test / Lint (push) Failing after 1m2s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
Test / Lint (pull_request) Failing after 1m4s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
2026-02-26 00:01:14 +00:00
renovate[bot] 4b7f1a16b9 fix(deps): update all digest updates
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
Test / Lint (pull_request) Failing after 59s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
Docs Deploy / Build and Deploy MkDocs (push) Successful in 32s
Test / Lint (push) Failing after 1m2s
RenovateBot / renovate (push) Successful in 28s
2026-02-25 00:01:21 +00:00
Valentin Popov ada3b903ad chore: update docs deployment branch from master to devel
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m9s
Test / Lint (push) Failing after 2m11s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
RenovateBot / renovate (push) Successful in 1m7s
2026-02-24 22:42:18 +00:00
Valentin Popov 31d849ddbf updated docs
Test / Lint (push) Failing after 1m57s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-02-19 16:10:57 +04:00
Valentin Popov 4ef08d0bf6 feat: add terrain-core, tma, and unitdat crates with parsing functionality
- Introduced `terrain-core` crate for loading and processing terrain mesh data.
- Added `tma` crate for parsing mission files, including footer and object records.
- Created `unitdat` crate for reading unit data files with validation of structure.
- Implemented error handling and tests for all new crates.
- Documented object registry format and rendering pipeline in specifications.
2026-02-19 16:07:01 +04:00
Valentin Popov 598137ed13 feat(resource-viewer): добавить новый ресурсный просмотрщик с базовой функциональностью
Test / Lint (push) Failing after 2m30s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
feat(nres): улучшить структуру архива с добавлением заголовка и информации о записях
feat(rsli): добавить поддержку заголовка библиотеки и улучшить обработку записей
2026-02-19 10:51:54 +00:00
Valentin Popov cb0ca2f2f0 feat(render-demo): добавить отображение FPS в заголовок окна и stdout в интерактивном режиме
Test / Lint (push) Failing after 1m16s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-02-19 10:27:10 +00:00
Valentin Popov 7346e695c4 feat(render-demo): обновить поддержку OpenGL с добавлением выбора между GLES2 и Core 3.3
Test / Lint (push) Failing after 1m17s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-02-19 10:17:14 +00:00
Valentin Popov bb827c3928 feat: Refactor code structure and enhance functionality across multiple crates 2026-02-19 10:09:18 +00:00
Valentin Popov efab61a45c feat(render-core): add default UV scale and refactor UV mapping logic
Test / Lint (push) Failing after 1m12s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
- Introduced a constant `DEFAULT_UV_SCALE` for UV scaling.
- Refactored UV mapping in `build_render_mesh` to use the new constant.
- Simplified `compute_bounds` functions by extracting common logic into `compute_bounds_impl`.

test(render-core): add tests for rendering with empty and multi-node models

- Added tests to verify behavior when building render meshes from models with no slots and multiple nodes.
- Ensured UV scaling is correctly applied in tests.

feat(render-demo): add FOV argument and improve error handling

- Added a `--fov` command-line argument to set the field of view.
- Enhanced error messages for texture resolution failures.
- Updated MVP computation to use the new FOV parameter.

fix(rsli): improve error handling in LZH decompression

- Added checks to prevent out-of-bounds access in LZH decoding logic.

refactor(texm): streamline texture parsing and decoding tests

- Created a helper function `build_texm_payload` for constructing test payloads.
- Added tests for various texture formats including RGB565, RGB556, ARGB4444, and Luminance Alpha.
- Improved error handling for invalid TEXM headers and mip bounds.
2026-02-19 09:46:23 +00:00
Valentin Popov 0d7ae6a017 Документирование и обновление спецификаций
Test / Lint (push) Failing after 1m10s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
- Обновлены спецификации `runtime-pipeline`, `sound`, `terrain-map-loading`, `texture`, `ui` и `wear`.
- Добавлены разделы о статусе покрытия и оставшихся задачах для достижения 100% завершенности.
- Внесены уточнения по архитектурным ролям, минимальным контрактам и требованиям к toolchain для каждой подсистемы.
- Уточнены форматы данных и правила взаимодействия между компонентами системы.
2026-02-19 11:07:04 +04:00
Valentin Popov a281ffa32e feat: Enhance model and texture loading with improved error handling and new features
Test / Lint (push) Failing after 1m10s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
- Introduced `LoadedModel` and `LoadedTexture` structs for better encapsulation of model and texture data.
- Added functions to load models and textures from archives, including support for resolving textures based on materials and wear entries.
- Implemented error handling for missing textures, materials, and wear entries.
- Updated the rendering pipeline to support texture loading and binding, including command-line arguments for texture customization.
- Enhanced the `texm` crate with new decoding capabilities for various pixel formats, including indexed textures.
- Added tests for texture decoding and loading to ensure reliability and correctness.
- Updated documentation to reflect changes in the material and texture resolution process.
2026-02-19 05:19:18 +04:00
Valentin Popov 18d4c6cf9f feat(render-parity): add deterministic frame comparison tool
- Introduced `render-parity` crate for comparing rendered frames against reference images.
- Added command-line options for specifying manifest and output directory.
- Implemented image comparison metrics: mean absolute difference, maximum absolute difference, and changed pixel ratio.
- Created a configuration file `cases.toml` for defining test cases with global defaults and specific parameters.
- Added functionality to capture frames from `render-demo` and save diff images on discrepancies.
- Updated documentation to include usage instructions and CI model for automated testing.
2026-02-19 05:02:26 +04:00
Valentin Popov 0e19660eb5 Refactor documentation structure and add new specifications
- Updated MSH documentation to reflect changes in material, wear, and texture specifications.
- Introduced new `render.md` file detailing the render pipeline process.
- Removed outdated sections from `runtime-pipeline.md` and redirected to `render.md`.
- Added detailed specifications for `Texm` texture format and `WEAR` wear table.
- Updated navigation in `mkdocs.yml` to align with new documentation structure.
2026-02-19 04:46:23 +04:00
Valentin Popov 8a69872576 Refactor code structure for improved readability and maintainability
Test / Lint (push) Successful in 1m8s
Test / Test (push) Successful in 1m15s
2026-02-12 11:07:25 +00:00
Valentin Popov aa68906a3d feat: добавить экспорт в формат OBJ для рендеринга террейна и опциональных полигонов 2026-02-12 10:36:15 +00:00
Valentin Popov 8bf3b7b209 feat: добавить 3D рендерер для террейна с поддержкой Land.msh и Land.map 2026-02-12 10:24:42 +00:00
Valentin Popov 669fb40a70 Add terrain map documentation validator
This commit introduces a new Python script, `terrain_map_doc_validator.py`, which validates terrain and map documentation assumptions against actual game data. The validator checks for the presence and correctness of various data chunks in the `Land.msh` and `Land.map` files, reporting any issues found during the validation process. It also generates a summary report of the validation results, including counts of errors and warnings, and statistics related to the map and mesh data.
2026-02-12 10:17:41 +00:00
Valentin Popov 9c0df3d299 feat: добавить скрипт для детерминированного аудита FXID "absolute parity" 2026-02-12 08:22:51 +00:00
91 changed files with 14099 additions and 6859 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ name: Docs Deploy
on: on:
push: push:
branches: branches:
- master - devel
jobs: jobs:
deploy-docs: deploy-docs:
+28
View File
@@ -25,3 +25,31 @@ jobs:
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- name: Cargo test - name: Cargo test
run: cargo test --workspace --all-features -- --nocapture run: cargo test --workspace --all-features -- --nocapture
render-parity:
name: Render parity
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v6
- 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
+2 -10
View File
@@ -1,13 +1,12 @@
# FParkan # FParkan
Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»** и набором [вспомогательных инструментов](tools) для исследования. Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»**.
## Описание ## Описание
Проект находится в активной разработке и включает: Проект находится в активной разработке и включает:
- библиотеки для работы с форматами игровых архивов; - библиотеки для работы с форматами игровых архивов;
- инструменты для валидации/подготовки тестовых данных;
- спецификации форматов и сопутствующую документацию. - спецификации форматов и сопутствующую документацию.
## Установка ## Установка
@@ -19,13 +18,6 @@ Open source проект с реализацией компонентов игр
- локально: каталог [`docs/`](docs) - локально: каталог [`docs/`](docs)
- сайт: <https://fparkan.popov.link> - сайт: <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/nres](crates/nres) — библиотека для работы с файлами архивов NRes (чтение, поиск, редактирование, сохранение).
@@ -37,8 +29,8 @@ Open source проект с реализацией компонентов игр
Для дополнительного тестирования на реальных игровых ресурсах: Для дополнительного тестирования на реальных игровых ресурсах:
- используйте [tools/init_testdata.py](tools/init_testdata.py) для подготовки локального набора;
- используйте оригинальную копию игры (диск или [GOG-версия](https://www.gog.com/en/game/parkan_iron_strategy)); - используйте оригинальную копию игры (диск или [GOG-версия](https://www.gog.com/en/game/parkan_iron_strategy));
- разместите игровые каталоги в [`testdata/`](testdata);
- игровые ресурсы в репозиторий не включаются, так как защищены авторским правом. - игровые ресурсы в репозиторий не включаются, так как защищены авторским правом.
## Contributing & Support ## Contributing & Support
+17
View File
@@ -1,4 +1,6 @@
use std::fs;
use std::io; use std::io;
use std::path::{Path, PathBuf};
/// Resource payload that can be either borrowed from mapped bytes or owned. /// Resource payload that can be either borrowed from mapped bytes or owned.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -42,3 +44,18 @@ impl OutputBuffer for Vec<u8> {
Ok(()) Ok(())
} }
} }
/// Recursively collects all files under `root`.
pub 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);
}
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "msh-core"
version = "0.1.0"
edition = "2021"
[dependencies]
encoding_rs = "0.8"
nres = { path = "../nres" }
[dev-dependencies]
common = { path = "../common" }
proptest = "1"
+14
View File
@@ -0,0 +1,14 @@
# msh-core
Парсер core-части формата `MSH`.
Покрывает:
- `Res1`, `Res2`, `Res3`, `Res6`, `Res13` (обязательные);
- `Res4`, `Res5`, `Res10` (опциональные);
- slot lookup по `node/lod/group`.
Тесты:
- прогон по всем `.msh` в `testdata`;
- синтетическая минимальная модель.
+75
View File
@@ -0,0 +1,75 @@
use core::fmt;
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
Nres(nres::error::Error),
MissingResource {
kind: u32,
label: &'static str,
},
InvalidResourceSize {
label: &'static str,
size: usize,
stride: usize,
},
InvalidRes2Size {
size: usize,
},
UnsupportedNodeStride {
stride: usize,
},
IndexOutOfBounds {
label: &'static str,
index: usize,
limit: usize,
},
IntegerOverflow,
}
impl From<nres::error::Error> for Error {
fn from(value: nres::error::Error) -> Self {
Self::Nres(value)
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Nres(err) => write!(f, "{err}"),
Self::MissingResource { kind, label } => {
write!(f, "missing required resource type={kind} ({label})")
}
Self::InvalidResourceSize {
label,
size,
stride,
} => {
write!(
f,
"invalid {label} size={size}, expected multiple of stride={stride}"
)
}
Self::InvalidRes2Size { size } => {
write!(f, "invalid Res2 size={size}, expected >= 140")
}
Self::UnsupportedNodeStride { stride } => {
write!(
f,
"unsupported Res1 node stride={stride}, expected 38 or 24"
)
}
Self::IndexOutOfBounds {
label,
index,
limit,
} => write!(
f,
"{label} index out of bounds: index={index}, limit={limit}"
),
Self::IntegerOverflow => write!(f, "integer overflow"),
}
}
}
impl std::error::Error for Error {}
+434
View File
@@ -0,0 +1,434 @@
pub mod error;
use crate::error::Error;
use encoding_rs::WINDOWS_1251;
use std::sync::Arc;
pub type Result<T> = core::result::Result<T, Error>;
pub const RES1_NODE_TABLE: u32 = 1;
pub const RES2_SLOTS: u32 = 2;
pub const RES3_POSITIONS: u32 = 3;
pub const RES4_NORMALS: u32 = 4;
pub const RES5_UV0: u32 = 5;
pub const RES6_INDICES: u32 = 6;
pub const RES10_NAMES: u32 = 10;
pub const RES13_BATCHES: u32 = 13;
#[derive(Clone, Debug)]
pub struct Slot {
pub tri_start: u16,
pub tri_count: u16,
pub batch_start: u16,
pub batch_count: u16,
pub aabb_min: [f32; 3],
pub aabb_max: [f32; 3],
pub sphere_center: [f32; 3],
pub sphere_radius: f32,
pub opaque: [u32; 5],
}
#[derive(Clone, Debug)]
pub struct Batch {
pub batch_flags: u16,
pub material_index: u16,
pub opaque4: u16,
pub opaque6: u16,
pub index_count: u16,
pub index_start: u32,
pub opaque14: u16,
pub base_vertex: u32,
}
#[derive(Clone, Debug)]
pub struct Model {
pub node_stride: usize,
pub node_count: usize,
pub nodes_raw: Vec<u8>,
pub slots: Vec<Slot>,
pub positions: Vec<[f32; 3]>,
pub normals: Option<Vec<[i8; 4]>>,
pub uv0: Option<Vec<[i16; 2]>>,
pub indices: Vec<u16>,
pub batches: Vec<Batch>,
pub node_names: Option<Vec<Option<String>>>,
}
impl Model {
pub fn slot_index(&self, node_index: usize, lod: usize, group: usize) -> Option<usize> {
if node_index >= self.node_count || lod >= 3 || group >= 5 {
return None;
}
if self.node_stride != 38 {
return None;
}
let node_off = node_index.checked_mul(self.node_stride)?;
let matrix_off = node_off.checked_add(8)?;
let word_off = matrix_off.checked_add((lod * 5 + group) * 2)?;
let raw = read_u16(&self.nodes_raw, word_off).ok()?;
if raw == u16::MAX {
return None;
}
let idx = usize::from(raw);
if idx >= self.slots.len() {
return None;
}
Some(idx)
}
}
pub fn parse_model_payload(payload: &[u8]) -> Result<Model> {
let archive = nres::Archive::open_bytes(
Arc::from(payload.to_vec().into_boxed_slice()),
nres::OpenOptions::default(),
)?;
let res1 = read_required(&archive, RES1_NODE_TABLE, "Res1")?;
let res2 = read_required(&archive, RES2_SLOTS, "Res2")?;
let res3 = read_required(&archive, RES3_POSITIONS, "Res3")?;
let res6 = read_required(&archive, RES6_INDICES, "Res6")?;
let res13 = read_required(&archive, RES13_BATCHES, "Res13")?;
let res4 = read_optional(&archive, RES4_NORMALS)?;
let res5 = read_optional(&archive, RES5_UV0)?;
let res10 = read_optional(&archive, RES10_NAMES)?;
let node_stride = usize::try_from(res1.meta.attr3).map_err(|_| Error::IntegerOverflow)?;
if node_stride != 38 && node_stride != 24 {
return Err(Error::UnsupportedNodeStride {
stride: node_stride,
});
}
if res1.bytes.len() % node_stride != 0 {
return Err(Error::InvalidResourceSize {
label: "Res1",
size: res1.bytes.len(),
stride: node_stride,
});
}
let node_count = res1.bytes.len() / node_stride;
if res2.bytes.len() < 0x8C {
return Err(Error::InvalidRes2Size {
size: res2.bytes.len(),
});
}
let slot_blob = res2
.bytes
.len()
.checked_sub(0x8C)
.ok_or(Error::IntegerOverflow)?;
if slot_blob % 68 != 0 {
return Err(Error::InvalidResourceSize {
label: "Res2.slots",
size: slot_blob,
stride: 68,
});
}
let slot_count = slot_blob / 68;
let mut slots = Vec::with_capacity(slot_count);
for i in 0..slot_count {
let off = 0x8Cusize
.checked_add(i.checked_mul(68).ok_or(Error::IntegerOverflow)?)
.ok_or(Error::IntegerOverflow)?;
slots.push(Slot {
tri_start: read_u16(&res2.bytes, off)?,
tri_count: read_u16(&res2.bytes, off + 2)?,
batch_start: read_u16(&res2.bytes, off + 4)?,
batch_count: read_u16(&res2.bytes, off + 6)?,
aabb_min: [
read_f32(&res2.bytes, off + 8)?,
read_f32(&res2.bytes, off + 12)?,
read_f32(&res2.bytes, off + 16)?,
],
aabb_max: [
read_f32(&res2.bytes, off + 20)?,
read_f32(&res2.bytes, off + 24)?,
read_f32(&res2.bytes, off + 28)?,
],
sphere_center: [
read_f32(&res2.bytes, off + 32)?,
read_f32(&res2.bytes, off + 36)?,
read_f32(&res2.bytes, off + 40)?,
],
sphere_radius: read_f32(&res2.bytes, off + 44)?,
opaque: [
read_u32(&res2.bytes, off + 48)?,
read_u32(&res2.bytes, off + 52)?,
read_u32(&res2.bytes, off + 56)?,
read_u32(&res2.bytes, off + 60)?,
read_u32(&res2.bytes, off + 64)?,
],
});
}
let positions = parse_positions(&res3.bytes)?;
let indices = parse_u16_array(&res6.bytes, "Res6")?;
let batches = parse_batches(&res13.bytes)?;
validate_slot_batch_ranges(&slots, batches.len())?;
validate_batch_index_ranges(&batches, indices.len())?;
let normals = match res4 {
Some(raw) => Some(parse_i8x4_array(&raw.bytes, "Res4")?),
None => None,
};
let uv0 = match res5 {
Some(raw) => Some(parse_i16x2_array(&raw.bytes, "Res5")?),
None => None,
};
let node_names = match res10 {
Some(raw) => Some(parse_res10_names(&raw.bytes, node_count)?),
None => None,
};
Ok(Model {
node_stride,
node_count,
nodes_raw: res1.bytes,
slots,
positions,
normals,
uv0,
indices,
batches,
node_names,
})
}
fn validate_slot_batch_ranges(slots: &[Slot], batch_count: usize) -> Result<()> {
for slot in slots {
let start = usize::from(slot.batch_start);
let end = start
.checked_add(usize::from(slot.batch_count))
.ok_or(Error::IntegerOverflow)?;
if end > batch_count {
return Err(Error::IndexOutOfBounds {
label: "Res2.batch_range",
index: end,
limit: batch_count,
});
}
}
Ok(())
}
fn validate_batch_index_ranges(batches: &[Batch], index_count: usize) -> Result<()> {
for batch in batches {
let start = usize::try_from(batch.index_start).map_err(|_| Error::IntegerOverflow)?;
let end = start
.checked_add(usize::from(batch.index_count))
.ok_or(Error::IntegerOverflow)?;
if end > index_count {
return Err(Error::IndexOutOfBounds {
label: "Res13.index_range",
index: end,
limit: index_count,
});
}
}
Ok(())
}
fn parse_positions(data: &[u8]) -> Result<Vec<[f32; 3]>> {
if !data.len().is_multiple_of(12) {
return Err(Error::InvalidResourceSize {
label: "Res3",
size: data.len(),
stride: 12,
});
}
let count = data.len() / 12;
let mut out = Vec::with_capacity(count);
for i in 0..count {
let off = i * 12;
out.push([
read_f32(data, off)?,
read_f32(data, off + 4)?,
read_f32(data, off + 8)?,
]);
}
Ok(out)
}
fn parse_batches(data: &[u8]) -> Result<Vec<Batch>> {
if !data.len().is_multiple_of(20) {
return Err(Error::InvalidResourceSize {
label: "Res13",
size: data.len(),
stride: 20,
});
}
let count = data.len() / 20;
let mut out = Vec::with_capacity(count);
for i in 0..count {
let off = i * 20;
out.push(Batch {
batch_flags: read_u16(data, off)?,
material_index: read_u16(data, off + 2)?,
opaque4: read_u16(data, off + 4)?,
opaque6: read_u16(data, off + 6)?,
index_count: read_u16(data, off + 8)?,
index_start: read_u32(data, off + 10)?,
opaque14: read_u16(data, off + 14)?,
base_vertex: read_u32(data, off + 16)?,
});
}
Ok(out)
}
fn parse_u16_array(data: &[u8], label: &'static str) -> Result<Vec<u16>> {
if !data.len().is_multiple_of(2) {
return Err(Error::InvalidResourceSize {
label,
size: data.len(),
stride: 2,
});
}
let mut out = Vec::with_capacity(data.len() / 2);
for i in (0..data.len()).step_by(2) {
out.push(read_u16(data, i)?);
}
Ok(out)
}
fn parse_i8x4_array(data: &[u8], label: &'static str) -> Result<Vec<[i8; 4]>> {
if !data.len().is_multiple_of(4) {
return Err(Error::InvalidResourceSize {
label,
size: data.len(),
stride: 4,
});
}
let mut out = Vec::with_capacity(data.len() / 4);
for i in (0..data.len()).step_by(4) {
out.push([
read_i8(data, i)?,
read_i8(data, i + 1)?,
read_i8(data, i + 2)?,
read_i8(data, i + 3)?,
]);
}
Ok(out)
}
fn parse_i16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[i16; 2]>> {
if !data.len().is_multiple_of(4) {
return Err(Error::InvalidResourceSize {
label,
size: data.len(),
stride: 4,
});
}
let mut out = Vec::with_capacity(data.len() / 4);
for i in (0..data.len()).step_by(4) {
out.push([read_i16(data, i)?, read_i16(data, i + 2)?]);
}
Ok(out)
}
fn parse_res10_names(data: &[u8], node_count: usize) -> Result<Vec<Option<String>>> {
let mut out = Vec::with_capacity(node_count);
let mut off = 0usize;
for _ in 0..node_count {
let len = usize::try_from(read_u32(data, off)?).map_err(|_| Error::IntegerOverflow)?;
off = off.checked_add(4).ok_or(Error::IntegerOverflow)?;
if len == 0 {
out.push(None);
continue;
}
let need = len.checked_add(1).ok_or(Error::IntegerOverflow)?;
let end = off.checked_add(need).ok_or(Error::IntegerOverflow)?;
let slice = data.get(off..end).ok_or(Error::InvalidResourceSize {
label: "Res10",
size: data.len(),
stride: 1,
})?;
let text = if slice.last().copied() == Some(0) {
&slice[..slice.len().saturating_sub(1)]
} else {
slice
};
let decoded = decode_cp1251(text);
out.push(Some(decoded));
off = end;
}
Ok(out)
}
fn decode_cp1251(bytes: &[u8]) -> String {
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
decoded.into_owned()
}
struct RawResource {
meta: nres::EntryMeta,
bytes: Vec<u8>,
}
fn read_required(archive: &nres::Archive, kind: u32, label: &'static str) -> Result<RawResource> {
let id = archive
.entries()
.find(|entry| entry.meta.kind == kind)
.map(|entry| entry.id)
.ok_or(Error::MissingResource { kind, label })?;
let entry = archive.get(id).ok_or(Error::IndexOutOfBounds {
label,
index: usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?,
limit: archive.entry_count(),
})?;
let data = archive.read(id)?.into_owned();
Ok(RawResource {
meta: entry.meta.clone(),
bytes: data,
})
}
fn read_optional(archive: &nres::Archive, kind: u32) -> Result<Option<RawResource>> {
let Some(id) = archive
.entries()
.find(|entry| entry.meta.kind == kind)
.map(|entry| entry.id)
else {
return Ok(None);
};
let entry = archive.get(id).ok_or(Error::IndexOutOfBounds {
label: "optional",
index: usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?,
limit: archive.entry_count(),
})?;
let data = archive.read(id)?.into_owned();
Ok(Some(RawResource {
meta: entry.meta.clone(),
bytes: data,
}))
}
fn read_u16(data: &[u8], offset: usize) -> Result<u16> {
let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?;
let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
Ok(u16::from_le_bytes(arr))
}
fn read_i16(data: &[u8], offset: usize) -> Result<i16> {
let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?;
let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
Ok(i16::from_le_bytes(arr))
}
fn read_i8(data: &[u8], offset: usize) -> Result<i8> {
let byte = data.get(offset).copied().ok_or(Error::IntegerOverflow)?;
Ok(i8::from_le_bytes([byte]))
}
fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
let bytes = data.get(offset..offset + 4).ok_or(Error::IntegerOverflow)?;
let arr: [u8; 4] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
Ok(u32::from_le_bytes(arr))
}
fn read_f32(data: &[u8], offset: usize) -> Result<f32> {
Ok(f32::from_bits(read_u32(data, offset)?))
}
#[cfg(test)]
mod tests;
+438
View File
@@ -0,0 +1,438 @@
use super::*;
use common::collect_files_recursive;
use nres::Archive;
use proptest::prelude::*;
use std::fs;
use std::path::{Path, PathBuf};
fn nres_test_files() -> Vec<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata");
let mut files = Vec::new();
collect_files_recursive(&root, &mut files);
files.sort();
files
.into_iter()
.filter(|path| {
fs::read(path)
.map(|bytes| bytes.get(0..4) == Some(b"NRes"))
.unwrap_or(false)
})
.collect()
}
fn is_msh_name(name: &str) -> bool {
name.to_ascii_lowercase().ends_with(".msh")
}
#[derive(Clone)]
struct SyntheticEntry {
kind: u32,
name: String,
attr1: u32,
attr2: u32,
attr3: u32,
data: Vec<u8>,
}
fn build_nested_nres(entries: &[SyntheticEntry]) -> Vec<u8> {
let mut payload = Vec::new();
payload.extend_from_slice(b"NRes");
payload.extend_from_slice(&0x100u32.to_le_bytes());
payload.extend_from_slice(
&u32::try_from(entries.len())
.expect("entry count overflow in test")
.to_le_bytes(),
);
payload.extend_from_slice(&0u32.to_le_bytes()); // total_size placeholder
let mut resource_offsets = Vec::with_capacity(entries.len());
for entry in entries {
resource_offsets.push(u32::try_from(payload.len()).expect("offset overflow in test"));
payload.extend_from_slice(&entry.data);
while !payload.len().is_multiple_of(8) {
payload.push(0);
}
}
for (index, entry) in entries.iter().enumerate() {
payload.extend_from_slice(&entry.kind.to_le_bytes());
payload.extend_from_slice(&entry.attr1.to_le_bytes());
payload.extend_from_slice(&entry.attr2.to_le_bytes());
payload.extend_from_slice(
&u32::try_from(entry.data.len())
.expect("size overflow in test")
.to_le_bytes(),
);
payload.extend_from_slice(&entry.attr3.to_le_bytes());
let mut name_raw = [0u8; 36];
let name_bytes = entry.name.as_bytes();
assert!(name_bytes.len() <= 35, "name too long for synthetic test");
name_raw[..name_bytes.len()].copy_from_slice(name_bytes);
payload.extend_from_slice(&name_raw);
payload.extend_from_slice(&resource_offsets[index].to_le_bytes());
payload.extend_from_slice(&(index as u32).to_le_bytes());
}
let total_size = u32::try_from(payload.len()).expect("size overflow in test");
payload[12..16].copy_from_slice(&total_size.to_le_bytes());
payload
}
fn synthetic_entry(kind: u32, name: &str, attr3: u32, data: Vec<u8>) -> SyntheticEntry {
SyntheticEntry {
kind,
name: name.to_string(),
attr1: 1,
attr2: 0,
attr3,
data,
}
}
fn res1_stride38_nodes(node_count: usize, node0_slot00: Option<u16>) -> Vec<u8> {
let mut out = vec![0u8; node_count.saturating_mul(38)];
for node in 0..node_count {
let node_off = node * 38;
for i in 0..15 {
let off = node_off + 8 + i * 2;
out[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes());
}
}
if let Some(slot) = node0_slot00 {
out[8..10].copy_from_slice(&slot.to_le_bytes());
}
out
}
fn res1_stride24_nodes(node_count: usize) -> Vec<u8> {
vec![0u8; node_count.saturating_mul(24)]
}
fn res2_single_slot(batch_start: u16, batch_count: u16) -> Vec<u8> {
let mut res2 = vec![0u8; 0x8C + 68];
res2[0x8C..0x8C + 2].copy_from_slice(&0u16.to_le_bytes()); // tri_start
res2[0x8C + 2..0x8C + 4].copy_from_slice(&0u16.to_le_bytes()); // tri_count
res2[0x8C + 4..0x8C + 6].copy_from_slice(&batch_start.to_le_bytes()); // batch_start
res2[0x8C + 6..0x8C + 8].copy_from_slice(&batch_count.to_le_bytes()); // batch_count
res2
}
fn res3_triangle_positions() -> Vec<u8> {
[0f32, 0f32, 0f32, 1f32, 0f32, 0f32, 0f32, 1f32, 0f32]
.iter()
.flat_map(|v| v.to_le_bytes())
.collect()
}
fn res4_normals() -> Vec<u8> {
vec![127u8, 0u8, 128u8, 0u8]
}
fn res5_uv0() -> Vec<u8> {
[1024i16, -1024i16]
.iter()
.flat_map(|v| v.to_le_bytes())
.collect()
}
fn res6_triangle_indices() -> Vec<u8> {
[0u16, 1u16, 2u16]
.iter()
.flat_map(|v| v.to_le_bytes())
.collect()
}
fn res13_single_batch(index_start: u32, index_count: u16) -> Vec<u8> {
let mut batch = vec![0u8; 20];
batch[0..2].copy_from_slice(&0u16.to_le_bytes());
batch[2..4].copy_from_slice(&0u16.to_le_bytes());
batch[8..10].copy_from_slice(&index_count.to_le_bytes());
batch[10..14].copy_from_slice(&index_start.to_le_bytes());
batch[16..20].copy_from_slice(&0u32.to_le_bytes());
batch
}
fn res10_names_raw(names: &[Option<&[u8]>]) -> Vec<u8> {
let mut out = Vec::new();
for name in names {
match name {
Some(name) => {
out.extend_from_slice(
&u32::try_from(name.len())
.expect("name size overflow in test")
.to_le_bytes(),
);
out.extend_from_slice(name);
out.push(0);
}
None => out.extend_from_slice(&0u32.to_le_bytes()),
}
}
out
}
fn res10_names(names: &[Option<&str>]) -> Vec<u8> {
let raw: Vec<Option<&[u8]>> = names.iter().map(|name| name.map(str::as_bytes)).collect();
res10_names_raw(&raw)
}
fn base_synthetic_entries() -> Vec<SyntheticEntry> {
vec![
synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0))),
synthetic_entry(RES2_SLOTS, "Res2", 68, res2_single_slot(0, 1)),
synthetic_entry(RES3_POSITIONS, "Res3", 12, res3_triangle_positions()),
synthetic_entry(RES6_INDICES, "Res6", 2, res6_triangle_indices()),
synthetic_entry(RES13_BATCHES, "Res13", 20, res13_single_batch(0, 3)),
]
}
#[test]
fn parse_all_game_msh_models() {
let archives = nres_test_files();
if archives.is_empty() {
eprintln!("skipping parse_all_game_msh_models: no NRes files in testdata");
return;
}
let mut model_count = 0usize;
let mut renderable_count = 0usize;
let mut legacy_stride24_count = 0usize;
for archive_path in archives {
let archive = Archive::open_path(&archive_path)
.unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display()));
for entry in archive.entries() {
if !is_msh_name(&entry.meta.name) {
continue;
}
model_count += 1;
let payload = archive.read(entry.id).unwrap_or_else(|err| {
panic!(
"failed to read model '{}' in {}: {err}",
entry.meta.name,
archive_path.display()
)
});
let model = parse_model_payload(payload.as_slice()).unwrap_or_else(|err| {
panic!(
"failed to parse model '{}' in {}: {err}",
entry.meta.name,
archive_path.display()
)
});
if model.node_stride == 24 {
legacy_stride24_count += 1;
}
for node_index in 0..model.node_count {
for lod in 0..3 {
for group in 0..5 {
if let Some(slot_idx) = model.slot_index(node_index, lod, group) {
assert!(
slot_idx < model.slots.len(),
"slot index out of bounds in '{}' ({})",
entry.meta.name,
archive_path.display()
);
}
}
}
}
let mut has_renderable_batch = false;
for node_index in 0..model.node_count {
let Some(slot_idx) = model.slot_index(node_index, 0, 0) else {
continue;
};
let slot = &model.slots[slot_idx];
let batch_end =
usize::from(slot.batch_start).saturating_add(usize::from(slot.batch_count));
if batch_end > model.batches.len() {
continue;
}
for batch in &model.batches[usize::from(slot.batch_start)..batch_end] {
let index_start = usize::try_from(batch.index_start).unwrap_or(usize::MAX);
let index_count = usize::from(batch.index_count);
let end = index_start.saturating_add(index_count);
if end <= model.indices.len() && index_count >= 3 {
has_renderable_batch = true;
break;
}
}
if has_renderable_batch {
break;
}
}
if has_renderable_batch {
renderable_count += 1;
}
}
}
assert!(model_count > 0, "no .msh entries found");
assert!(
renderable_count > 0,
"no renderable models (lod0/group0) were detected"
);
assert!(
legacy_stride24_count <= model_count,
"internal test accounting error"
);
}
#[test]
fn parse_minimal_synthetic_model() {
let payload = build_nested_nres(&base_synthetic_entries());
let model = parse_model_payload(&payload).expect("failed to parse synthetic model");
assert_eq!(model.node_count, 1);
assert_eq!(model.positions.len(), 3);
assert_eq!(model.indices.len(), 3);
assert_eq!(model.batches.len(), 1);
assert_eq!(model.slot_index(0, 0, 0), Some(0));
}
#[test]
fn parse_synthetic_stride24_variant() {
let mut entries = base_synthetic_entries();
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 24, res1_stride24_nodes(1));
let payload = build_nested_nres(&entries);
let model = parse_model_payload(&payload).expect("failed to parse stride24 model");
assert_eq!(model.node_stride, 24);
assert_eq!(model.node_count, 1);
assert_eq!(model.slot_index(0, 0, 0), None);
}
#[test]
fn parse_synthetic_model_with_optional_res4_res5_res10() {
let mut entries = base_synthetic_entries();
entries.push(synthetic_entry(RES4_NORMALS, "Res4", 4, res4_normals()));
entries.push(synthetic_entry(RES5_UV0, "Res5", 4, res5_uv0()));
entries.push(synthetic_entry(
RES10_NAMES,
"Res10",
1,
res10_names(&[Some("Hull"), None]),
));
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(2, Some(0)));
let payload = build_nested_nres(&entries);
let model = parse_model_payload(&payload).expect("failed to parse model with optional data");
assert_eq!(model.node_count, 2);
assert_eq!(model.normals.as_ref().map(Vec::len), Some(1));
assert_eq!(model.uv0.as_ref().map(Vec::len), Some(1));
assert_eq!(model.node_names, Some(vec![Some("Hull".to_string()), None]));
}
#[test]
fn parse_res10_names_decodes_cp1251() {
let mut entries = base_synthetic_entries();
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0)));
entries.push(synthetic_entry(
RES10_NAMES,
"Res10",
1,
res10_names_raw(&[Some(&[0xC0])]),
));
let payload = build_nested_nres(&entries);
let model = parse_model_payload(&payload).expect("failed to parse model with cp1251 name");
assert_eq!(model.node_names, Some(vec![Some("А".to_string())]));
}
#[test]
fn parse_fails_when_required_resource_missing() {
let mut entries = base_synthetic_entries();
entries.retain(|entry| entry.kind != RES13_BATCHES);
let payload = build_nested_nres(&entries);
assert!(matches!(
parse_model_payload(&payload),
Err(Error::MissingResource {
kind: RES13_BATCHES,
label: "Res13"
})
));
}
#[test]
fn parse_fails_for_invalid_res2_size() {
let mut entries = base_synthetic_entries();
entries[1] = synthetic_entry(RES2_SLOTS, "Res2", 68, vec![0u8; 0x8B]);
let payload = build_nested_nres(&entries);
assert!(matches!(
parse_model_payload(&payload),
Err(Error::InvalidRes2Size { .. })
));
}
#[test]
fn parse_fails_for_unsupported_node_stride() {
let mut entries = base_synthetic_entries();
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 30, vec![0u8; 30]);
let payload = build_nested_nres(&entries);
assert!(matches!(
parse_model_payload(&payload),
Err(Error::UnsupportedNodeStride { stride: 30 })
));
}
#[test]
fn parse_fails_for_invalid_optional_resource_size() {
let mut entries = base_synthetic_entries();
entries.push(synthetic_entry(RES4_NORMALS, "Res4", 4, vec![1, 2, 3]));
let payload = build_nested_nres(&entries);
assert!(matches!(
parse_model_payload(&payload),
Err(Error::InvalidResourceSize { label: "Res4", .. })
));
}
#[test]
fn parse_fails_for_slot_batch_range_out_of_bounds() {
let mut entries = base_synthetic_entries();
entries[1] = synthetic_entry(RES2_SLOTS, "Res2", 68, res2_single_slot(0, 2));
let payload = build_nested_nres(&entries);
assert!(matches!(
parse_model_payload(&payload),
Err(Error::IndexOutOfBounds {
label: "Res2.batch_range",
..
})
));
}
#[test]
fn parse_fails_for_batch_index_range_out_of_bounds() {
let mut entries = base_synthetic_entries();
entries[4] = synthetic_entry(RES13_BATCHES, "Res13", 20, res13_single_batch(1, 3));
let payload = build_nested_nres(&entries);
assert!(matches!(
parse_model_payload(&payload),
Err(Error::IndexOutOfBounds {
label: "Res13.index_range",
..
})
));
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn parse_model_payload_never_panics_on_random_bytes(data in proptest::collection::vec(any::<u8>(), 0..8192)) {
let _ = parse_model_payload(&data);
}
}
+100 -30
View File
@@ -26,10 +26,28 @@ pub enum OpenMode {
ReadWrite, ReadWrite,
} }
#[derive(Clone, Debug)]
pub struct ArchiveHeader {
pub magic: [u8; 4],
pub version: u32,
pub entry_count: u32,
pub total_size: u32,
pub directory_offset: u64,
pub directory_size: u64,
}
#[derive(Clone, Debug)]
pub struct ArchiveInfo {
pub raw_mode: bool,
pub file_size: u64,
pub header: Option<ArchiveHeader>,
}
#[derive(Debug)] #[derive(Debug)]
pub struct Archive { pub struct Archive {
bytes: Arc<[u8]>, bytes: Arc<[u8]>,
entries: Vec<EntryRecord>, entries: Vec<EntryRecord>,
info: ArchiveInfo,
raw_mode: bool, raw_mode: bool,
} }
@@ -54,6 +72,13 @@ pub struct EntryRef<'a> {
pub meta: &'a EntryMeta, pub meta: &'a EntryMeta,
} }
#[derive(Copy, Clone, Debug)]
pub struct EntryInspect<'a> {
pub id: EntryId,
pub meta: &'a EntryMeta,
pub name_raw: &'a [u8; 36],
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct EntryRecord { struct EntryRecord {
meta: EntryMeta, meta: EntryMeta,
@@ -76,29 +101,50 @@ impl Archive {
} }
pub fn open_bytes(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Self> { pub fn open_bytes(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Self> {
let (entries, _) = parse_archive(&bytes, opts.raw_mode)?; let file_size = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
let (entries, header) = parse_archive(&bytes, opts.raw_mode)?;
if opts.prefetch_pages { if opts.prefetch_pages {
prefetch_pages(&bytes); prefetch_pages(&bytes);
} }
Ok(Self { Ok(Self {
bytes, bytes,
entries, entries,
info: ArchiveInfo {
raw_mode: opts.raw_mode,
file_size,
header,
},
raw_mode: opts.raw_mode, raw_mode: opts.raw_mode,
}) })
} }
pub fn info(&self) -> &ArchiveInfo {
&self.info
}
pub fn entry_count(&self) -> usize { pub fn entry_count(&self) -> usize {
self.entries.len() self.entries.len()
} }
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> { pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
self.entries self.entries.iter().enumerate().filter_map(|(idx, entry)| {
.iter() let id = u32::try_from(idx).ok()?;
.enumerate() Some(EntryRef {
.map(|(idx, entry)| EntryRef { id: EntryId(id),
id: EntryId(u32::try_from(idx).expect("entry count validated at parse")),
meta: &entry.meta, meta: &entry.meta,
}) })
})
}
pub fn entries_inspect(&self) -> impl Iterator<Item = EntryInspect<'_>> {
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
let id = u32::try_from(idx).ok()?;
Some(EntryInspect {
id: EntryId(id),
meta: &entry.meta,
name_raw: &entry.name_raw,
})
})
} }
pub fn find(&self, name: &str) -> Option<EntryId> { pub fn find(&self, name: &str) -> Option<EntryId> {
@@ -125,9 +171,8 @@ impl Archive {
Ordering::Less => high = mid, Ordering::Less => high = mid,
Ordering::Greater => low = mid + 1, Ordering::Greater => low = mid + 1,
Ordering::Equal => { Ordering::Equal => {
return Some(EntryId( let id = u32::try_from(target_idx).ok()?;
u32::try_from(target_idx).expect("entry count validated at parse"), return Some(EntryId(id));
))
} }
} }
} }
@@ -137,9 +182,8 @@ impl Archive {
if cmp_name_case_insensitive(name.as_bytes(), entry_name_bytes(&entry.name_raw)) if cmp_name_case_insensitive(name.as_bytes(), entry_name_bytes(&entry.name_raw))
== Ordering::Equal == Ordering::Equal
{ {
Some(EntryId( let id = u32::try_from(idx).ok()?;
u32::try_from(idx).expect("entry count validated at parse"), Some(EntryId(id))
))
} else { } else {
None None
} }
@@ -155,6 +199,16 @@ impl Archive {
}) })
} }
pub fn inspect(&self, id: EntryId) -> Option<EntryInspect<'_>> {
let idx = usize::try_from(id.0).ok()?;
let entry = self.entries.get(idx)?;
Some(EntryInspect {
id,
meta: &entry.meta,
name_raw: &entry.name_raw,
})
}
pub fn read(&self, id: EntryId) -> Result<ResourceData<'_>> { pub fn read(&self, id: EntryId) -> Result<ResourceData<'_>> {
let range = self.entry_range(id)?; let range = self.entry_range(id)?;
Ok(ResourceData::Borrowed(&self.bytes[range])) Ok(ResourceData::Borrowed(&self.bytes[range]))
@@ -197,7 +251,7 @@ impl Archive {
let Some(entry) = self.entries.get(idx) else { let Some(entry) = self.entries.get(idx) else {
return Err(Error::EntryIdOutOfRange { return Err(Error::EntryIdOutOfRange {
id: id.0, id: id.0,
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX), entry_count: saturating_u32_len(self.entries.len()),
}); });
}; };
checked_range( checked_range(
@@ -248,13 +302,13 @@ pub struct NewEntry<'a> {
impl Editor { impl Editor {
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> { pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
self.entries self.entries.iter().enumerate().filter_map(|(idx, entry)| {
.iter() let id = u32::try_from(idx).ok()?;
.enumerate() Some(EntryRef {
.map(|(idx, entry)| EntryRef { id: EntryId(id),
id: EntryId(u32::try_from(idx).expect("entry count validated at add")),
meta: &entry.meta, meta: &entry.meta,
}) })
})
} }
pub fn add(&mut self, entry: NewEntry<'_>) -> Result<EntryId> { pub fn add(&mut self, entry: NewEntry<'_>) -> Result<EntryId> {
@@ -283,7 +337,7 @@ impl Editor {
let Some(entry) = self.entries.get_mut(idx) else { let Some(entry) = self.entries.get_mut(idx) else {
return Err(Error::EntryIdOutOfRange { return Err(Error::EntryIdOutOfRange {
id: id.0, id: id.0,
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX), entry_count: saturating_u32_len(self.entries.len()),
}); });
}; };
entry.meta.data_size = u32::try_from(data.len()).map_err(|_| Error::IntegerOverflow)?; entry.meta.data_size = u32::try_from(data.len()).map_err(|_| Error::IntegerOverflow)?;
@@ -297,7 +351,7 @@ impl Editor {
if idx >= self.entries.len() { if idx >= self.entries.len() {
return Err(Error::EntryIdOutOfRange { return Err(Error::EntryIdOutOfRange {
id: id.0, id: id.0,
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX), entry_count: saturating_u32_len(self.entries.len()),
}); });
} }
self.entries.remove(idx); self.entries.remove(idx);
@@ -350,6 +404,8 @@ impl Editor {
}); });
for (idx, entry) in self.entries.iter_mut().enumerate() { for (idx, entry) in self.entries.iter_mut().enumerate() {
// sort_index stores the original-entry index at sorted position `idx`.
// This mirrors the format emitted by the retail assets and test fixtures.
entry.meta.sort_index = entry.meta.sort_index =
u32::try_from(sort_order[idx]).map_err(|_| Error::IntegerOverflow)?; u32::try_from(sort_order[idx]).map_err(|_| Error::IntegerOverflow)?;
} }
@@ -377,7 +433,10 @@ impl Editor {
} }
} }
fn parse_archive(bytes: &[u8], raw_mode: bool) -> Result<(Vec<EntryRecord>, u64)> { fn parse_archive(
bytes: &[u8],
raw_mode: bool,
) -> Result<(Vec<EntryRecord>, Option<ArchiveHeader>)> {
if raw_mode { if raw_mode {
let data_size = u32::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?; let data_size = u32::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
let entry = EntryRecord { let entry = EntryRecord {
@@ -398,10 +457,7 @@ fn parse_archive(bytes: &[u8], raw_mode: bool) -> Result<(Vec<EntryRecord>, u64)
name name
}, },
}; };
return Ok(( return Ok((vec![entry], None));
vec![entry],
u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
));
} }
if bytes.len() < 16 { if bytes.len() < 16 {
@@ -526,7 +582,17 @@ fn parse_archive(bytes: &[u8], raw_mode: bool) -> Result<(Vec<EntryRecord>, u64)
}); });
} }
Ok((entries, directory_offset)) Ok((
entries,
Some(ArchiveHeader {
magic: *b"NRes",
version,
entry_count: u32::try_from(entry_count).map_err(|_| Error::IntegerOverflow)?,
total_size,
directory_offset,
directory_size: directory_len,
}),
))
} }
fn checked_range(offset: u64, size: u32, bytes_len: usize) -> Result<Range<usize>> { fn checked_range(offset: u64, size: u32, bytes_len: usize) -> Result<Range<usize>> {
@@ -599,8 +665,12 @@ fn ascii_lower(value: u8) -> u8 {
} }
} }
fn saturating_u32_len(len: usize) -> u32 {
u32::try_from(len).unwrap_or(u32::MAX)
}
fn prefetch_pages(bytes: &[u8]) { fn prefetch_pages(bytes: &[u8]) {
use std::sync::atomic::{compiler_fence, Ordering}; use std::hint::black_box;
let mut cursor = 0usize; let mut cursor = 0usize;
let mut sink = 0u8; let mut sink = 0u8;
@@ -608,8 +678,7 @@ fn prefetch_pages(bytes: &[u8]) {
sink ^= bytes[cursor]; sink ^= bytes[cursor];
cursor = cursor.saturating_add(4096); cursor = cursor.saturating_add(4096);
} }
compiler_fence(Ordering::SeqCst); black_box(sink);
let _ = sink;
} }
fn write_atomic(path: &Path, content: &[u8]) -> Result<()> { fn write_atomic(path: &Path, content: &[u8]) -> Result<()> {
@@ -675,7 +744,8 @@ fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> {
let src_wide: Vec<u16> = src.as_os_str().encode_wide().chain(iter::once(0)).collect(); 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(); 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. // SAFETY: pointers reference NUL-terminated UTF-16 buffers that stay alive
// for the duration of the call; flags and argument contract match WinAPI.
let ok = unsafe { let ok = unsafe {
MoveFileExW( MoveFileExW(
src_wide.as_ptr(), src_wide.as_ptr(),
+1 -14
View File
@@ -1,4 +1,5 @@
use super::*; use super::*;
use common::collect_files_recursive;
use std::any::Any; use std::any::Any;
use std::fs; use std::fs;
use std::panic::{catch_unwind, AssertUnwindSafe}; use std::panic::{catch_unwind, AssertUnwindSafe};
@@ -13,20 +14,6 @@ struct SyntheticEntry<'a> {
data: &'a [u8], 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> { fn nres_test_files() -> Vec<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR")) let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..") .join("..")
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "render-core"
version = "0.1.0"
edition = "2021"
[dependencies]
msh-core = { path = "../msh-core" }
[dev-dependencies]
common = { path = "../common" }
nres = { path = "../nres" }
+14
View File
@@ -0,0 +1,14 @@
# render-core
CPU-подготовка draw-данных для моделей `MSH`.
Покрывает:
- обход `node -> slot -> batch`;
- раскрытие индексов в triangle-list (`position + uv0`);
- расчёт bounds по вершинам.
Тесты:
- построение рендер-сеток на реальных `.msh` из `testdata`;
- unit-test bounds.
+146
View File
@@ -0,0 +1,146 @@
use msh_core::Model;
use std::collections::HashMap;
pub const DEFAULT_UV_SCALE: f32 = 1024.0;
#[derive(Clone, Debug)]
pub struct RenderVertex {
pub position: [f32; 3],
pub uv0: [f32; 2],
}
#[derive(Clone, Debug)]
pub struct RenderMesh {
pub vertices: Vec<RenderVertex>,
pub indices: Vec<u16>,
pub batch_count: usize,
pub index_overflow: bool,
}
impl RenderMesh {
pub fn triangle_count(&self) -> usize {
self.indices.len() / 3
}
}
/// Builds an indexed triangle mesh for a specific LOD/group pair.
pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh {
let mut vertices = Vec::new();
let mut indices = Vec::new();
let mut index_remap: HashMap<usize, u16> = HashMap::new();
let mut batch_count = 0usize;
let mut index_overflow = false;
let uv0 = model.uv0.as_ref();
for node_index in 0..model.node_count {
let Some(slot_idx) = model.slot_index(node_index, lod, group) else {
continue;
};
let Some(slot) = model.slots.get(slot_idx) else {
continue;
};
let batch_start = usize::from(slot.batch_start);
let batch_end = batch_start.saturating_add(usize::from(slot.batch_count));
if batch_end > model.batches.len() {
continue;
}
for batch in &model.batches[batch_start..batch_end] {
let index_start = usize::try_from(batch.index_start).unwrap_or(usize::MAX);
let index_count = usize::from(batch.index_count);
let index_end = index_start.saturating_add(index_count);
if index_end > model.indices.len() || index_count < 3 {
continue;
}
let batch_out_start = indices.len();
let mut batch_valid = true;
for &idx in &model.indices[index_start..index_end] {
let final_idx_u64 = u64::from(batch.base_vertex).saturating_add(u64::from(idx));
let Ok(final_idx) = usize::try_from(final_idx_u64) else {
batch_valid = false;
break;
};
let Some(pos) = model.positions.get(final_idx) else {
batch_valid = false;
break;
};
let local_index = if let Some(&mapped) = index_remap.get(&final_idx) {
mapped
} else {
let Ok(mapped) = u16::try_from(vertices.len()) else {
index_overflow = true;
batch_valid = false;
break;
};
let uv = uv0
.and_then(|uvs| uvs.get(final_idx))
.copied()
.map(|packed| {
[
packed[0] as f32 / DEFAULT_UV_SCALE,
packed[1] as f32 / DEFAULT_UV_SCALE,
]
})
.unwrap_or([0.0, 0.0]);
vertices.push(RenderVertex {
position: *pos,
uv0: uv,
});
index_remap.insert(final_idx, mapped);
mapped
};
indices.push(local_index);
}
if !batch_valid {
indices.truncate(batch_out_start);
continue;
}
batch_count += 1;
}
}
RenderMesh {
vertices,
indices,
batch_count,
index_overflow,
}
}
pub fn compute_bounds(vertices: &[[f32; 3]]) -> Option<([f32; 3], [f32; 3])> {
compute_bounds_impl(vertices.iter().copied())
}
pub fn compute_bounds_for_mesh(vertices: &[RenderVertex]) -> Option<([f32; 3], [f32; 3])> {
compute_bounds_impl(vertices.iter().map(|v| v.position))
}
fn compute_bounds_impl<I>(mut positions: I) -> Option<([f32; 3], [f32; 3])>
where
I: Iterator<Item = [f32; 3]>,
{
let first = positions.next()?;
let mut min_v = first;
let mut max_v = first;
for pos in positions {
for i in 0..3 {
if pos[i] < min_v[i] {
min_v[i] = pos[i];
}
if pos[i] > max_v[i] {
max_v[i] = pos[i];
}
}
}
Some((min_v, max_v))
}
#[cfg(test)]
mod tests;
+256
View File
@@ -0,0 +1,256 @@
use super::*;
use common::collect_files_recursive;
use msh_core::parse_model_payload;
use nres::Archive;
use std::fs;
use std::path::{Path, PathBuf};
fn nres_test_files() -> Vec<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata");
let mut files = Vec::new();
collect_files_recursive(&root, &mut files);
files.sort();
files
.into_iter()
.filter(|path| {
fs::read(path)
.map(|bytes| bytes.get(0..4) == Some(b"NRes"))
.unwrap_or(false)
})
.collect()
}
#[test]
fn build_render_mesh_for_real_models() {
let archives = nres_test_files();
if archives.is_empty() {
eprintln!("skipping build_render_mesh_for_real_models: no NRes files in testdata");
return;
}
let mut models_checked = 0usize;
let mut meshes_non_empty = 0usize;
let mut bounds_non_empty = 0usize;
for archive_path in archives {
let archive = Archive::open_path(&archive_path)
.unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display()));
for entry in archive.entries() {
if !entry.meta.name.to_ascii_lowercase().ends_with(".msh") {
continue;
}
models_checked += 1;
let payload = archive.read(entry.id).unwrap_or_else(|err| {
panic!(
"failed to read model '{}' from {}: {err}",
entry.meta.name,
archive_path.display()
)
});
let model = parse_model_payload(payload.as_slice()).unwrap_or_else(|err| {
panic!(
"failed to parse model '{}' from {}: {err}",
entry.meta.name,
archive_path.display()
)
});
let mesh = build_render_mesh(&model, 0, 0);
if !mesh.indices.is_empty() {
meshes_non_empty += 1;
}
if compute_bounds_for_mesh(&mesh.vertices).is_some() {
bounds_non_empty += 1;
}
for &index in &mesh.indices {
assert!(
usize::from(index) < mesh.vertices.len(),
"index out of bounds for '{}' in {}",
entry.meta.name,
archive_path.display()
);
}
for vertex in &mesh.vertices {
assert!(
vertex.uv0[0].is_finite() && vertex.uv0[1].is_finite(),
"UV must be finite for '{}' in {}",
entry.meta.name,
archive_path.display()
);
}
}
}
assert!(models_checked > 0, "no MSH models found");
assert!(
meshes_non_empty > 0,
"all generated render meshes are empty"
);
assert_eq!(
meshes_non_empty, bounds_non_empty,
"bounds must be available for every non-empty mesh"
);
}
#[test]
fn compute_bounds_handles_empty_and_non_empty() {
assert!(compute_bounds(&[]).is_none());
let bounds = compute_bounds(&[[1.0, 2.0, 3.0], [-2.0, 5.0, 0.5], [0.0, -1.0, 9.0]])
.expect("bounds expected");
assert_eq!(bounds.0, [-2.0, -1.0, 0.5]);
assert_eq!(bounds.1, [1.0, 5.0, 9.0]);
}
#[test]
fn compute_bounds_for_mesh_handles_empty_and_non_empty() {
assert!(compute_bounds_for_mesh(&[]).is_none());
let bounds = compute_bounds_for_mesh(&[
RenderVertex {
position: [1.0, 2.0, 3.0],
uv0: [0.0, 0.0],
},
RenderVertex {
position: [-2.0, 5.0, 0.5],
uv0: [0.2, 0.3],
},
RenderVertex {
position: [0.0, -1.0, 9.0],
uv0: [1.0, 1.0],
},
])
.expect("bounds expected");
assert_eq!(bounds.0, [-2.0, -1.0, 0.5]);
assert_eq!(bounds.1, [1.0, 5.0, 9.0]);
}
fn nodes_with_slot_refs(slot_ids: &[Option<u16>]) -> Vec<u8> {
let mut out = vec![0u8; slot_ids.len().saturating_mul(38)];
for (node_index, slot_id) in slot_ids.iter().copied().enumerate() {
let node_off = node_index * 38;
for i in 0..15 {
let off = node_off + 8 + i * 2;
out[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes());
}
if let Some(slot_id) = slot_id {
out[node_off + 8..node_off + 10].copy_from_slice(&slot_id.to_le_bytes());
}
}
out
}
fn slot(batch_start: u16, batch_count: u16) -> msh_core::Slot {
msh_core::Slot {
tri_start: 0,
tri_count: 0,
batch_start,
batch_count,
aabb_min: [0.0; 3],
aabb_max: [0.0; 3],
sphere_center: [0.0; 3],
sphere_radius: 0.0,
opaque: [0; 5],
}
}
fn batch(index_start: u32, index_count: u16, base_vertex: u32) -> msh_core::Batch {
msh_core::Batch {
batch_flags: 0,
material_index: 0,
opaque4: 0,
opaque6: 0,
index_count,
index_start,
opaque14: 0,
base_vertex,
}
}
#[test]
fn build_render_mesh_handles_empty_slot_model() {
let model = msh_core::Model {
node_stride: 38,
node_count: 1,
nodes_raw: nodes_with_slot_refs(&[None]),
slots: Vec::new(),
positions: vec![[0.0, 0.0, 0.0]],
normals: None,
uv0: None,
indices: Vec::new(),
batches: Vec::new(),
node_names: None,
};
let mesh = build_render_mesh(&model, 0, 0);
assert!(mesh.vertices.is_empty());
assert!(mesh.indices.is_empty());
assert_eq!(mesh.batch_count, 0);
assert_eq!(mesh.triangle_count(), 0);
}
#[test]
fn build_render_mesh_supports_multi_node_and_uv_scaling() {
let model = msh_core::Model {
node_stride: 38,
node_count: 2,
nodes_raw: nodes_with_slot_refs(&[Some(0), Some(1)]),
slots: vec![slot(0, 1), slot(1, 1)],
positions: vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[2.0, 0.0, 0.0],
[3.0, 0.0, 0.0],
[2.0, 1.0, 0.0],
],
normals: None,
uv0: Some(vec![
[1024, -1024],
[512, 256],
[0, 0],
[1024, 1024],
[2048, 1024],
[1024, 0],
]),
indices: vec![0, 1, 2, 0, 1, 2],
batches: vec![batch(0, 3, 0), batch(3, 3, 3)],
node_names: None,
};
let mesh = build_render_mesh(&model, 0, 0);
assert_eq!(mesh.batch_count, 2);
assert_eq!(mesh.vertices.len(), 6);
assert_eq!(mesh.indices, vec![0, 1, 2, 3, 4, 5]);
assert_eq!(mesh.triangle_count(), 2);
assert_eq!(mesh.vertices[0].uv0, [1.0, -1.0]);
assert_eq!(mesh.vertices[1].uv0, [0.5, 0.25]);
assert_eq!(mesh.vertices[2].uv0, [0.0, 0.0]);
assert_eq!(mesh.vertices[3].uv0, [1.0, 1.0]);
}
#[test]
fn build_render_mesh_deduplicates_shared_vertices() {
let model = msh_core::Model {
node_stride: 38,
node_count: 1,
nodes_raw: nodes_with_slot_refs(&[Some(0)]),
slots: vec![slot(0, 1)],
positions: vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[1.0, 1.0, 0.0],
],
normals: None,
uv0: None,
indices: vec![0, 1, 2, 2, 1, 3],
batches: vec![batch(0, 6, 0)],
node_names: None,
};
let mesh = build_render_mesh(&model, 0, 0);
assert_eq!(mesh.vertices.len(), 4);
assert_eq!(mesh.indices, vec![0, 1, 2, 2, 1, 3]);
assert_eq!(mesh.triangle_count(), 2);
}
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "render-demo"
version = "0.1.0"
edition = "2021"
[features]
default = []
demo = ["dep:sdl2", "dep:glow", "dep:image"]
[dependencies]
encoding_rs = "0.8"
msh-core = { path = "../msh-core" }
nres = { path = "../nres" }
render-core = { path = "../render-core" }
texm = { path = "../texm" }
glow = { version = "0.17", optional = true }
image = { version = "0.25", optional = true, default-features = false, features = ["png"] }
[dev-dependencies]
common = { path = "../common" }
[target.'cfg(target_os = "macos")'.dependencies]
sdl2 = { version = "0.38", optional = true, default-features = false, features = ["use-pkgconfig"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
sdl2 = { version = "0.38", optional = true, default-features = false, features = ["bundled", "static-link"] }
[[bin]]
name = "parkan-render-demo"
path = "src/main.rs"
required-features = ["demo"]
+84
View File
@@ -0,0 +1,84 @@
# render-demo
Тестовый рендерер Parkan-моделей на Rust (`SDL2 + OpenGL`: GLES2 с fallback на Core 3.3).
## Назначение
- Проверить, что `nres + msh-core + render-core` дают рабочий draw-path на реальных ассетах.
- Проверить текстурный path `WEAR -> MAT0 -> Texm` на реальных ассетах.
- Служить минимальным reference-приложением.
## Запуск
```bash
cargo run -p render-demo --features demo -- \
--archive "testdata/Parkan - Iron Strategy/animals.rlb" \
--model "A_L_01.msh" \
--lod 0 \
--group 0
```
### macOS prerequisites
Для macOS `render-demo` ожидает системный SDL2 через `pkg-config`:
```bash
brew install sdl2 pkg-config
```
После этого запускайте той же командой `cargo run ... --features demo`.
Параметры:
- `--archive` (обязательный): NRes-архив с `.msh` entry.
- `--model` (опционально): имя модели; если не задано, берётся первая `.msh`.
- `--lod` (опционально, default `0`).
- `--group` (опционально, default `0`).
- `--width`, `--height` (опционально, default `1280x720`).
- `--angle` (опционально): фиксированный угол поворота вокруг Y (в радианах).
- `--spin-rate` (опционально, default `0.35`): скорость вращения в интерактивном режиме.
- В интерактивном режиме FPS выводится в заголовок окна и в stdout (обновление примерно каждые 0.5 сек).
- `--texture <name>`: явное имя `Texm` (override авто-резолва).
- `--texture-archive <path>`: путь к архиву текстур (по умолчанию `textures.lib` рядом с `--archive`).
- `--material-archive <path>`: путь к `material.lib` (по умолчанию соседний `material.lib`).
- `--wear <name.wea>`: имя wear-entry внутри модельного архива (по умолчанию `<model_stem>.wea`).
- `--no-texture`: отключить текстуры и рендерить однотонным цветом.
## Авто-резолв текстуры
Если не передан `--texture`, демо пытается взять текстуру из игровых данных:
1. `model.msh -> model.wea` (первый wear-материал),
2. `material.lib` (`MAT0`) по имени материала с fallback `DEFAULT`,
3. первая непустая `textureName` фаза материала,
4. загрузка `Texm` из `textures.lib` (или `lightmap.lib` как fallback).
## Детерминированный снимок кадра
Для parity-проверок используется headless-сценарий с фиксированными параметрами:
```bash
cargo run -p render-demo --features demo -- \
--archive "testdata/Parkan - Iron Strategy/animals.rlb" \
--model "A_L_01.msh" \
--lod 0 \
--group 0 \
--width 1280 \
--height 720 \
--angle 0.0 \
--capture "target/render-parity/current/animals_a_l_01.png"
```
Явный выбор текстуры:
```bash
cargo run -p render-demo --features demo -- \
--archive "testdata/Parkan - Iron Strategy/animals.rlb" \
--model "A_L_01.msh" \
--texture "PG09.0"
```
## Ограничения
- Используется только базовая texture-фаза (без полной material/fx анимации).
- Вывод через `glDrawElements(GL_TRIANGLES)` с index-buffer (позиции+UV).
+4
View File
@@ -0,0 +1,4 @@
fn main() {
#[cfg(windows)]
println!("cargo:rustc-link-lib=advapi32");
}
+591
View File
@@ -0,0 +1,591 @@
use encoding_rs::WINDOWS_1251;
use msh_core::{parse_model_payload, Model};
use nres::{Archive, EntryRef};
use std::fmt;
use std::path::{Path, PathBuf};
use texm::{decode_mip_rgba8, parse_texm};
const WEAR_KIND: u32 = 0x5241_4557;
const MAT0_KIND: u32 = 0x3054_414D;
#[derive(Debug)]
pub enum Error {
Nres(nres::error::Error),
Msh(msh_core::error::Error),
Texm(texm::error::Error),
Io(std::io::Error),
NoMshEntries,
ModelNotFound(String),
NoTexmEntries,
TextureNotFound(String),
MaterialNotFound(String),
WearNotFound(String),
InvalidWear(String),
InvalidMaterial(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Nres(err) => write!(f, "{err}"),
Self::Msh(err) => write!(f, "{err}"),
Self::Texm(err) => write!(f, "{err}"),
Self::Io(err) => write!(f, "{err}"),
Self::NoMshEntries => write!(f, "archive does not contain .msh entries"),
Self::ModelNotFound(name) => write!(f, "model not found: {name}"),
Self::NoTexmEntries => write!(f, "archive does not contain Texm entries"),
Self::TextureNotFound(name) => write!(f, "texture not found: {name}"),
Self::MaterialNotFound(name) => write!(f, "material not found: {name}"),
Self::WearNotFound(name) => write!(f, "wear entry not found: {name}"),
Self::InvalidWear(reason) => write!(f, "invalid WEAR payload: {reason}"),
Self::InvalidMaterial(reason) => write!(f, "invalid MAT0 payload: {reason}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Nres(err) => Some(err),
Self::Msh(err) => Some(err),
Self::Texm(err) => Some(err),
Self::Io(err) => Some(err),
_ => None,
}
}
}
impl From<nres::error::Error> for Error {
fn from(value: nres::error::Error) -> Self {
Self::Nres(value)
}
}
impl From<msh_core::error::Error> for Error {
fn from(value: msh_core::error::Error) -> Self {
Self::Msh(value)
}
}
impl From<texm::error::Error> for Error {
fn from(value: texm::error::Error) -> Self {
Self::Texm(value)
}
}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
pub type Result<T> = core::result::Result<T, Error>;
#[derive(Clone, Debug)]
pub struct LoadedModel {
pub name: String,
pub model: Model,
}
#[derive(Clone, Debug)]
pub struct LoadedTexture {
pub name: String,
pub width: u32,
pub height: u32,
pub rgba8: Vec<u8>,
}
pub fn load_model_with_name_from_archive(
path: &Path,
model_name: Option<&str>,
) -> Result<LoadedModel> {
let archive = Archive::open_path(path)?;
let mut msh_entries = Vec::new();
for entry in archive.entries() {
if entry.meta.name.to_ascii_lowercase().ends_with(".msh") {
msh_entries.push((entry.id, entry.meta.name.clone()));
}
}
if msh_entries.is_empty() {
return Err(Error::NoMshEntries);
}
let target_id = if let Some(name) = model_name {
msh_entries
.iter()
.find(|(_, n)| n.eq_ignore_ascii_case(name))
.map(|(id, _)| *id)
.ok_or_else(|| Error::ModelNotFound(name.to_string()))?
} else {
msh_entries[0].0
};
let target_name = archive
.get(target_id)
.map(|entry| entry.meta.name.clone())
.unwrap_or_else(|| String::from("<unknown>"));
let payload = archive.read(target_id)?;
Ok(LoadedModel {
name: target_name,
model: parse_model_payload(payload.as_slice())?,
})
}
pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result<Model> {
Ok(load_model_with_name_from_archive(path, model_name)?.model)
}
pub fn load_texture_from_archive(path: &Path, texture_name: Option<&str>) -> Result<LoadedTexture> {
let archive = Archive::open_path(path)?;
if let Some(name) = texture_name {
return load_texture_from_archive_by_name(&archive, name);
}
let mut texm_entries = archive
.entries()
.filter(|entry| entry.meta.kind == texm::TEXM_MAGIC)
.collect::<Vec<_>>();
if texm_entries.is_empty() {
return Err(Error::NoTexmEntries);
}
texm_entries.sort_by(|a, b| {
a.meta
.name
.to_ascii_lowercase()
.cmp(&b.meta.name.to_ascii_lowercase())
});
let first = texm_entries[0];
decode_texture_entry(&archive, first)
}
pub fn resolve_texture_for_model(
model_archive_path: &Path,
model_entry_name: &str,
texture_name_override: Option<&str>,
textures_archive_override: Option<&Path>,
material_archive_override: Option<&Path>,
wear_entry_override: Option<&str>,
) -> Result<Option<LoadedTexture>> {
if let Some(name) = texture_name_override {
return load_texture_by_name_from_candidate_archives(
name,
candidate_texture_archives(model_archive_path, textures_archive_override),
)
.map(Some);
}
let wear_entry_name = if let Some(name) = wear_entry_override {
name.to_string()
} else {
derive_wear_entry_name(model_entry_name).ok_or_else(|| {
Error::WearNotFound(format!(
"cannot derive WEAR name from model '{model_entry_name}'"
))
})?
};
let model_archive = Archive::open_path(model_archive_path)?;
let wear_materials = parse_wear_material_names(
read_entry_by_name_kind(&model_archive, &wear_entry_name, WEAR_KIND)?
.0
.as_slice(),
)?;
let Some(primary_material) = wear_materials.first() else {
return Ok(None);
};
let material_path = if let Some(path) = material_archive_override {
path.to_path_buf()
} else {
sibling_archive_path(model_archive_path, "material.lib")
.ok_or_else(|| Error::MaterialNotFound(String::from("material.lib")))?
};
let material_archive = Archive::open_path(&material_path)?;
let material_entry = find_material_entry_with_fallback(&material_archive, primary_material)?;
let material_payload = material_archive.read(material_entry.id)?.into_owned();
let texture_name =
parse_primary_texture_name_from_mat0(&material_payload, material_entry.meta.attr2)?;
let Some(texture_name) = texture_name else {
return Ok(None);
};
let texture = load_texture_by_name_from_candidate_archives(
&texture_name,
candidate_texture_archives(model_archive_path, textures_archive_override),
)?;
Ok(Some(texture))
}
fn load_texture_by_name_from_candidate_archives(
texture_name: &str,
archives: Vec<PathBuf>,
) -> Result<LoadedTexture> {
let mut last_not_found = None;
for archive_path in archives {
if !archive_path.is_file() {
continue;
}
let archive = Archive::open_path(&archive_path)?;
match load_texture_from_archive_by_name(&archive, texture_name) {
Ok(texture) => return Ok(texture),
Err(Error::TextureNotFound(name)) => {
last_not_found = Some(name);
}
Err(other) => return Err(other),
}
}
Err(Error::TextureNotFound(
last_not_found.unwrap_or_else(|| texture_name.to_string()),
))
}
fn candidate_texture_archives(
model_archive_path: &Path,
textures_archive_override: Option<&Path>,
) -> Vec<PathBuf> {
if let Some(path) = textures_archive_override {
return vec![path.to_path_buf()];
}
let mut out = Vec::new();
if let Some(path) = sibling_archive_path(model_archive_path, "textures.lib") {
out.push(path);
}
if let Some(path) = sibling_archive_path(model_archive_path, "lightmap.lib") {
out.push(path);
}
out
}
fn sibling_archive_path(model_archive_path: &Path, name: &str) -> Option<PathBuf> {
let parent = model_archive_path.parent()?;
Some(parent.join(name))
}
fn derive_wear_entry_name(model_entry_name: &str) -> Option<String> {
let stem = model_entry_name.rsplit_once('.').map(|(left, _)| left)?;
Some(format!("{stem}.wea"))
}
fn read_entry_by_name_kind(
archive: &Archive,
name: &str,
expected_kind: u32,
) -> Result<(Vec<u8>, String)> {
let Some(id) = archive.find(name) else {
return Err(Error::WearNotFound(name.to_string()));
};
let Some(entry) = archive.get(id) else {
return Err(Error::WearNotFound(name.to_string()));
};
if entry.meta.kind != expected_kind {
return Err(Error::WearNotFound(name.to_string()));
}
let payload = archive.read(id)?.into_owned();
Ok((payload, entry.meta.name.clone()))
}
fn find_material_entry_with_fallback<'a>(
archive: &'a Archive,
requested_name: &str,
) -> Result<EntryRef<'a>> {
if let Some(id) = archive.find(requested_name) {
if let Some(entry) = archive.get(id) {
if entry.meta.kind == MAT0_KIND {
return Ok(entry);
}
}
}
if let Some(id) = archive.find("DEFAULT") {
if let Some(entry) = archive.get(id) {
if entry.meta.kind == MAT0_KIND {
return Ok(entry);
}
}
}
let Some(entry) = archive.entries().find(|entry| entry.meta.kind == MAT0_KIND) else {
return Err(Error::MaterialNotFound(requested_name.to_string()));
};
Ok(entry)
}
fn parse_wear_material_names(payload: &[u8]) -> Result<Vec<String>> {
let text = decode_cp1251(payload).replace('\r', "");
let mut lines = text.lines();
let Some(first) = lines.next() else {
return Err(Error::InvalidWear(String::from("WEAR payload is empty")));
};
let count = first
.trim()
.parse::<usize>()
.map_err(|_| Error::InvalidWear(format!("invalid wearCount line: '{first}'")))?;
if count == 0 {
return Err(Error::InvalidWear(String::from("wearCount must be > 0")));
}
let mut materials = Vec::with_capacity(count);
for idx in 0..count {
let Some(line) = lines.next() else {
return Err(Error::InvalidWear(format!(
"missing material line {idx} of {count}"
)));
};
let mut parts = line.split_whitespace();
let _legacy = parts
.next()
.ok_or_else(|| Error::InvalidWear(format!("invalid material line {idx}: '{line}'")))?;
let name = parts
.next()
.ok_or_else(|| Error::InvalidWear(format!("invalid material line {idx}: '{line}'")))?;
materials.push(name.to_string());
}
Ok(materials)
}
fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Option<String>> {
if payload.len() < 4 {
return Err(Error::InvalidMaterial(String::from(
"MAT0 payload is too small for header",
)));
}
let phase_count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
if phase_count == 0 {
return Ok(None);
}
let mut offset = 4usize;
if attr2 >= 2 {
offset = offset
.checked_add(2)
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?;
}
if attr2 >= 3 {
offset = offset
.checked_add(4)
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?;
}
if attr2 >= 4 {
offset = offset
.checked_add(4)
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?;
}
for phase in 0..phase_count {
let phase_off = offset
.checked_add(phase.checked_mul(34).ok_or_else(|| {
Error::InvalidMaterial(String::from("MAT0 phase offset overflow"))
})?)
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 phase offset overflow")))?;
let phase_end = phase_off
.checked_add(34)
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 phase offset overflow")))?;
let Some(rec) = payload.get(phase_off..phase_end) else {
return Err(Error::InvalidMaterial(format!(
"MAT0 phase {phase} is out of bounds"
)));
};
let name_raw = &rec[18..34];
let name_end = name_raw
.iter()
.position(|&b| b == 0)
.unwrap_or(name_raw.len());
let name = decode_cp1251(&name_raw[..name_end]).trim().to_string();
if !name.is_empty() {
return Ok(Some(name));
}
}
Ok(None)
}
fn decode_cp1251(bytes: &[u8]) -> String {
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
decoded.into_owned()
}
fn load_texture_from_archive_by_name(archive: &Archive, name: &str) -> Result<LoadedTexture> {
let Some(id) = archive.find(name) else {
return Err(Error::TextureNotFound(name.to_string()));
};
let Some(entry) = archive.get(id) else {
return Err(Error::TextureNotFound(name.to_string()));
};
if entry.meta.kind != texm::TEXM_MAGIC {
return Err(Error::TextureNotFound(name.to_string()));
}
decode_texture_entry(archive, entry)
}
fn decode_texture_entry(archive: &Archive, entry: EntryRef<'_>) -> Result<LoadedTexture> {
let payload = archive.read(entry.id)?.into_owned();
let parsed = parse_texm(&payload)?;
let decoded = decode_mip_rgba8(&parsed, &payload, 0)?;
Ok(LoadedTexture {
name: entry.meta.name.clone(),
width: decoded.width,
height: decoded.height,
rgba8: decoded.rgba8,
})
}
#[cfg(test)]
mod tests {
use super::*;
use common::collect_files_recursive;
use std::fs;
use std::path::{Path, PathBuf};
fn archive_with_msh() -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata");
let mut files = Vec::new();
collect_files_recursive(&root, &mut files);
files.sort();
for path in files {
let Ok(bytes) = fs::read(&path) else {
continue;
};
if bytes.get(0..4) != Some(b"NRes") {
continue;
}
let Ok(archive) = Archive::open_path(&path) else {
continue;
};
if archive
.entries()
.any(|entry| entry.meta.name.to_ascii_lowercase().ends_with(".msh"))
{
return Some(path);
}
}
None
}
fn game_root() -> Option<PathBuf> {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata")
.join("Parkan - Iron Strategy");
if path.is_dir() {
Some(path)
} else {
None
}
}
#[test]
fn load_model_from_real_archive() {
let Some(path) = archive_with_msh() else {
eprintln!("skipping load_model_from_real_archive: no .msh archives in testdata");
return;
};
let model = load_model_from_archive(&path, None)
.unwrap_or_else(|err| panic!("failed to load model from {}: {err:?}", path.display()));
assert!(model.node_count > 0);
assert!(!model.positions.is_empty());
assert!(!model.indices.is_empty());
}
#[test]
fn resolve_texture_for_real_model_via_wear_and_material() {
let Some(root) = game_root() else {
eprintln!(
"skipping resolve_texture_for_real_model_via_wear_and_material: no game root"
);
return;
};
let archive = root.join("animals.rlb");
if !archive.is_file() {
eprintln!("skipping resolve_texture_for_real_model_via_wear_and_material: missing animals.rlb");
return;
}
let loaded = load_model_with_name_from_archive(&archive, Some("A_L_01.msh"))
.unwrap_or_else(|err| {
panic!(
"failed to load model A_L_01.msh from {}: {err:?}",
archive.display()
)
});
let texture = resolve_texture_for_model(&archive, &loaded.name, None, None, None, None)
.unwrap_or_else(|err| panic!("failed to resolve texture for {}: {err:?}", loaded.name))
.expect("texture must be resolved for A_L_01.msh");
assert!(texture.width > 0 && texture.height > 0);
assert_eq!(
texture.rgba8.len(),
usize::try_from(texture.width)
.ok()
.and_then(|w| usize::try_from(texture.height).ok().map(|h| w * h * 4))
.unwrap_or(0)
);
}
#[test]
fn load_first_texture_from_real_archive() {
let Some(root) = game_root() else {
eprintln!("skipping load_first_texture_from_real_archive: no game root");
return;
};
let archive = root.join("textures.lib");
if !archive.is_file() {
eprintln!("skipping load_first_texture_from_real_archive: missing textures.lib");
return;
}
let texture = load_texture_from_archive(&archive, None).unwrap_or_else(|err| {
panic!(
"failed to load first texture from {}: {err:?}",
archive.display()
)
});
assert!(texture.width > 0 && texture.height > 0);
assert!(!texture.rgba8.is_empty());
}
#[test]
fn parse_wear_material_names_parses_counted_lines() {
let payload = b"2\r\n0 MAT_A\r\n1 MAT_B\r\n";
let materials =
parse_wear_material_names(payload).expect("failed to parse valid WEAR payload");
assert_eq!(materials, vec!["MAT_A".to_string(), "MAT_B".to_string()]);
}
#[test]
fn parse_wear_material_names_rejects_invalid_payload() {
let payload = b"2\n0 ONLY_ONE\n";
assert!(matches!(
parse_wear_material_names(payload),
Err(Error::InvalidWear(_))
));
}
#[test]
fn parse_primary_texture_name_from_mat0_respects_attr2_layout() {
let mut payload = vec![0u8; 4 + 10 + 34];
payload[0..2].copy_from_slice(&1u16.to_le_bytes()); // phase_count
// attr2=4 adds 10 bytes before phase table
let name = b"TEX_MAIN";
payload[4 + 10 + 18..4 + 10 + 18 + name.len()].copy_from_slice(name);
let parsed = parse_primary_texture_name_from_mat0(&payload, 4)
.expect("failed to parse MAT0 payload with attr2=4");
assert_eq!(parsed, Some("TEX_MAIN".to_string()));
}
#[test]
fn parse_primary_texture_name_from_mat0_decodes_cp1251_bytes() {
let mut payload = vec![0u8; 4 + 34];
payload[0..2].copy_from_slice(&1u16.to_le_bytes()); // phase_count
payload[4 + 18] = 0xC0; // 'А' in CP1251
let parsed =
parse_primary_texture_name_from_mat0(&payload, 0).expect("failed to parse MAT0");
assert_eq!(parsed, Some("А".to_string()));
}
}
+997
View File
@@ -0,0 +1,997 @@
use glow::HasContext as _;
use render_core::{build_render_mesh, compute_bounds_for_mesh};
use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
struct Args {
archive: PathBuf,
model: Option<String>,
lod: usize,
group: usize,
width: u32,
height: u32,
fov_deg: f32,
capture: Option<PathBuf>,
angle: Option<f32>,
spin_rate: f32,
texture: Option<String>,
texture_archive: Option<PathBuf>,
material_archive: Option<PathBuf>,
wear: Option<String>,
no_texture: bool,
}
struct GpuTexture {
handle: glow::NativeTexture,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum GlBackend {
Gles2,
Core33,
}
fn parse_args() -> Result<Args, String> {
let mut archive = None;
let mut model = None;
let mut lod = 0usize;
let mut group = 0usize;
let mut width = 1280u32;
let mut height = 720u32;
let mut fov_deg = 60.0f32;
let mut capture = None;
let mut angle = None;
let mut spin_rate = 0.35f32;
let mut texture = None;
let mut texture_archive = None;
let mut material_archive = None;
let mut wear = None;
let mut no_texture = false;
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
match arg.as_str() {
"--archive" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --archive"))?;
archive = Some(PathBuf::from(value));
}
"--model" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --model"))?;
model = Some(value);
}
"--lod" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --lod"))?;
lod = value
.parse::<usize>()
.map_err(|_| String::from("invalid --lod value"))?;
}
"--group" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --group"))?;
group = value
.parse::<usize>()
.map_err(|_| String::from("invalid --group value"))?;
}
"--width" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --width"))?;
width = value
.parse::<u32>()
.map_err(|_| String::from("invalid --width value"))?;
if width == 0 {
return Err(String::from("--width must be > 0"));
}
}
"--height" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --height"))?;
height = value
.parse::<u32>()
.map_err(|_| String::from("invalid --height value"))?;
if height == 0 {
return Err(String::from("--height must be > 0"));
}
}
"--fov" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --fov"))?;
fov_deg = value
.parse::<f32>()
.map_err(|_| String::from("invalid --fov value"))?;
if !(1.0..=179.0).contains(&fov_deg) {
return Err(String::from("--fov must be in range [1, 179]"));
}
}
"--capture" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --capture"))?;
capture = Some(PathBuf::from(value));
}
"--angle" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --angle"))?;
angle = Some(
value
.parse::<f32>()
.map_err(|_| String::from("invalid --angle value"))?,
);
}
"--spin-rate" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --spin-rate"))?;
spin_rate = value
.parse::<f32>()
.map_err(|_| String::from("invalid --spin-rate value"))?;
}
"--texture" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --texture"))?;
texture = Some(value);
}
"--texture-archive" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --texture-archive"))?;
texture_archive = Some(PathBuf::from(value));
}
"--material-archive" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --material-archive"))?;
material_archive = Some(PathBuf::from(value));
}
"--wear" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --wear"))?;
wear = Some(value);
}
"--no-texture" => {
no_texture = true;
}
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
other => {
return Err(format!("unknown argument: {other}"));
}
}
}
let archive = archive.ok_or_else(|| String::from("missing required --archive"))?;
Ok(Args {
archive,
model,
lod,
group,
width,
height,
fov_deg,
capture,
angle,
spin_rate,
texture,
texture_archive,
material_archive,
wear,
no_texture,
})
}
fn print_help() {
eprintln!(
"parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N] [--width W] [--height H] [--fov DEG]"
);
eprintln!(" [--capture <out.png>] [--angle RAD] [--spin-rate RAD_PER_SEC]");
eprintln!(" [--texture <name>] [--texture-archive <path>] [--material-archive <path>] [--wear <name.wea>] [--no-texture]");
}
fn main() {
let args = match parse_args() {
Ok(v) => v,
Err(err) => {
eprintln!("{err}");
print_help();
std::process::exit(2);
}
};
if let Err(err) = run(args) {
eprintln!("{err}");
std::process::exit(1);
}
}
fn run(args: Args) -> Result<(), String> {
let loaded_model = load_model_with_name_from_archive(&args.archive, args.model.as_deref())
.map_err(|err| {
format!(
"failed to load model from archive {}: {err}",
args.archive.display()
)
})?;
let mesh = build_render_mesh(&loaded_model.model, args.lod, args.group);
if mesh.indices.is_empty() {
return Err(format!(
"model has no renderable triangles for lod={} group={}",
args.lod, args.group
));
}
if mesh.index_overflow {
eprintln!(
"warning: mesh exceeds u16 index space and may be partially rendered on GLES2 targets"
);
}
let Some((bounds_min, bounds_max)) = compute_bounds_for_mesh(&mesh.vertices) else {
return Err(String::from("failed to compute mesh bounds"));
};
let resolved_texture = resolve_texture(&args, &loaded_model.name)?;
if let Some(tex) = resolved_texture.as_ref() {
println!(
"resolved texture '{}' ({}x{})",
tex.name, tex.width, tex.height
);
} else {
println!("texture path disabled or unresolved; rendering with fallback color");
}
let center = [
0.5 * (bounds_min[0] + bounds_max[0]),
0.5 * (bounds_min[1] + bounds_max[1]),
0.5 * (bounds_min[2] + bounds_max[2]),
];
let extent = [
bounds_max[0] - bounds_min[0],
bounds_max[1] - bounds_min[1],
bounds_max[2] - bounds_min[2],
];
let radius =
(extent[0] * extent[0] + extent[1] * extent[1] + extent[2] * extent[2]).sqrt() * 0.5;
let camera_distance = (radius * 2.5).max(2.0);
let sdl = sdl2::init().map_err(|err| format!("failed to init SDL2: {err}"))?;
let video = sdl
.video()
.map_err(|err| format!("failed to init SDL2 video: {err}"))?;
let (mut window, _gl_ctx, gl_backend) = create_window_and_context(&video, &args)?;
let _ = if args.capture.is_some() {
video.gl_set_swap_interval(0)
} else {
video.gl_set_swap_interval(1)
};
let mut vertex_data = Vec::with_capacity(mesh.vertices.len() * 5);
for vertex in &mesh.vertices {
vertex_data.push(vertex.position[0]);
vertex_data.push(vertex.position[1]);
vertex_data.push(vertex.position[2]);
vertex_data.push(vertex.uv0[0]);
vertex_data.push(vertex.uv0[1]);
}
let vertex_bytes = f32_slice_to_ne_bytes(&vertex_data);
let index_bytes = u16_slice_to_ne_bytes(&mesh.indices);
let gl = unsafe {
glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _)
};
let program = unsafe { create_program(&gl, gl_backend)? };
let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") };
let u_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") };
let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") };
let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }
.ok_or_else(|| String::from("shader attribute a_pos is missing"))?;
let a_uv = unsafe { gl.get_attrib_location(program, "a_uv") }
.ok_or_else(|| String::from("shader attribute a_uv is missing"))?;
let vbo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? };
let ebo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? };
unsafe {
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW);
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
gl.buffer_data_u8_slice(glow::ELEMENT_ARRAY_BUFFER, &index_bytes, glow::STATIC_DRAW);
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
gl.bind_buffer(glow::ARRAY_BUFFER, None);
}
let vao = unsafe { create_vertex_layout_if_needed(&gl, gl_backend, vbo, ebo, a_pos, a_uv)? };
let gpu_texture = if let Some(texture) = resolved_texture.as_ref() {
Some(unsafe { create_texture(&gl, texture)? })
} else {
None
};
let result = if let Some(capture_path) = args.capture.as_ref() {
run_capture(
&gl,
program,
u_mvp.as_ref(),
u_use_tex.as_ref(),
u_tex.as_ref(),
a_pos,
a_uv,
vbo,
ebo,
vao,
gpu_texture.as_ref(),
mesh.indices.len(),
&args,
center,
camera_distance,
capture_path,
)
} else {
run_interactive(
&sdl,
&mut window,
&gl,
program,
u_mvp.as_ref(),
u_use_tex.as_ref(),
u_tex.as_ref(),
a_pos,
a_uv,
vbo,
ebo,
vao,
gpu_texture.as_ref(),
mesh.indices.len(),
&args,
center,
camera_distance,
)
};
unsafe {
if let Some(texture) = gpu_texture {
gl.delete_texture(texture.handle);
}
if let Some(vao) = vao {
gl.delete_vertex_array(vao);
}
gl.delete_buffer(ebo);
gl.delete_buffer(vbo);
gl.delete_program(program);
}
result
}
fn create_window_and_context(
video: &sdl2::VideoSubsystem,
args: &Args,
) -> Result<(sdl2::video::Window, sdl2::video::GLContext, GlBackend), String> {
let candidates = [
(GlBackend::Gles2, sdl2::video::GLProfile::GLES, 2, 0),
(GlBackend::Core33, sdl2::video::GLProfile::Core, 3, 3),
];
let mut errors = Vec::new();
for (backend, profile, major, minor) in candidates {
{
let gl_attr = video.gl_attr();
gl_attr.set_context_profile(profile);
gl_attr.set_context_version(major, minor);
gl_attr.set_depth_size(24);
gl_attr.set_double_buffer(true);
}
let mut window_builder = video.window(
"Parkan Render Demo (SDL2 + OpenGL)",
args.width,
args.height,
);
window_builder.opengl();
if args.capture.is_some() {
window_builder.hidden();
} else {
window_builder.resizable();
}
let window = match window_builder.build() {
Ok(window) => window,
Err(err) => {
errors.push(format!(
"{profile:?} {major}.{minor}: window build failed ({err})"
));
continue;
}
};
let gl_ctx = match window.gl_create_context() {
Ok(ctx) => ctx,
Err(err) => {
errors.push(format!(
"{profile:?} {major}.{minor}: context create failed ({err})"
));
continue;
}
};
if let Err(err) = window.gl_make_current(&gl_ctx) {
errors.push(format!(
"{profile:?} {major}.{minor}: make current failed ({err})"
));
continue;
}
return Ok((window, gl_ctx, backend));
}
Err(format!(
"failed to create OpenGL context. Attempts: {}",
errors.join(" | ")
))
}
unsafe fn create_vertex_layout_if_needed(
gl: &glow::Context,
backend: GlBackend,
vbo: glow::NativeBuffer,
ebo: glow::NativeBuffer,
a_pos: u32,
a_uv: u32,
) -> Result<Option<glow::NativeVertexArray>, String> {
if backend != GlBackend::Core33 {
return Ok(None);
}
let vao = gl.create_vertex_array().map_err(|e| e.to_string())?;
gl.bind_vertex_array(Some(vao));
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
gl.enable_vertex_attrib_array(a_pos);
gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
gl.enable_vertex_attrib_array(a_uv);
gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
gl.bind_vertex_array(None);
Ok(Some(vao))
}
fn resolve_texture(args: &Args, model_name: &str) -> Result<Option<LoadedTexture>, String> {
if args.no_texture {
return Ok(None);
}
match resolve_texture_for_model(
&args.archive,
model_name,
args.texture.as_deref(),
args.texture_archive.as_deref(),
args.material_archive.as_deref(),
args.wear.as_deref(),
) {
Ok(texture) => Ok(texture),
Err(err) => {
if args.texture.is_some()
|| args.texture_archive.is_some()
|| args.material_archive.is_some()
|| args.wear.is_some()
{
Err(format!("failed to resolve texture: {err}"))
} else {
eprintln!("warning: auto texture resolve failed ({err}), fallback to solid color");
Ok(None)
}
}
}
}
unsafe fn create_texture(
gl: &glow::Context,
texture: &LoadedTexture,
) -> Result<GpuTexture, String> {
let handle = gl.create_texture().map_err(|e| e.to_string())?;
gl.bind_texture(glow::TEXTURE_2D, Some(handle));
gl.tex_parameter_i32(
glow::TEXTURE_2D,
glow::TEXTURE_MIN_FILTER,
glow::LINEAR as i32,
);
gl.tex_parameter_i32(
glow::TEXTURE_2D,
glow::TEXTURE_MAG_FILTER,
glow::LINEAR as i32,
);
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 1);
gl.tex_image_2d(
glow::TEXTURE_2D,
0,
glow::RGBA as i32,
texture.width.min(i32::MAX as u32) as i32,
texture.height.min(i32::MAX as u32) as i32,
0,
glow::RGBA,
glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice(Some(texture.rgba8.as_slice())),
);
gl.bind_texture(glow::TEXTURE_2D, None);
Ok(GpuTexture { handle })
}
#[allow(clippy::too_many_arguments)]
fn run_capture(
gl: &glow::Context,
program: glow::NativeProgram,
u_mvp: Option<&glow::NativeUniformLocation>,
u_use_tex: Option<&glow::NativeUniformLocation>,
u_tex: Option<&glow::NativeUniformLocation>,
a_pos: u32,
a_uv: u32,
vbo: glow::NativeBuffer,
ebo: glow::NativeBuffer,
vao: Option<glow::NativeVertexArray>,
texture: Option<&GpuTexture>,
index_count: usize,
args: &Args,
center: [f32; 3],
camera_distance: f32,
capture_path: &Path,
) -> Result<(), String> {
let angle = args.angle.unwrap_or(0.0);
let mvp = compute_mvp(
args.width,
args.height,
args.fov_deg,
center,
camera_distance,
angle,
);
unsafe {
draw_frame(
gl,
program,
u_mvp,
u_use_tex,
u_tex,
a_pos,
a_uv,
vbo,
ebo,
vao,
texture,
index_count,
args.width,
args.height,
&mvp,
);
}
let mut rgba = unsafe { read_pixels_rgba(gl, args.width, args.height)? };
flip_image_y_rgba(&mut rgba, args.width as usize, args.height as usize);
save_png(capture_path, args.width, args.height, rgba)?;
println!("captured frame to {}", capture_path.display());
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn run_interactive(
sdl: &sdl2::Sdl,
window: &mut sdl2::video::Window,
gl: &glow::Context,
program: glow::NativeProgram,
u_mvp: Option<&glow::NativeUniformLocation>,
u_use_tex: Option<&glow::NativeUniformLocation>,
u_tex: Option<&glow::NativeUniformLocation>,
a_pos: u32,
a_uv: u32,
vbo: glow::NativeBuffer,
ebo: glow::NativeBuffer,
vao: Option<glow::NativeVertexArray>,
texture: Option<&GpuTexture>,
index_count: usize,
args: &Args,
center: [f32; 3],
camera_distance: f32,
) -> Result<(), String> {
let mut events = sdl
.event_pump()
.map_err(|err| format!("failed to get SDL event pump: {err}"))?;
let start = Instant::now();
let mut fps_window_start = Instant::now();
let mut fps_frames: u32 = 0;
let mut fps_printed = false;
let base_title = "Parkan Render Demo (SDL2 + OpenGL)";
'main_loop: loop {
for event in events.poll_iter() {
match event {
sdl2::event::Event::Quit { .. } => break 'main_loop,
sdl2::event::Event::KeyDown {
keycode: Some(sdl2::keyboard::Keycode::Escape),
..
} => break 'main_loop,
_ => {}
}
}
let (w, h) = window.size();
let angle = args
.angle
.unwrap_or(start.elapsed().as_secs_f32() * args.spin_rate);
let mvp = compute_mvp(w, h, args.fov_deg, center, camera_distance, angle);
unsafe {
draw_frame(
gl,
program,
u_mvp,
u_use_tex,
u_tex,
a_pos,
a_uv,
vbo,
ebo,
vao,
texture,
index_count,
w,
h,
&mvp,
);
}
window.gl_swap_window();
fps_frames = fps_frames.saturating_add(1);
let elapsed = fps_window_start.elapsed();
if elapsed >= Duration::from_millis(500) {
let fps = fps_frames as f32 / elapsed.as_secs_f32().max(0.000_1);
let frame_time_ms = 1000.0 / fps.max(0.000_1);
let _ = window.set_title(&format!(
"{base_title} | FPS: {fps:.1} ({frame_time_ms:.2} ms)"
));
print!("\rFPS: {fps:.1} ({frame_time_ms:.2} ms)");
let _ = std::io::stdout().flush();
fps_printed = true;
fps_frames = 0;
fps_window_start = Instant::now();
}
}
if fps_printed {
println!();
}
Ok(())
}
fn compute_mvp(
width: u32,
height: u32,
fov_deg: f32,
center: [f32; 3],
camera_distance: f32,
angle_rad: f32,
) -> [f32; 16] {
let aspect = (width as f32 / (height.max(1) as f32)).max(0.01);
let proj = mat4_perspective(fov_deg.to_radians(), aspect, 0.01, camera_distance * 10.0);
let view = mat4_translation(0.0, 0.0, -camera_distance);
let center_shift = mat4_translation(-center[0], -center[1], -center[2]);
let rot = mat4_rotation_y(angle_rad);
let model_m = mat4_mul(&rot, &center_shift);
let vp = mat4_mul(&view, &model_m);
mat4_mul(&proj, &vp)
}
#[allow(clippy::too_many_arguments)]
unsafe fn draw_frame(
gl: &glow::Context,
program: glow::NativeProgram,
u_mvp: Option<&glow::NativeUniformLocation>,
u_use_tex: Option<&glow::NativeUniformLocation>,
u_tex: Option<&glow::NativeUniformLocation>,
a_pos: u32,
a_uv: u32,
vbo: glow::NativeBuffer,
ebo: glow::NativeBuffer,
vao: Option<glow::NativeVertexArray>,
texture: Option<&GpuTexture>,
index_count: usize,
width: u32,
height: u32,
mvp: &[f32; 16],
) {
gl.viewport(
0,
0,
width.min(i32::MAX as u32) as i32,
height.min(i32::MAX as u32) as i32,
);
gl.enable(glow::DEPTH_TEST);
gl.clear_color(0.06, 0.08, 0.12, 1.0);
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
gl.use_program(Some(program));
gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp);
let texture_enabled = texture.is_some();
gl.uniform_1_f32(u_use_tex, if texture_enabled { 1.0 } else { 0.0 });
if let Some(tex) = texture {
gl.active_texture(glow::TEXTURE0);
gl.bind_texture(glow::TEXTURE_2D, Some(tex.handle));
gl.uniform_1_i32(u_tex, 0);
} else {
gl.bind_texture(glow::TEXTURE_2D, None);
}
if let Some(vao) = vao {
gl.bind_vertex_array(Some(vao));
gl.draw_elements(
glow::TRIANGLES,
index_count.min(i32::MAX as usize) as i32,
glow::UNSIGNED_SHORT,
0,
);
gl.bind_vertex_array(None);
} else {
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
gl.enable_vertex_attrib_array(a_pos);
gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
gl.enable_vertex_attrib_array(a_uv);
gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
gl.draw_elements(
glow::TRIANGLES,
index_count.min(i32::MAX as usize) as i32,
glow::UNSIGNED_SHORT,
0,
);
gl.disable_vertex_attrib_array(a_uv);
gl.disable_vertex_attrib_array(a_pos);
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
gl.bind_buffer(glow::ARRAY_BUFFER, None);
}
gl.bind_texture(glow::TEXTURE_2D, None);
gl.use_program(None);
}
unsafe fn read_pixels_rgba(gl: &glow::Context, width: u32, height: u32) -> Result<Vec<u8>, String> {
let pixel_count = usize::try_from(width)
.ok()
.and_then(|w| usize::try_from(height).ok().map(|h| w.saturating_mul(h)))
.ok_or_else(|| String::from("frame dimensions are too large"))?;
let mut pixels = vec![0u8; pixel_count.saturating_mul(4)];
gl.read_pixels(
0,
0,
width.min(i32::MAX as u32) as i32,
height.min(i32::MAX as u32) as i32,
glow::RGBA,
glow::UNSIGNED_BYTE,
glow::PixelPackData::Slice(Some(pixels.as_mut_slice())),
);
Ok(pixels)
}
fn flip_image_y_rgba(rgba: &mut [u8], width: usize, height: usize) {
let stride = width.saturating_mul(4);
if stride == 0 {
return;
}
for y in 0..(height / 2) {
let top = y * stride;
let bottom = (height - 1 - y) * stride;
for i in 0..stride {
rgba.swap(top + i, bottom + i);
}
}
}
fn save_png(path: &Path, width: u32, height: u32, rgba: Vec<u8>) -> Result<(), String> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create output directory {}: {err}",
parent.display()
)
})?;
}
}
let image = image::RgbaImage::from_raw(width, height, rgba)
.ok_or_else(|| String::from("failed to build image from framebuffer bytes"))?;
image
.save(path)
.map_err(|err| format!("failed to save PNG {}: {err}", path.display()))
}
unsafe fn create_program(
gl: &glow::Context,
backend: GlBackend,
) -> Result<glow::NativeProgram, String> {
let (vs_src, fs_src) = match backend {
GlBackend::Gles2 => (
r#"
attribute vec3 a_pos;
attribute vec2 a_uv;
uniform mat4 u_mvp;
varying vec2 v_uv;
void main() {
v_uv = a_uv;
gl_Position = u_mvp * vec4(a_pos, 1.0);
}
"#,
r#"
precision mediump float;
uniform sampler2D u_tex;
uniform float u_use_tex;
varying vec2 v_uv;
void main() {
vec4 base = vec4(0.85, 0.90, 1.00, 1.0);
vec4 texColor = texture2D(u_tex, v_uv);
gl_FragColor = mix(base, texColor, u_use_tex);
}
"#,
),
GlBackend::Core33 => (
r#"#version 330 core
in vec3 a_pos;
in vec2 a_uv;
uniform mat4 u_mvp;
out vec2 v_uv;
void main() {
v_uv = a_uv;
gl_Position = u_mvp * vec4(a_pos, 1.0);
}
"#,
r#"#version 330 core
uniform sampler2D u_tex;
uniform float u_use_tex;
in vec2 v_uv;
out vec4 fragColor;
void main() {
vec4 base = vec4(0.85, 0.90, 1.00, 1.0);
vec4 texColor = texture(u_tex, v_uv);
fragColor = mix(base, texColor, u_use_tex);
}
"#,
),
};
let program = gl.create_program().map_err(|e| e.to_string())?;
let vs = gl
.create_shader(glow::VERTEX_SHADER)
.map_err(|e| e.to_string())?;
let fs = gl
.create_shader(glow::FRAGMENT_SHADER)
.map_err(|e| e.to_string())?;
gl.shader_source(vs, vs_src);
gl.compile_shader(vs);
if !gl.get_shader_compile_status(vs) {
let log = gl.get_shader_info_log(vs);
gl.delete_shader(vs);
gl.delete_shader(fs);
gl.delete_program(program);
return Err(format!("vertex shader compile failed: {log}"));
}
gl.shader_source(fs, fs_src);
gl.compile_shader(fs);
if !gl.get_shader_compile_status(fs) {
let log = gl.get_shader_info_log(fs);
gl.delete_shader(vs);
gl.delete_shader(fs);
gl.delete_program(program);
return Err(format!("fragment shader compile failed: {log}"));
}
gl.attach_shader(program, vs);
gl.attach_shader(program, fs);
gl.link_program(program);
gl.detach_shader(program, vs);
gl.detach_shader(program, fs);
gl.delete_shader(vs);
gl.delete_shader(fs);
if !gl.get_program_link_status(program) {
let log = gl.get_program_info_log(program);
gl.delete_program(program);
return Err(format!("program link failed: {log}"));
}
Ok(program)
}
fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<f32>()));
for &value in slice {
out.extend_from_slice(&value.to_ne_bytes());
}
out
}
fn u16_slice_to_ne_bytes(slice: &[u16]) -> Vec<u8> {
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<u16>()));
for &value in slice {
out.extend_from_slice(&value.to_ne_bytes());
}
out
}
fn mat4_identity() -> [f32; 16] {
[
1.0, 0.0, 0.0, 0.0, //
0.0, 1.0, 0.0, 0.0, //
0.0, 0.0, 1.0, 0.0, //
0.0, 0.0, 0.0, 1.0, //
]
}
fn mat4_translation(x: f32, y: f32, z: f32) -> [f32; 16] {
let mut m = mat4_identity();
m[12] = x;
m[13] = y;
m[14] = z;
m
}
fn mat4_rotation_y(rad: f32) -> [f32; 16] {
let c = rad.cos();
let s = rad.sin();
[
c, 0.0, -s, 0.0, //
0.0, 1.0, 0.0, 0.0, //
s, 0.0, c, 0.0, //
0.0, 0.0, 0.0, 1.0, //
]
}
fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] {
let f = 1.0 / (0.5 * fovy).tan();
let nf = 1.0 / (near - far);
[
f / aspect,
0.0,
0.0,
0.0,
0.0,
f,
0.0,
0.0,
0.0,
0.0,
(far + near) * nf,
-1.0,
0.0,
0.0,
(2.0 * far * near) * nf,
0.0,
]
}
fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
let mut out = [0.0f32; 16];
for c in 0..4 {
for r in 0..4 {
let mut acc = 0.0f32;
for k in 0..4 {
acc += a[k * 4 + r] * b[c * 4 + k];
}
out[c * 4 + r] = acc;
}
}
out
}
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "render-mission-demo"
version = "0.1.0"
edition = "2021"
[features]
default = []
demo = ["dep:sdl2", "dep:glow"]
[dependencies]
encoding_rs = "0.8"
glow = { version = "0.16", optional = true }
nres = { path = "../nres" }
render-core = { path = "../render-core" }
render-demo = { path = "../render-demo" }
tma = { path = "../tma" }
terrain-core = { path = "../terrain-core" }
texm = { path = "../texm" }
unitdat = { path = "../unitdat" }
[dev-dependencies]
common = { path = "../common" }
[target.'cfg(target_os = "macos")'.dependencies]
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["use-pkgconfig"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] }
[[bin]]
name = "parkan-render-mission-demo"
path = "src/main.rs"
required-features = ["demo"]
+881
View File
@@ -0,0 +1,881 @@
use encoding_rs::WINDOWS_1251;
use nres::Archive;
use render_core::{build_render_mesh, RenderMesh};
use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture};
use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use terrain_core::TerrainMesh;
use tma::MissionFile;
const MAT0_KIND: u32 = 0x3054_414D;
const MESH_KIND: u32 = 0x4853_454D;
const OBJECT_REF_STRIDE: usize = 64;
const OBJECT_REF_ARCHIVE_BYTES: usize = 32;
pub type Result<T> = core::result::Result<T, Error>;
#[derive(Debug)]
pub enum Error {
Io(std::io::Error),
Mission(tma::Error),
Terrain(terrain_core::Error),
UnitDat(unitdat::Error),
RenderDemo(render_demo::Error),
Nres(nres::error::Error),
Texm(texm::error::Error),
InvalidMapPath(String),
GameRootNotFound(PathBuf),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(err) => write!(f, "{err}"),
Self::Mission(err) => write!(f, "{err}"),
Self::Terrain(err) => write!(f, "{err}"),
Self::UnitDat(err) => write!(f, "{err}"),
Self::RenderDemo(err) => write!(f, "{err}"),
Self::Nres(err) => write!(f, "{err}"),
Self::Texm(err) => write!(f, "{err}"),
Self::InvalidMapPath(path) => write!(f, "invalid mission map path: {path}"),
Self::GameRootNotFound(path) => {
write!(
f,
"failed to detect game root from mission path {}",
path.display()
)
}
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
Self::Mission(err) => Some(err),
Self::Terrain(err) => Some(err),
Self::UnitDat(err) => Some(err),
Self::RenderDemo(err) => Some(err),
Self::Nres(err) => Some(err),
Self::Texm(err) => Some(err),
Self::InvalidMapPath(_) | Self::GameRootNotFound(_) => None,
}
}
}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl From<tma::Error> for Error {
fn from(value: tma::Error) -> Self {
Self::Mission(value)
}
}
impl From<terrain_core::Error> for Error {
fn from(value: terrain_core::Error) -> Self {
Self::Terrain(value)
}
}
impl From<unitdat::Error> for Error {
fn from(value: unitdat::Error) -> Self {
Self::UnitDat(value)
}
}
impl From<render_demo::Error> for Error {
fn from(value: render_demo::Error) -> Self {
Self::RenderDemo(value)
}
}
impl From<nres::error::Error> for Error {
fn from(value: nres::error::Error) -> Self {
Self::Nres(value)
}
}
impl From<texm::error::Error> for Error {
fn from(value: texm::error::Error) -> Self {
Self::Texm(value)
}
}
#[derive(Copy, Clone, Debug)]
pub struct LoadOptions {
pub load_model_textures: bool,
pub load_terrain_texture: bool,
}
impl Default for LoadOptions {
fn default() -> Self {
Self {
load_model_textures: true,
load_terrain_texture: true,
}
}
}
#[derive(Clone, Debug)]
pub struct MissionScene {
pub game_root: PathBuf,
pub mission_path: PathBuf,
pub mission: MissionFile,
pub map_folder_rel: PathBuf,
pub land_msh_path: PathBuf,
pub terrain: TerrainMesh,
pub terrain_texture: Option<LoadedTexture>,
pub models: Vec<SceneModel>,
pub skipped_objects: usize,
}
#[derive(Clone, Debug)]
pub struct SceneModel {
pub archive_path: PathBuf,
pub model_name: String,
pub mesh: RenderMesh,
pub texture: Option<LoadedTexture>,
pub instances: Vec<ModelInstance>,
}
#[derive(Copy, Clone, Debug)]
pub struct ModelInstance {
pub position: [f32; 3],
pub yaw_rad: f32,
pub scale: [f32; 3],
}
#[derive(Clone, Debug)]
struct ObjectPrototype {
archive_path: PathBuf,
model_name: String,
}
#[derive(Clone, Debug)]
struct ObjectRef {
archive_name: String,
resource_name: String,
}
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
struct ModelKey {
archive_path: PathBuf,
model_name: String,
}
pub fn detect_game_root_from_mission_path(mission_path: &Path) -> Option<PathBuf> {
let mut cursor = mission_path.parent();
while let Some(dir) = cursor {
if dir.join("DATA").is_dir() && dir.join("objects.rlb").is_file() {
return Some(dir.to_path_buf());
}
cursor = dir.parent();
}
None
}
pub fn load_scene(
game_root: impl AsRef<Path>,
mission_path: impl AsRef<Path>,
) -> Result<MissionScene> {
load_scene_with_options(game_root, mission_path, LoadOptions::default())
}
pub fn load_scene_with_options(
game_root: impl AsRef<Path>,
mission_path: impl AsRef<Path>,
options: LoadOptions,
) -> Result<MissionScene> {
let game_root = game_root.as_ref().to_path_buf();
let mission_path = mission_path.as_ref().to_path_buf();
let mission = tma::parse_path(&mission_path)?;
let map_folder_rel = map_folder_from_footer(&mission.footer.map_path)?;
let land_msh_path = game_root.join(&map_folder_rel).join("Land.msh");
let terrain = terrain_core::load_land_mesh(&land_msh_path)?;
let terrain_texture = if options.load_terrain_texture {
resolve_terrain_texture(&game_root, &map_folder_rel)?
} else {
None
};
let mut grouped_instances: HashMap<ModelKey, Vec<ModelInstance>> = HashMap::new();
let mut prototype_cache: HashMap<String, Option<ObjectPrototype>> = HashMap::new();
let mut skipped = 0usize;
for object in &mission.objects {
let cache_key = object.resource_name.to_ascii_lowercase();
let proto = if let Some(cached) = prototype_cache.get(&cache_key) {
cached.clone()
} else {
let resolved = resolve_object_prototype(&game_root, object)?;
prototype_cache.insert(cache_key, resolved.clone());
resolved
};
let Some(proto) = proto else {
skipped += 1;
continue;
};
let instance = ModelInstance {
position: object.position,
yaw_rad: object.orientation[2],
scale: normalize_scale(object.scale),
};
grouped_instances
.entry(ModelKey {
archive_path: proto.archive_path,
model_name: proto.model_name,
})
.or_default()
.push(instance);
}
let mut models = Vec::new();
for (key, instances) in grouped_instances {
let loaded =
match load_model_with_name_from_archive(&key.archive_path, Some(&key.model_name)) {
Ok(v) => v,
Err(_) => {
skipped += instances.len();
continue;
}
};
let mesh = build_render_mesh(&loaded.model, 0, 0);
if mesh.indices.is_empty() {
skipped += instances.len();
continue;
}
let texture = if options.load_model_textures {
resolve_texture_for_model(&key.archive_path, &loaded.name, None, None, None, None)
.ok()
.flatten()
} else {
None
};
models.push(SceneModel {
archive_path: key.archive_path,
model_name: loaded.name,
mesh,
texture,
instances,
});
}
models.sort_by(|a, b| a.model_name.cmp(&b.model_name));
Ok(MissionScene {
game_root,
mission_path,
mission,
map_folder_rel,
land_msh_path,
terrain,
terrain_texture,
models,
skipped_objects: skipped,
})
}
pub fn compute_scene_bounds(scene: &MissionScene) -> Option<([f32; 3], [f32; 3])> {
let mut min_v = [f32::INFINITY; 3];
let mut max_v = [f32::NEG_INFINITY; 3];
let mut any = false;
for pos in &scene.terrain.positions {
merge_bounds(&mut min_v, &mut max_v, *pos);
any = true;
}
for model in &scene.models {
for instance in &model.instances {
merge_bounds(&mut min_v, &mut max_v, instance.position);
any = true;
}
}
any.then_some((min_v, max_v))
}
fn merge_bounds(min_v: &mut [f32; 3], max_v: &mut [f32; 3], p: [f32; 3]) {
for i in 0..3 {
if p[i] < min_v[i] {
min_v[i] = p[i];
}
if p[i] > max_v[i] {
max_v[i] = p[i];
}
}
}
fn normalize_scale(scale: [f32; 3]) -> [f32; 3] {
let mut out = scale;
for item in &mut out {
if !item.is_finite() || item.abs() < 0.000_1 {
*item = 1.0;
}
}
out
}
fn map_folder_from_footer(map_path: &str) -> Result<PathBuf> {
let mut parts = split_relative_path(map_path);
if parts.len() < 2 {
return Err(Error::InvalidMapPath(map_path.to_string()));
}
parts.pop(); // remove 'land'
let mut out = PathBuf::new();
for part in parts {
out.push(part);
}
Ok(out)
}
fn resolve_object_prototype(
game_root: &Path,
object: &tma::MissionObject,
) -> Result<Option<ObjectPrototype>> {
if object.resource_name.to_ascii_lowercase().ends_with(".dat") {
let dat_path = game_root.join(pathbuf_from_rel(&object.resource_name));
if !dat_path.is_file() {
return Ok(None);
}
let parsed = unitdat::parse_path(&dat_path)?;
let archive_path = game_root.join(pathbuf_from_rel(&parsed.archive_name));
if !archive_path.is_file() {
return Ok(None);
}
return resolve_archive_model(game_root, &archive_path, &parsed.model_key);
}
let archive_path = game_root.join("objects.rlb");
if !archive_path.is_file() {
return Ok(None);
}
resolve_archive_model(game_root, &archive_path, &object.resource_name)
}
fn resolve_archive_model(
game_root: &Path,
archive_path: &Path,
model_key: &str,
) -> Result<Option<ObjectPrototype>> {
if !archive_path.is_file() {
return Ok(None);
}
if is_objects_registry_archive(archive_path) {
if let Some(proto) = resolve_objects_registry_model(game_root, archive_path, model_key)? {
return Ok(Some(proto));
}
}
let model_name = ensure_msh_suffix(model_key);
if !archive_has_mesh_entry(archive_path, &model_name)? {
return Ok(None);
}
Ok(Some(ObjectPrototype {
archive_path: archive_path.to_path_buf(),
model_name: model_name.to_ascii_lowercase(),
}))
}
fn is_objects_registry_archive(archive_path: &Path) -> bool {
archive_path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("objects.rlb"))
}
fn resolve_objects_registry_model(
game_root: &Path,
registry_archive_path: &Path,
object_key: &str,
) -> Result<Option<ObjectPrototype>> {
let archive = Archive::open_path(registry_archive_path)?;
let Some(entry_id) = find_registry_entry_id(&archive, object_key) else {
return Ok(None);
};
let payload = archive.read(entry_id)?.into_owned();
let refs = parse_object_refs(&payload);
if refs.is_empty() {
return Ok(None);
}
for item in refs
.iter()
.filter(|item| has_extension(&item.resource_name, "msh"))
{
if let Some(proto) = resolve_object_ref_model(game_root, item, &item.resource_name)? {
return Ok(Some(proto));
}
}
for item in refs
.iter()
.filter(|item| has_extension(&item.resource_name, "bas"))
{
let Some(stem) = Path::new(&item.resource_name)
.file_stem()
.and_then(|stem| stem.to_str())
else {
continue;
};
if stem.is_empty() {
continue;
}
let candidate = format!("{stem}.msh");
if let Some(proto) = resolve_object_ref_model(game_root, item, &candidate)? {
return Ok(Some(proto));
}
}
Ok(None)
}
fn find_registry_entry_id(archive: &Archive, object_key: &str) -> Option<nres::EntryId> {
mesh_name_candidates(object_key)
.into_iter()
.find_map(|candidate| archive.find(&candidate))
}
fn resolve_object_ref_model(
game_root: &Path,
item: &ObjectRef,
model_name: &str,
) -> Result<Option<ObjectPrototype>> {
let archive_path = game_root.join(pathbuf_from_rel(&item.archive_name));
if !archive_path.is_file() {
return Ok(None);
}
if !archive_has_mesh_entry(&archive_path, model_name)? {
return Ok(None);
}
Ok(Some(ObjectPrototype {
archive_path,
model_name: model_name.to_ascii_lowercase(),
}))
}
fn parse_object_refs(payload: &[u8]) -> Vec<ObjectRef> {
if !payload.len().is_multiple_of(OBJECT_REF_STRIDE) {
return Vec::new();
}
let mut refs = Vec::with_capacity(payload.len() / OBJECT_REF_STRIDE);
for chunk in payload.chunks_exact(OBJECT_REF_STRIDE) {
let archive_name = decode_cp1251_cstr(&chunk[..OBJECT_REF_ARCHIVE_BYTES]);
let resource_name = decode_cp1251_cstr(&chunk[OBJECT_REF_ARCHIVE_BYTES..]);
if archive_name.is_empty() || resource_name.is_empty() {
continue;
}
refs.push(ObjectRef {
archive_name,
resource_name,
});
}
refs
}
fn archive_has_mesh_entry(archive_path: &Path, requested_name: &str) -> Result<bool> {
let archive = Archive::open_path(archive_path)?;
Ok(find_mesh_entry_id(&archive, requested_name).is_some())
}
fn find_mesh_entry_id(archive: &Archive, requested_name: &str) -> Option<nres::EntryId> {
for candidate in mesh_name_candidates(requested_name) {
let Some(id) = archive.find(&candidate) else {
continue;
};
let Some(entry) = archive.get(id) else {
continue;
};
if entry.meta.kind == MESH_KIND || has_extension(&entry.meta.name, "msh") {
return Some(id);
}
}
None
}
fn mesh_name_candidates(name: &str) -> Vec<String> {
let mut out = Vec::new();
let trimmed = name.trim();
if trimmed.is_empty() {
return out;
}
push_unique_string(&mut out, trimmed.to_string());
if let Some(stem) = trimmed
.strip_suffix(".msh")
.or_else(|| trimmed.strip_suffix(".MSH"))
{
if !stem.is_empty() {
push_unique_string(&mut out, stem.to_string());
}
} else {
push_unique_string(&mut out, format!("{trimmed}.msh"));
}
out
}
fn push_unique_string(items: &mut Vec<String>, value: String) {
if !items.iter().any(|item| item.eq_ignore_ascii_case(&value)) {
items.push(value);
}
}
fn ensure_msh_suffix(name: &str) -> String {
let trimmed = name.trim();
if trimmed.to_ascii_lowercase().ends_with(".msh") {
trimmed.to_string()
} else {
format!("{trimmed}.msh")
}
}
fn has_extension(name: &str, ext: &str) -> bool {
Path::new(name)
.extension()
.and_then(|value| value.to_str())
.is_some_and(|value| value.eq_ignore_ascii_case(ext))
}
fn resolve_terrain_texture(
game_root: &Path,
map_folder_rel: &Path,
) -> Result<Option<LoadedTexture>> {
let material_archive_path = game_root.join("material.lib");
let texture_archive_path = game_root.join("textures.lib");
if !material_archive_path.is_file() || !texture_archive_path.is_file() {
return Ok(None);
}
for wear_name in ["Land1.wea", "Land2.wea"] {
let wear_path = game_root.join(map_folder_rel).join(wear_name);
if !wear_path.is_file() {
continue;
}
let wear_payload = fs::read(&wear_path)?;
let Some(material_name) = parse_primary_material_from_wear(&wear_payload) else {
continue;
};
let Some(texture_name) =
resolve_texture_name_from_material_archive(&material_archive_path, &material_name)?
else {
continue;
};
if let Some(texture) = load_texm_by_name(&texture_archive_path, &texture_name)? {
return Ok(Some(texture));
}
}
Ok(None)
}
fn parse_primary_material_from_wear(bytes: &[u8]) -> Option<String> {
let text = decode_cp1251(bytes).replace('\r', "");
let mut lines = text.lines();
let count = lines.next()?.trim().parse::<usize>().ok()?;
if count == 0 {
return None;
}
for line in lines.take(count) {
let mut parts = line.split_whitespace();
let _legacy = parts.next()?;
let name = parts.next()?;
if !name.is_empty() {
return Some(name.to_string());
}
}
None
}
fn resolve_texture_name_from_material_archive(
archive_path: &Path,
material_name: &str,
) -> Result<Option<String>> {
let archive = Archive::open_path(archive_path)?;
let entry = if let Some(id) = archive.find(material_name) {
archive
.get(id)
.filter(|entry| entry.meta.kind == MAT0_KIND)
.or_else(|| {
archive
.find("DEFAULT")
.and_then(|id| archive.get(id))
.filter(|entry| entry.meta.kind == MAT0_KIND)
})
} else {
archive
.find("DEFAULT")
.and_then(|id| archive.get(id))
.filter(|entry| entry.meta.kind == MAT0_KIND)
}
.or_else(|| archive.entries().find(|entry| entry.meta.kind == MAT0_KIND));
let Some(entry) = entry else {
return Ok(None);
};
let payload = archive.read(entry.id)?.into_owned();
parse_primary_texture_name_from_mat0(&payload, entry.meta.attr2)
}
fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Option<String>> {
if payload.len() < 4 {
return Ok(None);
}
let phase_count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
if phase_count == 0 {
return Ok(None);
}
let mut offset = 4usize;
if attr2 >= 2 {
offset = offset.saturating_add(2);
}
if attr2 >= 3 {
offset = offset.saturating_add(4);
}
if attr2 >= 4 {
offset = offset.saturating_add(4);
}
for phase in 0..phase_count {
let phase_off = offset.saturating_add(phase.saturating_mul(34));
let Some(rec) = payload.get(phase_off..phase_off + 34) else {
break;
};
let name_raw = &rec[18..34];
let end = name_raw
.iter()
.position(|&b| b == 0)
.unwrap_or(name_raw.len());
let name = decode_cp1251(&name_raw[..end]).trim().to_string();
if !name.is_empty() {
return Ok(Some(name));
}
}
Ok(None)
}
fn load_texm_by_name(archive_path: &Path, texture_name: &str) -> Result<Option<LoadedTexture>> {
let archive = Archive::open_path(archive_path)?;
let Some(id) = archive.find(texture_name) else {
return Ok(None);
};
let Some(entry) = archive.get(id) else {
return Ok(None);
};
if entry.meta.kind != texm::TEXM_MAGIC {
return Ok(None);
}
let payload = archive.read(id)?.into_owned();
let parsed = texm::parse_texm(&payload)?;
let decoded = texm::decode_mip_rgba8(&parsed, &payload, 0)?;
Ok(Some(LoadedTexture {
name: entry.meta.name.clone(),
width: decoded.width,
height: decoded.height,
rgba8: decoded.rgba8,
}))
}
fn split_relative_path(path: &str) -> Vec<&str> {
path.split(['\\', '/'])
.filter(|part| !part.is_empty())
.collect()
}
fn pathbuf_from_rel(path: &str) -> PathBuf {
let mut out = PathBuf::new();
for part in split_relative_path(path) {
out.push(part);
}
out
}
fn decode_cp1251_cstr(bytes: &[u8]) -> String {
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
let (decoded, _, _) = WINDOWS_1251.decode(&bytes[..end]);
decoded.trim().to_string()
}
fn decode_cp1251(bytes: &[u8]) -> String {
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
decoded.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn game_root() -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata")
.join("Parkan - Iron Strategy");
root.is_dir().then_some(root)
}
#[test]
fn detects_game_root_from_mission_path() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let mission = root
.join("MISSIONS")
.join("CAMPAIGN")
.join("CAMPAIGN.00")
.join("Mission.01")
.join("data.tma");
if !mission.is_file() {
eprintln!("skipping missing mission sample");
return;
}
let detected = detect_game_root_from_mission_path(&mission)
.expect("failed to detect game root from mission path");
assert_eq!(detected, root);
}
#[test]
fn loads_scene_cpu_without_textures() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let mission = root
.join("MISSIONS")
.join("CAMPAIGN")
.join("CAMPAIGN.00")
.join("Mission.01")
.join("data.tma");
if !mission.is_file() {
eprintln!("skipping missing mission sample");
return;
}
let scene = load_scene_with_options(
&root,
&mission,
LoadOptions {
load_model_textures: false,
load_terrain_texture: false,
},
)
.unwrap_or_else(|err| panic!("failed to load scene {}: {err}", mission.display()));
assert!(!scene.terrain.positions.is_empty());
assert!(!scene.terrain.faces.is_empty());
assert!(!scene.models.is_empty());
let instance_count = scene
.models
.iter()
.map(|model| model.instances.len())
.sum::<usize>();
assert!(instance_count >= 10);
let bounds = compute_scene_bounds(&scene).expect("scene bounds should exist");
assert!(bounds.0[0] <= bounds.1[0]);
assert!(bounds.0[1] <= bounds.1[1]);
assert!(bounds.0[2] <= bounds.1[2]);
}
#[test]
fn loads_scene_with_textures() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let mission = root
.join("MISSIONS")
.join("CAMPAIGN")
.join("CAMPAIGN.00")
.join("Mission.01")
.join("data.tma");
if !mission.is_file() {
eprintln!("skipping missing mission sample");
return;
}
let scene = load_scene_with_options(&root, &mission, LoadOptions::default())
.unwrap_or_else(|err| panic!("failed to load textured scene {}: {err}", mission.display()));
assert!(!scene.models.is_empty());
let textured_models = scene.models.iter().filter(|model| model.texture.is_some()).count();
assert!(textured_models > 0, "no model textures resolved");
assert!(scene.terrain_texture.is_some(), "terrain texture was not resolved");
}
#[test]
fn resolves_objects_registry_models() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let registry = root.join("objects.rlb");
if !registry.is_file() {
eprintln!("skipping missing objects.rlb");
return;
}
let cases = [
("r_h_01", "bases.rlb", "r_h_01.msh"),
("s_tree_04", "static.rlb", "s_tree_0_04.msh"),
("fr_m_brige", "fortif.rlb", "fr_m_brige.msh"),
];
for (key, archive_name, model_name) in cases {
let proto = resolve_objects_registry_model(&root, &registry, key)
.unwrap_or_else(|err| panic!("failed to resolve '{key}' from objects.rlb: {err}"))
.unwrap_or_else(|| panic!("missing model resolution for '{key}'"));
let got_archive = proto
.archive_path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.to_ascii_lowercase())
.unwrap_or_default();
assert_eq!(got_archive, archive_name.to_ascii_lowercase());
assert!(
proto.model_name.eq_ignore_ascii_case(model_name),
"unexpected model for key '{key}': got '{}', expected '{}'",
proto.model_name,
model_name
);
}
}
}
+924
View File
@@ -0,0 +1,924 @@
use glow::HasContext as _;
use render_mission_demo::{
compute_scene_bounds, detect_game_root_from_mission_path, load_scene_with_options, LoadOptions,
MissionScene, ModelInstance,
};
use std::io::Write as _;
use std::path::PathBuf;
use std::time::{Duration, Instant};
struct Args {
mission: PathBuf,
game_root: Option<PathBuf>,
width: u32,
height: u32,
fov_deg: f32,
no_model_texture: bool,
no_terrain_texture: bool,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum GlBackend {
Gles2,
Core33,
}
struct GpuTexture {
handle: glow::NativeTexture,
}
struct GpuRenderable {
vbo: glow::NativeBuffer,
ebo: glow::NativeBuffer,
index_count: usize,
texture: Option<GpuTexture>,
}
struct ModelRenderable {
gpu: GpuRenderable,
instances: Vec<ModelInstance>,
}
#[derive(Copy, Clone, Debug)]
struct Camera {
position: [f32; 3],
yaw: f32,
pitch: f32,
move_speed: f32,
mouse_sensitivity: f32,
}
fn parse_args() -> Result<Args, String> {
let mut mission = None;
let mut game_root = None;
let mut width = 1600u32;
let mut height = 900u32;
let mut fov_deg = 60.0f32;
let mut no_model_texture = false;
let mut no_terrain_texture = false;
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
match arg.as_str() {
"--mission" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --mission"))?;
mission = Some(PathBuf::from(value));
}
"--game-root" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --game-root"))?;
game_root = Some(PathBuf::from(value));
}
"--width" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --width"))?;
width = value
.parse::<u32>()
.map_err(|_| String::from("invalid --width value"))?;
if width == 0 {
return Err(String::from("--width must be > 0"));
}
}
"--height" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --height"))?;
height = value
.parse::<u32>()
.map_err(|_| String::from("invalid --height value"))?;
if height == 0 {
return Err(String::from("--height must be > 0"));
}
}
"--fov" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --fov"))?;
fov_deg = value
.parse::<f32>()
.map_err(|_| String::from("invalid --fov value"))?;
if !(1.0..=179.0).contains(&fov_deg) {
return Err(String::from("--fov must be in range [1, 179]"));
}
}
"--no-model-texture" => {
no_model_texture = true;
}
"--no-terrain-texture" => {
no_terrain_texture = true;
}
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
other => {
return Err(format!("unknown argument: {other}"));
}
}
}
let mission = mission.ok_or_else(|| String::from("missing required --mission"))?;
Ok(Args {
mission,
game_root,
width,
height,
fov_deg,
no_model_texture,
no_terrain_texture,
})
}
fn print_help() {
eprintln!("parkan-render-mission-demo --mission <path/to/data.tma> [--game-root <path>] [--width W] [--height H] [--fov DEG]");
eprintln!(" [--no-model-texture] [--no-terrain-texture]");
eprintln!("controls: arrows/WASD move, PageUp/PageDown vertical move, Right Mouse drag look, Shift speed-up, Esc exit");
}
fn main() {
let args = match parse_args() {
Ok(v) => v,
Err(err) => {
eprintln!("{err}");
print_help();
std::process::exit(2);
}
};
if let Err(err) = run(args) {
eprintln!("{err}");
std::process::exit(1);
}
}
fn run(args: Args) -> Result<(), String> {
let game_root = if let Some(path) = args.game_root.clone() {
path
} else {
detect_game_root_from_mission_path(&args.mission).ok_or_else(|| {
format!(
"failed to detect game root from mission path {} (use --game-root)",
args.mission.display()
)
})?
};
let scene = load_scene_with_options(
&game_root,
&args.mission,
LoadOptions {
load_model_textures: !args.no_model_texture,
load_terrain_texture: !args.no_terrain_texture,
},
)
.map_err(|err| format!("failed to load mission scene: {err}"))?;
let terrain_mesh = terrain_core::build_render_mesh(&scene.terrain)
.map_err(|err| format!("failed to build terrain render mesh: {err}"))?;
let instance_count = scene
.models
.iter()
.map(|model| model.instances.len())
.sum::<usize>();
println!(
"mission loaded: map='{}', terrain_vertices={}, terrain_faces={}, models={}, instances={}, skipped={}",
scene.mission.footer.map_path,
scene.terrain.positions.len(),
scene.terrain.faces.len(),
scene.models.len(),
instance_count,
scene.skipped_objects
);
let sdl = sdl2::init().map_err(|err| format!("failed to init SDL2: {err}"))?;
let video = sdl
.video()
.map_err(|err| format!("failed to init SDL2 video: {err}"))?;
let (mut window, _gl_ctx, gl_backend) =
create_window_and_context(&video, args.width, args.height)?;
let _ = video.gl_set_swap_interval(1);
let gl = unsafe {
glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _)
};
let program = unsafe { create_program(&gl, gl_backend)? };
let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") };
let u_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") };
let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") };
let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }
.ok_or_else(|| String::from("shader attribute a_pos is missing"))?;
let a_uv = unsafe { gl.get_attrib_location(program, "a_uv") }
.ok_or_else(|| String::from("shader attribute a_uv is missing"))?;
let terrain_gpu =
unsafe { upload_terrain_renderable(&gl, &terrain_mesh, scene.terrain_texture.as_ref())? };
let mut model_gpus = Vec::new();
for model in &scene.models {
let renderable = unsafe { upload_model_renderable(&gl, model)? };
model_gpus.push(renderable);
}
let (scene_center, scene_radius) = initial_scene_sphere(&scene);
let mut camera = Camera {
position: [
scene_center[0],
scene_center[1] + scene_radius * 0.6,
scene_center[2] + scene_radius * 1.4,
],
yaw: std::f32::consts::PI,
pitch: -0.28,
move_speed: (scene_radius * 0.55).max(60.0),
mouse_sensitivity: 0.005,
};
let mut events = sdl
.event_pump()
.map_err(|err| format!("failed to get SDL event pump: {err}"))?;
let mut last = Instant::now();
let mut fps_window_start = Instant::now();
let mut fps_frames = 0u32;
let mut fps_printed = false;
let mut mouse_look = false;
'main_loop: loop {
for event in events.poll_iter() {
match event {
sdl2::event::Event::Quit { .. } => break 'main_loop,
sdl2::event::Event::KeyDown {
keycode: Some(sdl2::keyboard::Keycode::Escape),
..
} => break 'main_loop,
sdl2::event::Event::MouseButtonDown {
mouse_btn: sdl2::mouse::MouseButton::Right,
..
} => {
mouse_look = true;
sdl.mouse().set_relative_mouse_mode(true);
}
sdl2::event::Event::MouseButtonUp {
mouse_btn: sdl2::mouse::MouseButton::Right,
..
} => {
mouse_look = false;
sdl.mouse().set_relative_mouse_mode(false);
}
sdl2::event::Event::MouseMotion { xrel, yrel, .. } if mouse_look => {
camera.yaw += xrel as f32 * camera.mouse_sensitivity;
camera.pitch -= yrel as f32 * camera.mouse_sensitivity;
camera.pitch = camera.pitch.clamp(-1.54, 1.54);
}
_ => {}
}
}
let now = Instant::now();
let dt = (now - last).as_secs_f32().clamp(0.0, 0.05);
last = now;
update_camera(&events, &mut camera, dt);
let (w, h) = window.size();
let proj = mat4_perspective(
args.fov_deg.to_radians(),
(w as f32 / h.max(1) as f32).max(0.01),
0.1,
(scene_radius * 25.0).max(5000.0),
);
let forward = camera_forward(camera.yaw, camera.pitch);
let view = mat4_look_at(
camera.position,
[
camera.position[0] + forward[0],
camera.position[1] + forward[1],
camera.position[2] + forward[2],
],
[0.0, 1.0, 0.0],
);
unsafe {
draw_frame_begin(&gl, w, h);
let terrain_mvp = mat4_mul(&proj, &view);
draw_gpu_renderable(
&gl,
program,
u_mvp.as_ref(),
u_use_tex.as_ref(),
u_tex.as_ref(),
a_pos,
a_uv,
&terrain_gpu,
&terrain_mvp,
);
for model in &model_gpus {
for instance in &model.instances {
let model_m = model_matrix(instance.position, instance.yaw_rad, instance.scale);
let view_model = mat4_mul(&view, &model_m);
let mvp = mat4_mul(&proj, &view_model);
draw_gpu_renderable(
&gl,
program,
u_mvp.as_ref(),
u_use_tex.as_ref(),
u_tex.as_ref(),
a_pos,
a_uv,
&model.gpu,
&mvp,
);
}
}
}
window.gl_swap_window();
fps_frames = fps_frames.saturating_add(1);
let elapsed = fps_window_start.elapsed();
if elapsed >= Duration::from_millis(500) {
let fps = fps_frames as f32 / elapsed.as_secs_f32().max(0.000_1);
let frame_time_ms = 1000.0 / fps.max(0.000_1);
let _ = window.set_title(&format!(
"Parkan Mission Demo | FPS: {fps:.1} ({frame_time_ms:.2} ms) | objects: {instance_count}"
));
print!("\rFPS: {fps:.1} ({frame_time_ms:.2} ms)");
let _ = std::io::stdout().flush();
fps_printed = true;
fps_frames = 0;
fps_window_start = Instant::now();
}
}
if fps_printed {
println!();
}
unsafe {
cleanup_renderable(&gl, terrain_gpu);
for model in model_gpus {
cleanup_renderable(&gl, model.gpu);
}
gl.delete_program(program);
}
Ok(())
}
fn initial_scene_sphere(scene: &MissionScene) -> ([f32; 3], f32) {
if let Some((min_v, max_v)) = compute_scene_bounds(scene) {
let center = [
0.5 * (min_v[0] + max_v[0]),
0.5 * (min_v[1] + max_v[1]),
0.5 * (min_v[2] + max_v[2]),
];
let extent = [
max_v[0] - min_v[0],
max_v[1] - min_v[1],
max_v[2] - min_v[2],
];
let radius = ((extent[0] * extent[0]) + (extent[1] * extent[1]) + (extent[2] * extent[2]))
.sqrt()
.max(10.0)
* 0.5;
return (center, radius);
}
([0.0, 0.0, 0.0], 100.0)
}
fn update_camera(events: &sdl2::EventPump, camera: &mut Camera, dt: f32) {
use sdl2::keyboard::Scancode;
let keys = events.keyboard_state();
let mut move_dir = [0.0f32, 0.0f32, 0.0f32];
let forward = camera_forward(camera.yaw, camera.pitch);
let right = normalize3(cross3(forward, [0.0, 1.0, 0.0]));
if keys.is_scancode_pressed(Scancode::Up) || keys.is_scancode_pressed(Scancode::W) {
move_dir[0] += forward[0];
move_dir[1] += forward[1];
move_dir[2] += forward[2];
}
if keys.is_scancode_pressed(Scancode::Down) || keys.is_scancode_pressed(Scancode::S) {
move_dir[0] -= forward[0];
move_dir[1] -= forward[1];
move_dir[2] -= forward[2];
}
if keys.is_scancode_pressed(Scancode::Left) || keys.is_scancode_pressed(Scancode::A) {
move_dir[0] -= right[0];
move_dir[1] -= right[1];
move_dir[2] -= right[2];
}
if keys.is_scancode_pressed(Scancode::Right) || keys.is_scancode_pressed(Scancode::D) {
move_dir[0] += right[0];
move_dir[1] += right[1];
move_dir[2] += right[2];
}
if keys.is_scancode_pressed(Scancode::PageUp) || keys.is_scancode_pressed(Scancode::E) {
move_dir[1] += 1.0;
}
if keys.is_scancode_pressed(Scancode::PageDown) || keys.is_scancode_pressed(Scancode::Q) {
move_dir[1] -= 1.0;
}
let shift =
keys.is_scancode_pressed(Scancode::LShift) || keys.is_scancode_pressed(Scancode::RShift);
let speed_mul = if shift { 3.0 } else { 1.0 };
let norm = normalize3(move_dir);
camera.position[0] += norm[0] * camera.move_speed * speed_mul * dt;
camera.position[1] += norm[1] * camera.move_speed * speed_mul * dt;
camera.position[2] += norm[2] * camera.move_speed * speed_mul * dt;
}
unsafe fn upload_model_renderable(
gl: &glow::Context,
model: &render_mission_demo::SceneModel,
) -> Result<ModelRenderable, String> {
let mut vertex_data = Vec::with_capacity(model.mesh.vertices.len() * 5);
for vertex in &model.mesh.vertices {
vertex_data.push(vertex.position[0]);
vertex_data.push(vertex.position[1]);
vertex_data.push(vertex.position[2]);
vertex_data.push(vertex.uv0[0]);
vertex_data.push(vertex.uv0[1]);
}
let gpu = upload_gpu_renderable(
gl,
&vertex_data,
&model.mesh.indices,
model.texture.as_ref(),
)?;
Ok(ModelRenderable {
gpu,
instances: model.instances.clone(),
})
}
unsafe fn upload_terrain_renderable(
gl: &glow::Context,
mesh: &terrain_core::TerrainRenderMesh,
texture: Option<&render_demo::LoadedTexture>,
) -> Result<GpuRenderable, String> {
let mut vertex_data = Vec::with_capacity(mesh.vertices.len() * 5);
for vertex in &mesh.vertices {
vertex_data.push(vertex.position[0]);
vertex_data.push(vertex.position[1]);
vertex_data.push(vertex.position[2]);
vertex_data.push(vertex.uv0[0]);
vertex_data.push(vertex.uv0[1]);
}
upload_gpu_renderable(gl, &vertex_data, &mesh.indices, texture)
}
unsafe fn upload_gpu_renderable(
gl: &glow::Context,
vertices: &[f32],
indices: &[u16],
texture: Option<&render_demo::LoadedTexture>,
) -> Result<GpuRenderable, String> {
let vbo = gl.create_buffer().map_err(|e| e.to_string())?;
let ebo = gl.create_buffer().map_err(|e| e.to_string())?;
let vertex_bytes = f32_slice_to_ne_bytes(vertices);
let index_bytes = u16_slice_to_ne_bytes(indices);
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW);
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
gl.buffer_data_u8_slice(glow::ELEMENT_ARRAY_BUFFER, &index_bytes, glow::STATIC_DRAW);
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
gl.bind_buffer(glow::ARRAY_BUFFER, None);
let gpu_texture = if let Some(texture) = texture {
Some(create_texture(gl, texture)?)
} else {
None
};
Ok(GpuRenderable {
vbo,
ebo,
index_count: indices.len(),
texture: gpu_texture,
})
}
unsafe fn cleanup_renderable(gl: &glow::Context, renderable: GpuRenderable) {
if let Some(tex) = renderable.texture {
gl.delete_texture(tex.handle);
}
gl.delete_buffer(renderable.ebo);
gl.delete_buffer(renderable.vbo);
}
unsafe fn draw_frame_begin(gl: &glow::Context, width: u32, height: u32) {
gl.viewport(
0,
0,
width.min(i32::MAX as u32) as i32,
height.min(i32::MAX as u32) as i32,
);
gl.enable(glow::DEPTH_TEST);
gl.clear_color(0.06, 0.08, 0.12, 1.0);
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
}
unsafe fn draw_gpu_renderable(
gl: &glow::Context,
program: glow::NativeProgram,
u_mvp: Option<&glow::NativeUniformLocation>,
u_use_tex: Option<&glow::NativeUniformLocation>,
u_tex: Option<&glow::NativeUniformLocation>,
a_pos: u32,
a_uv: u32,
renderable: &GpuRenderable,
mvp: &[f32; 16],
) {
gl.use_program(Some(program));
gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp);
let texture_enabled = renderable.texture.is_some();
gl.uniform_1_f32(u_use_tex, if texture_enabled { 1.0 } else { 0.0 });
if let Some(tex) = &renderable.texture {
gl.active_texture(glow::TEXTURE0);
gl.bind_texture(glow::TEXTURE_2D, Some(tex.handle));
gl.uniform_1_i32(u_tex, 0);
} else {
gl.bind_texture(glow::TEXTURE_2D, None);
}
gl.bind_buffer(glow::ARRAY_BUFFER, Some(renderable.vbo));
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(renderable.ebo));
gl.enable_vertex_attrib_array(a_pos);
gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
gl.enable_vertex_attrib_array(a_uv);
gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
gl.draw_elements(
glow::TRIANGLES,
renderable.index_count.min(i32::MAX as usize) as i32,
glow::UNSIGNED_SHORT,
0,
);
gl.disable_vertex_attrib_array(a_uv);
gl.disable_vertex_attrib_array(a_pos);
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
gl.bind_buffer(glow::ARRAY_BUFFER, None);
gl.bind_texture(glow::TEXTURE_2D, None);
gl.use_program(None);
}
fn create_window_and_context(
video: &sdl2::VideoSubsystem,
width: u32,
height: u32,
) -> Result<(sdl2::video::Window, sdl2::video::GLContext, GlBackend), String> {
let candidates = [
(GlBackend::Gles2, sdl2::video::GLProfile::GLES, 2, 0),
(GlBackend::Core33, sdl2::video::GLProfile::Core, 3, 3),
];
let mut errors = Vec::new();
for (backend, profile, major, minor) in candidates {
{
let gl_attr = video.gl_attr();
gl_attr.set_context_profile(profile);
gl_attr.set_context_version(major, minor);
gl_attr.set_depth_size(24);
gl_attr.set_double_buffer(true);
}
let mut window_builder = video.window("Parkan Mission Demo", width, height);
window_builder.opengl().resizable();
let window = match window_builder.build() {
Ok(window) => window,
Err(err) => {
errors.push(format!(
"{profile:?} {major}.{minor}: window build failed ({err})"
));
continue;
}
};
let gl_ctx = match window.gl_create_context() {
Ok(ctx) => ctx,
Err(err) => {
errors.push(format!(
"{profile:?} {major}.{minor}: context create failed ({err})"
));
continue;
}
};
if let Err(err) = window.gl_make_current(&gl_ctx) {
errors.push(format!(
"{profile:?} {major}.{minor}: make current failed ({err})"
));
continue;
}
return Ok((window, gl_ctx, backend));
}
Err(format!(
"failed to create OpenGL context. Attempts: {}",
errors.join(" | ")
))
}
unsafe fn create_texture(
gl: &glow::Context,
texture: &render_demo::LoadedTexture,
) -> Result<GpuTexture, String> {
let handle = gl.create_texture().map_err(|e| e.to_string())?;
gl.bind_texture(glow::TEXTURE_2D, Some(handle));
gl.tex_parameter_i32(
glow::TEXTURE_2D,
glow::TEXTURE_MIN_FILTER,
glow::LINEAR as i32,
);
gl.tex_parameter_i32(
glow::TEXTURE_2D,
glow::TEXTURE_MAG_FILTER,
glow::LINEAR as i32,
);
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 1);
gl.tex_image_2d(
glow::TEXTURE_2D,
0,
glow::RGBA as i32,
texture.width.min(i32::MAX as u32) as i32,
texture.height.min(i32::MAX as u32) as i32,
0,
glow::RGBA,
glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice(Some(texture.rgba8.as_slice())),
);
gl.bind_texture(glow::TEXTURE_2D, None);
Ok(GpuTexture { handle })
}
unsafe fn create_program(
gl: &glow::Context,
backend: GlBackend,
) -> Result<glow::NativeProgram, String> {
let (vs_src, fs_src) = match backend {
GlBackend::Gles2 => (
r#"
attribute vec3 a_pos;
attribute vec2 a_uv;
uniform mat4 u_mvp;
varying vec2 v_uv;
void main() {
v_uv = a_uv;
gl_Position = u_mvp * vec4(a_pos, 1.0);
}
"#,
r#"
precision mediump float;
uniform sampler2D u_tex;
uniform float u_use_tex;
varying vec2 v_uv;
void main() {
vec4 base = vec4(0.82, 0.87, 0.95, 1.0);
vec4 texColor = texture2D(u_tex, v_uv);
gl_FragColor = mix(base, texColor, u_use_tex);
}
"#,
),
GlBackend::Core33 => (
r#"#version 330 core
in vec3 a_pos;
in vec2 a_uv;
uniform mat4 u_mvp;
out vec2 v_uv;
void main() {
v_uv = a_uv;
gl_Position = u_mvp * vec4(a_pos, 1.0);
}
"#,
r#"#version 330 core
uniform sampler2D u_tex;
uniform float u_use_tex;
in vec2 v_uv;
out vec4 fragColor;
void main() {
vec4 base = vec4(0.82, 0.87, 0.95, 1.0);
vec4 texColor = texture(u_tex, v_uv);
fragColor = mix(base, texColor, u_use_tex);
}
"#,
),
};
let program = gl.create_program().map_err(|e| e.to_string())?;
let vs = gl
.create_shader(glow::VERTEX_SHADER)
.map_err(|e| e.to_string())?;
let fs = gl
.create_shader(glow::FRAGMENT_SHADER)
.map_err(|e| e.to_string())?;
gl.shader_source(vs, vs_src);
gl.compile_shader(vs);
if !gl.get_shader_compile_status(vs) {
let log = gl.get_shader_info_log(vs);
gl.delete_shader(vs);
gl.delete_shader(fs);
gl.delete_program(program);
return Err(format!("vertex shader compile failed: {log}"));
}
gl.shader_source(fs, fs_src);
gl.compile_shader(fs);
if !gl.get_shader_compile_status(fs) {
let log = gl.get_shader_info_log(fs);
gl.delete_shader(vs);
gl.delete_shader(fs);
gl.delete_program(program);
return Err(format!("fragment shader compile failed: {log}"));
}
gl.attach_shader(program, vs);
gl.attach_shader(program, fs);
gl.link_program(program);
gl.detach_shader(program, vs);
gl.detach_shader(program, fs);
gl.delete_shader(vs);
gl.delete_shader(fs);
if !gl.get_program_link_status(program) {
let log = gl.get_program_info_log(program);
gl.delete_program(program);
return Err(format!("program link failed: {log}"));
}
Ok(program)
}
fn model_matrix(position: [f32; 3], yaw: f32, scale: [f32; 3]) -> [f32; 16] {
let translation = mat4_translation(position[0], position[1], position[2]);
let rotation = mat4_rotation_y(yaw);
let scaling = mat4_scale(scale[0], scale[1], scale[2]);
let tr = mat4_mul(&translation, &rotation);
mat4_mul(&tr, &scaling)
}
fn camera_forward(yaw: f32, pitch: f32) -> [f32; 3] {
let cp = pitch.cos();
normalize3([yaw.sin() * cp, pitch.sin(), yaw.cos() * cp])
}
fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn normalize3(v: [f32; 3]) -> [f32; 3] {
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if len <= 1e-6 {
[0.0, 0.0, 0.0]
} else {
[v[0] / len, v[1] / len, v[2] / len]
}
}
fn mat4_identity() -> [f32; 16] {
[
1.0, 0.0, 0.0, 0.0, //
0.0, 1.0, 0.0, 0.0, //
0.0, 0.0, 1.0, 0.0, //
0.0, 0.0, 0.0, 1.0, //
]
}
fn mat4_translation(x: f32, y: f32, z: f32) -> [f32; 16] {
let mut m = mat4_identity();
m[12] = x;
m[13] = y;
m[14] = z;
m
}
fn mat4_scale(x: f32, y: f32, z: f32) -> [f32; 16] {
[
x, 0.0, 0.0, 0.0, //
0.0, y, 0.0, 0.0, //
0.0, 0.0, z, 0.0, //
0.0, 0.0, 0.0, 1.0, //
]
}
fn mat4_rotation_y(rad: f32) -> [f32; 16] {
let c = rad.cos();
let s = rad.sin();
[
c, 0.0, -s, 0.0, //
0.0, 1.0, 0.0, 0.0, //
s, 0.0, c, 0.0, //
0.0, 0.0, 0.0, 1.0, //
]
}
fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] {
let f = 1.0 / (0.5 * fovy).tan();
let nf = 1.0 / (near - far);
[
f / aspect,
0.0,
0.0,
0.0,
0.0,
f,
0.0,
0.0,
0.0,
0.0,
(far + near) * nf,
-1.0,
0.0,
0.0,
(2.0 * far * near) * nf,
0.0,
]
}
fn mat4_look_at(eye: [f32; 3], target: [f32; 3], up: [f32; 3]) -> [f32; 16] {
let f = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
let s = normalize3(cross3(f, up));
let u = cross3(s, f);
[
s[0],
u[0],
-f[0],
0.0,
s[1],
u[1],
-f[1],
0.0,
s[2],
u[2],
-f[2],
0.0,
-dot3(s, eye),
-dot3(u, eye),
dot3(f, eye),
1.0,
]
}
fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
let mut out = [0.0f32; 16];
for c in 0..4 {
for r in 0..4 {
let mut acc = 0.0f32;
for k in 0..4 {
acc += a[k * 4 + r] * b[c * 4 + k];
}
out[c * 4 + r] = acc;
}
}
out
}
fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<f32>()));
for &value in slice {
out.extend_from_slice(&value.to_ne_bytes());
}
out
}
fn u16_slice_to_ne_bytes(slice: &[u16]) -> Vec<u8> {
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<u16>()));
for &value in slice {
out.extend_from_slice(&value.to_ne_bytes());
}
out
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "render-parity"
version = "0.1.0"
edition = "2021"
[dependencies]
image = { version = "0.25", default-features = false, features = ["png"] }
serde = { version = "1", features = ["derive"] }
toml = "1.0"
+16
View File
@@ -0,0 +1,16 @@
# render-parity
Deterministic frame-diff runner for `parkan-render-demo`.
Usage:
```bash
cargo run -p render-parity -- \
--manifest parity/cases.toml \
--output-dir target/render-parity/current
```
Options:
- `--demo-bin <path>`: use prebuilt `parkan-render-demo` binary instead of `cargo run`.
- `--keep-going`: continue all cases even after failures.
+212
View File
@@ -0,0 +1,212 @@
use image::{ImageBuffer, Rgba, RgbaImage};
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ManifestMeta {
pub width: Option<u32>,
pub height: Option<u32>,
pub lod: Option<usize>,
pub group: Option<usize>,
pub angle: Option<f32>,
pub diff_threshold: Option<u8>,
pub max_mean_abs: Option<f32>,
pub max_changed_ratio: Option<f32>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CaseSpec {
pub id: String,
pub archive: String,
pub model: Option<String>,
pub reference: String,
pub width: Option<u32>,
pub height: Option<u32>,
pub lod: Option<usize>,
pub group: Option<usize>,
pub angle: Option<f32>,
pub diff_threshold: Option<u8>,
pub max_mean_abs: Option<f32>,
pub max_changed_ratio: Option<f32>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ParityManifest {
#[serde(default)]
pub meta: ManifestMeta,
#[serde(rename = "case", default)]
pub cases: Vec<CaseSpec>,
}
#[derive(Debug, Clone)]
pub struct DiffMetrics {
pub width: u32,
pub height: u32,
pub mean_abs: f32,
pub max_abs: u8,
pub changed_pixels: u64,
pub changed_ratio: f32,
}
pub fn compare_images(
reference: &RgbaImage,
actual: &RgbaImage,
diff_threshold: u8,
) -> Result<DiffMetrics, String> {
let (rw, rh) = reference.dimensions();
let (aw, ah) = actual.dimensions();
if rw != aw || rh != ah {
return Err(format!(
"image size mismatch: reference={}x{}, actual={}x{}",
rw, rh, aw, ah
));
}
let mut diff_sum = 0u64;
let mut max_abs = 0u8;
let mut changed_pixels = 0u64;
let pixel_count = u64::from(rw).saturating_mul(u64::from(rh));
for (ref_px, act_px) in reference.pixels().zip(actual.pixels()) {
let mut pixel_changed = false;
for chan in 0..3 {
let a = i16::from(ref_px[chan]);
let b = i16::from(act_px[chan]);
let diff = (a - b).unsigned_abs() as u8;
diff_sum = diff_sum.saturating_add(u64::from(diff));
if diff > max_abs {
max_abs = diff;
}
if diff > diff_threshold {
pixel_changed = true;
}
}
if pixel_changed {
changed_pixels = changed_pixels.saturating_add(1);
}
}
let channels = pixel_count.saturating_mul(3);
let mean_abs = if channels == 0 {
0.0
} else {
diff_sum as f32 / channels as f32
};
let changed_ratio = if pixel_count == 0 {
0.0
} else {
changed_pixels as f32 / pixel_count as f32
};
Ok(DiffMetrics {
width: rw,
height: rh,
mean_abs,
max_abs,
changed_pixels,
changed_ratio,
})
}
pub fn build_diff_image(reference: &RgbaImage, actual: &RgbaImage) -> Result<RgbaImage, String> {
let (rw, rh) = reference.dimensions();
let (aw, ah) = actual.dimensions();
if rw != aw || rh != ah {
return Err(format!(
"image size mismatch: reference={}x{}, actual={}x{}",
rw, rh, aw, ah
));
}
let mut out: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(rw, rh);
for (dst, (ref_px, act_px)) in out
.pixels_mut()
.zip(reference.pixels().zip(actual.pixels()))
{
let dr = (i16::from(ref_px[0]) - i16::from(act_px[0])).unsigned_abs() as u8;
let dg = (i16::from(ref_px[1]) - i16::from(act_px[1])).unsigned_abs() as u8;
let db = (i16::from(ref_px[2]) - i16::from(act_px[2])).unsigned_abs() as u8;
*dst = Rgba([dr, dg, db, 255]);
}
Ok(out)
}
pub fn evaluate_metrics(
metrics: &DiffMetrics,
max_mean_abs: f32,
max_changed_ratio: f32,
) -> Vec<String> {
let mut violations = Vec::new();
if metrics.mean_abs > max_mean_abs {
violations.push(format!(
"mean_abs {:.4} > allowed {:.4}",
metrics.mean_abs, max_mean_abs
));
}
if metrics.changed_ratio > max_changed_ratio {
violations.push(format!(
"changed_ratio {:.4}% > allowed {:.4}%",
metrics.changed_ratio * 100.0,
max_changed_ratio * 100.0
));
}
violations
}
#[cfg(test)]
mod tests {
use super::*;
fn solid(w: u32, h: u32, r: u8, g: u8, b: u8) -> RgbaImage {
let mut img = RgbaImage::new(w, h);
for px in img.pixels_mut() {
*px = Rgba([r, g, b, 255]);
}
img
}
#[test]
fn compare_identical_images() {
let ref_img = solid(4, 3, 10, 20, 30);
let act_img = solid(4, 3, 10, 20, 30);
let metrics = compare_images(&ref_img, &act_img, 2).expect("comparison must succeed");
assert_eq!(metrics.width, 4);
assert_eq!(metrics.height, 3);
assert_eq!(metrics.max_abs, 0);
assert_eq!(metrics.changed_pixels, 0);
assert_eq!(metrics.mean_abs, 0.0);
assert_eq!(metrics.changed_ratio, 0.0);
}
#[test]
fn compare_detects_changes_and_thresholds() {
let mut ref_img = solid(2, 2, 100, 100, 100);
let mut act_img = solid(2, 2, 100, 100, 100);
ref_img.put_pixel(1, 1, Rgba([120, 100, 100, 255]));
act_img.put_pixel(1, 1, Rgba([100, 100, 100, 255]));
let metrics = compare_images(&ref_img, &act_img, 5).expect("comparison must succeed");
assert_eq!(metrics.max_abs, 20);
assert_eq!(metrics.changed_pixels, 1);
assert!((metrics.changed_ratio - 0.25).abs() < 1e-6);
assert!(metrics.mean_abs > 0.0);
let violations = evaluate_metrics(&metrics, 2.0, 0.20);
assert_eq!(violations.len(), 1);
assert!(violations[0].contains("changed_ratio"));
}
#[test]
fn build_diff_image_returns_per_channel_abs_diff() {
let mut ref_img = solid(1, 1, 100, 150, 200);
let mut act_img = solid(1, 1, 90, 180, 170);
ref_img.put_pixel(0, 0, Rgba([100, 150, 200, 255]));
act_img.put_pixel(0, 0, Rgba([90, 180, 170, 255]));
let diff = build_diff_image(&ref_img, &act_img).expect("diff image must build");
let px = diff.get_pixel(0, 0);
assert_eq!(px[0], 10);
assert_eq!(px[1], 30);
assert_eq!(px[2], 30);
assert_eq!(px[3], 255);
}
}
+405
View File
@@ -0,0 +1,405 @@
use image::RgbaImage;
use render_parity::{
build_diff_image, compare_images, evaluate_metrics, CaseSpec, ManifestMeta, ParityManifest,
};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
const DEFAULT_MANIFEST: &str = "parity/cases.toml";
const DEFAULT_OUTPUT_DIR: &str = "target/render-parity/current";
const DEFAULT_WIDTH: u32 = 1280;
const DEFAULT_HEIGHT: u32 = 720;
const DEFAULT_LOD: usize = 0;
const DEFAULT_GROUP: usize = 0;
const DEFAULT_ANGLE: f32 = 0.0;
const DEFAULT_DIFF_THRESHOLD: u8 = 8;
const DEFAULT_MAX_MEAN_ABS: f32 = 2.0;
const DEFAULT_MAX_CHANGED_RATIO: f32 = 0.01;
struct Args {
manifest: PathBuf,
output_dir: PathBuf,
demo_bin: Option<PathBuf>,
keep_going: bool,
}
#[derive(Debug, Clone)]
struct EffectiveCase {
id: String,
archive: PathBuf,
model: Option<String>,
reference: PathBuf,
width: u32,
height: u32,
lod: usize,
group: usize,
angle: f32,
diff_threshold: u8,
max_mean_abs: f32,
max_changed_ratio: f32,
}
fn main() {
let args = match parse_args() {
Ok(v) => v,
Err(err) => {
eprintln!("{err}");
print_help();
std::process::exit(2);
}
};
if let Err(err) = run(args) {
eprintln!("{err}");
std::process::exit(1);
}
}
fn parse_args() -> Result<Args, String> {
let mut manifest = PathBuf::from(DEFAULT_MANIFEST);
let mut output_dir = PathBuf::from(DEFAULT_OUTPUT_DIR);
let mut demo_bin = None;
let mut keep_going = false;
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
match arg.as_str() {
"--manifest" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --manifest"))?;
manifest = PathBuf::from(value);
}
"--output-dir" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --output-dir"))?;
output_dir = PathBuf::from(value);
}
"--demo-bin" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --demo-bin"))?;
demo_bin = Some(PathBuf::from(value));
}
"--keep-going" => {
keep_going = true;
}
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
other => {
return Err(format!("unknown argument: {other}"));
}
}
}
Ok(Args {
manifest,
output_dir,
demo_bin,
keep_going,
})
}
fn print_help() {
eprintln!(
"render-parity [--manifest <cases.toml>] [--output-dir <dir>] [--demo-bin <path>] [--keep-going]"
);
eprintln!(" --manifest path to parity manifest (default: {DEFAULT_MANIFEST})");
eprintln!(" --output-dir where current renders and diff images are written");
eprintln!(" --demo-bin prebuilt parkan-render-demo binary path");
eprintln!(" --keep-going continue all cases even after failures");
}
fn run(args: Args) -> Result<(), String> {
let workspace = workspace_root()?;
let manifest_path = resolve_path(&workspace, &args.manifest);
let output_dir = resolve_path(&workspace, &args.output_dir);
let demo_bin = args
.demo_bin
.as_ref()
.map(|path| resolve_path(&workspace, path));
let manifest_raw = fs::read_to_string(&manifest_path)
.map_err(|err| format!("failed to read manifest {}: {err}", manifest_path.display()))?;
let manifest: ParityManifest = toml::from_str(&manifest_raw).map_err(|err| {
format!(
"failed to parse manifest {}: {err}",
manifest_path.display()
)
})?;
if manifest.cases.is_empty() {
println!(
"render-parity: no cases in {} (nothing to validate)",
manifest_path.display()
);
return Ok(());
}
fs::create_dir_all(&output_dir).map_err(|err| {
format!(
"failed to create output directory {}: {err}",
output_dir.display()
)
})?;
let manifest_dir = manifest_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| workspace.clone());
let mut failed_cases = 0usize;
for case in &manifest.cases {
let effective = make_effective_case(&manifest.meta, case, &manifest_dir)?;
let case_file = output_dir.join(format!("{}.png", sanitize_case_id(&effective.id)));
let diff_file = output_dir
.join("diff")
.join(format!("{}.png", sanitize_case_id(&effective.id)));
let run_res = run_single_case(
&workspace, // ensure `cargo run` executes from workspace root
demo_bin.as_deref(),
&effective,
&case_file,
&diff_file,
);
match run_res {
Ok(()) => {}
Err(err) => {
failed_cases = failed_cases.saturating_add(1);
eprintln!("[FAIL] {}: {}", effective.id, err);
if !args.keep_going {
break;
}
}
}
}
if failed_cases > 0 {
return Err(format!(
"render-parity failed: {} case(s) did not match reference frames",
failed_cases
));
}
println!("render-parity: all cases passed");
Ok(())
}
fn run_single_case(
workspace: &Path,
demo_bin: Option<&Path>,
case: &EffectiveCase,
case_file: &Path,
diff_file: &Path,
) -> Result<(), String> {
run_render_capture(workspace, demo_bin, case, case_file)?;
let reference = load_rgba(&case.reference)?;
let actual = load_rgba(case_file)?;
let metrics = compare_images(&reference, &actual, case.diff_threshold)?;
let violations = evaluate_metrics(&metrics, case.max_mean_abs, case.max_changed_ratio);
if violations.is_empty() {
println!(
"[OK] {} mean_abs={:.4} changed={:.4}% max_abs={} ({}x{})",
case.id,
metrics.mean_abs,
metrics.changed_ratio * 100.0,
metrics.max_abs,
metrics.width,
metrics.height
);
return Ok(());
}
if let Some(parent) = diff_file.parent() {
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create diff output directory {}: {err}",
parent.display()
)
})?;
}
let diff = build_diff_image(&reference, &actual)?;
diff.save(diff_file)
.map_err(|err| format!("failed to save diff image {}: {err}", diff_file.display()))?;
let mut details = String::new();
for item in violations {
if !details.is_empty() {
details.push_str("; ");
}
details.push_str(&item);
}
Err(format!(
"{} | diff={} | mean_abs={:.4}, changed={:.4}% ({} px), max_abs={}",
details,
diff_file.display(),
metrics.mean_abs,
metrics.changed_ratio * 100.0,
metrics.changed_pixels,
metrics.max_abs
))
}
fn run_render_capture(
workspace: &Path,
demo_bin: Option<&Path>,
case: &EffectiveCase,
out_path: &Path,
) -> Result<(), String> {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create capture directory {}: {err}",
parent.display()
)
})?;
}
let mut cmd = if let Some(bin) = demo_bin {
Command::new(bin)
} else {
let mut command = Command::new("cargo");
command.args(["run", "-p", "render-demo", "--features", "demo", "--"]);
command
};
cmd.current_dir(workspace)
.arg("--archive")
.arg(&case.archive)
.arg("--lod")
.arg(case.lod.to_string())
.arg("--group")
.arg(case.group.to_string())
.arg("--width")
.arg(case.width.to_string())
.arg("--height")
.arg(case.height.to_string())
.arg("--angle")
.arg(case.angle.to_string())
.arg("--capture")
.arg(out_path);
if let Some(model) = case.model.as_deref() {
cmd.arg("--model").arg(model);
}
let output = cmd.output().map_err(|err| {
let mode = if demo_bin.is_some() {
"parkan-render-demo"
} else {
"cargo run -p render-demo"
};
format!("failed to execute {} for case {}: {err}", mode, case.id)
})?;
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"render command exited with status {:?}\nstdout:\n{}\nstderr:\n{}",
output.status.code(),
stdout,
stderr
));
}
Ok(())
}
fn load_rgba(path: &Path) -> Result<RgbaImage, String> {
image::open(path)
.map_err(|err| format!("failed to load image {}: {err}", path.display()))
.map(|img| img.to_rgba8())
}
fn make_effective_case(
meta: &ManifestMeta,
case: &CaseSpec,
manifest_dir: &Path,
) -> Result<EffectiveCase, String> {
let width = case.width.or(meta.width).unwrap_or(DEFAULT_WIDTH);
let height = case.height.or(meta.height).unwrap_or(DEFAULT_HEIGHT);
if width == 0 || height == 0 {
return Err(format!(
"case '{}' has invalid dimensions {}x{}",
case.id, width, height
));
}
let archive = resolve_path(manifest_dir, Path::new(&case.archive));
let reference = resolve_path(manifest_dir, Path::new(&case.reference));
if !archive.is_file() {
return Err(format!(
"case '{}' archive not found: {}",
case.id,
archive.display()
));
}
if !reference.is_file() {
return Err(format!(
"case '{}' reference frame not found: {}",
case.id,
reference.display()
));
}
Ok(EffectiveCase {
id: case.id.clone(),
archive,
model: case.model.clone(),
reference,
width,
height,
lod: case.lod.or(meta.lod).unwrap_or(DEFAULT_LOD),
group: case.group.or(meta.group).unwrap_or(DEFAULT_GROUP),
angle: case.angle.or(meta.angle).unwrap_or(DEFAULT_ANGLE),
diff_threshold: case
.diff_threshold
.or(meta.diff_threshold)
.unwrap_or(DEFAULT_DIFF_THRESHOLD),
max_mean_abs: case
.max_mean_abs
.or(meta.max_mean_abs)
.unwrap_or(DEFAULT_MAX_MEAN_ABS),
max_changed_ratio: case
.max_changed_ratio
.or(meta.max_changed_ratio)
.unwrap_or(DEFAULT_MAX_CHANGED_RATIO),
})
}
fn sanitize_case_id(id: &str) -> String {
id.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
fn workspace_root() -> Result<PathBuf, String> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.map_err(|err| format!("failed to resolve workspace root: {err}"))?;
Ok(root)
}
fn resolve_path(base: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
base.join(path)
}
}
+3
View File
@@ -6,3 +6,6 @@ edition = "2021"
[dependencies] [dependencies]
common = { path = "../common" } common = { path = "../common" }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] } flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
[dev-dependencies]
proptest = "1"
+6 -1
View File
@@ -135,7 +135,12 @@ impl<'a> LzhDecoder<'a> {
let mut node = self.son[LZH_R]; let mut node = self.son[LZH_R];
while node < LZH_T { while node < LZH_T {
let bit = usize::from(self.bit_reader.read_bit()?); let bit = usize::from(self.bit_reader.read_bit()?);
node = self.son[node + bit]; let branch = node
.checked_add(bit)
.ok_or(Error::DecompressionFailed("lzss-huffman tree overflow"))?;
node = *self.son.get(branch).ok_or(Error::DecompressionFailed(
"lzss-huffman tree out of bounds",
))?;
} }
let c = node - LZH_T; let c = node - LZH_T;
+90 -31
View File
@@ -30,20 +30,33 @@ impl Default for OpenOptions {
} }
} }
#[derive(Clone, Debug)]
pub struct LibraryHeader {
pub raw: [u8; 32],
pub magic: [u8; 2],
pub reserved: u8,
pub version: u8,
pub entry_count: i16,
pub presorted_flag: u16,
pub xor_seed: u32,
}
#[derive(Clone, Debug)]
pub struct AoTrailer {
pub raw: [u8; 6],
pub overlay: u32,
}
#[derive(Debug)] #[derive(Debug)]
pub struct Library { pub struct Library {
bytes: Arc<[u8]>, bytes: Arc<[u8]>,
entries: Vec<EntryRecord>, entries: Vec<EntryRecord>,
#[cfg(test)] header: LibraryHeader,
pub(crate) header_raw: [u8; 32], ao_trailer: Option<AoTrailer>,
#[cfg(test)] #[cfg(test)]
pub(crate) table_plain_original: Vec<u8>, pub(crate) table_plain_original: Vec<u8>,
#[cfg(test)] #[cfg(test)]
pub(crate) xor_seed: u32,
#[cfg(test)]
pub(crate) source_size: usize, pub(crate) source_size: usize,
#[cfg(test)]
pub(crate) trailer_raw: Option<[u8; 6]>,
} }
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
@@ -77,6 +90,16 @@ pub struct EntryRef<'a> {
pub meta: &'a EntryMeta, pub meta: &'a EntryMeta,
} }
#[derive(Copy, Clone, Debug)]
pub struct EntryInspect<'a> {
pub id: EntryId,
pub meta: &'a EntryMeta,
pub name_raw: &'a [u8; 12],
pub service_tail: &'a [u8; 4],
pub sort_to_original: i16,
pub data_offset_raw: u32,
}
pub struct PackedResource { pub struct PackedResource {
pub meta: EntryMeta, pub meta: EntryMeta,
pub packed: Vec<u8>, pub packed: Vec<u8>,
@@ -86,9 +109,9 @@ pub struct PackedResource {
pub(crate) struct EntryRecord { pub(crate) struct EntryRecord {
pub(crate) meta: EntryMeta, pub(crate) meta: EntryMeta,
pub(crate) name_raw: [u8; 12], pub(crate) name_raw: [u8; 12],
pub(crate) service_tail: [u8; 4],
pub(crate) sort_to_original: i16, pub(crate) sort_to_original: i16,
pub(crate) key16: u16, pub(crate) key16: u16,
#[cfg(test)]
pub(crate) data_offset_raw: u32, pub(crate) data_offset_raw: u32,
pub(crate) packed_size_declared: u32, pub(crate) packed_size_declared: u32,
pub(crate) packed_size_available: usize, pub(crate) packed_size_available: usize,
@@ -106,18 +129,40 @@ impl Library {
parse_library(arc, opts) parse_library(arc, opts)
} }
pub fn header(&self) -> &LibraryHeader {
&self.header
}
pub fn ao_trailer(&self) -> Option<&AoTrailer> {
self.ao_trailer.as_ref()
}
pub fn entry_count(&self) -> usize { pub fn entry_count(&self) -> usize {
self.entries.len() self.entries.len()
} }
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> { pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
self.entries self.entries.iter().enumerate().filter_map(|(idx, entry)| {
.iter() let id = u32::try_from(idx).ok()?;
.enumerate() Some(EntryRef {
.map(|(idx, entry)| EntryRef { id: EntryId(id),
id: EntryId(u32::try_from(idx).expect("entry count validated at parse")),
meta: &entry.meta, meta: &entry.meta,
}) })
})
}
pub fn entries_inspect(&self) -> impl Iterator<Item = EntryInspect<'_>> {
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
let id = u32::try_from(idx).ok()?;
Some(EntryInspect {
id: EntryId(id),
meta: &entry.meta,
name_raw: &entry.name_raw,
service_tail: &entry.service_tail,
sort_to_original: entry.sort_to_original,
data_offset_raw: entry.data_offset_raw,
})
})
} }
pub fn find(&self, name: &str) -> Option<EntryId> { pub fn find(&self, name: &str) -> Option<EntryId> {
@@ -161,9 +206,8 @@ impl Library {
Ordering::Less => high = mid, Ordering::Less => high = mid,
Ordering::Greater => low = mid + 1, Ordering::Greater => low = mid + 1,
Ordering::Equal => { Ordering::Equal => {
return Some(EntryId( let id = u32::try_from(idx).ok()?;
u32::try_from(idx).expect("entry count validated at parse"), return Some(EntryId(id));
))
} }
} }
} }
@@ -171,9 +215,8 @@ impl Library {
// Linear fallback search // Linear fallback search
self.entries.iter().enumerate().find_map(|(idx, entry)| { self.entries.iter().enumerate().find_map(|(idx, entry)| {
if cmp_c_string(query_bytes, c_name_bytes(&entry.name_raw)) == Ordering::Equal { if cmp_c_string(query_bytes, c_name_bytes(&entry.name_raw)) == Ordering::Equal {
Some(EntryId( let id = u32::try_from(idx).ok()?;
u32::try_from(idx).expect("entry count validated at parse"), Some(EntryId(id))
))
} else { } else {
None None
} }
@@ -189,6 +232,19 @@ impl Library {
}) })
} }
pub fn inspect(&self, id: EntryId) -> Option<EntryInspect<'_>> {
let idx = usize::try_from(id.0).ok()?;
let entry = self.entries.get(idx)?;
Some(EntryInspect {
id,
meta: &entry.meta,
name_raw: &entry.name_raw,
service_tail: &entry.service_tail,
sort_to_original: entry.sort_to_original,
data_offset_raw: entry.data_offset_raw,
})
}
pub fn load(&self, id: EntryId) -> Result<Vec<u8>> { pub fn load(&self, id: EntryId) -> Result<Vec<u8>> {
let entry = self.entry_by_id(id)?; let entry = self.entry_by_id(id)?;
let packed = self.packed_slice(id, entry)?; let packed = self.packed_slice(id, entry)?;
@@ -251,7 +307,7 @@ impl Library {
.get(idx) .get(idx)
.ok_or_else(|| Error::EntryIdOutOfRange { .ok_or_else(|| Error::EntryIdOutOfRange {
id: id.0, id: id.0,
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX), entry_count: saturating_u32_len(self.entries.len()),
}) })
} }
@@ -286,7 +342,7 @@ impl Library {
#[cfg(test)] #[cfg(test)]
pub(crate) fn rebuild_from_parsed_metadata(&self) -> Result<Vec<u8>> { pub(crate) fn rebuild_from_parsed_metadata(&self) -> Result<Vec<u8>> {
let trailer_len = usize::from(self.trailer_raw.is_some()) * 6; let trailer_len = usize::from(self.ao_trailer.is_some()) * 6;
let pre_trailer_size = self let pre_trailer_size = self
.source_size .source_size
.checked_sub(trailer_len) .checked_sub(trailer_len)
@@ -306,9 +362,11 @@ impl Library {
} }
let mut out = vec![0u8; pre_trailer_size]; let mut out = vec![0u8; pre_trailer_size];
out[0..32].copy_from_slice(&self.header_raw); out[0..32].copy_from_slice(&self.header.raw);
let encrypted_table = let encrypted_table = xor_stream(
xor_stream(&self.table_plain_original, (self.xor_seed & 0xFFFF) as u16); &self.table_plain_original,
(self.header.xor_seed & 0xFFFF) as u16,
);
out[32..table_end].copy_from_slice(&encrypted_table); out[32..table_end].copy_from_slice(&encrypted_table);
let mut occupied = vec![false; pre_trailer_size]; let mut occupied = vec![false; pre_trailer_size];
@@ -317,18 +375,15 @@ impl Library {
} }
for (idx, entry) in self.entries.iter().enumerate() { for (idx, entry) in self.entries.iter().enumerate() {
let packed = self let id = u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?;
.load_packed(EntryId( let packed = self.load_packed(EntryId(id))?.packed;
u32::try_from(idx).expect("entry count validated at parse"),
))?
.packed;
let start = let start =
usize::try_from(entry.data_offset_raw).map_err(|_| Error::IntegerOverflow)?; usize::try_from(entry.data_offset_raw).map_err(|_| Error::IntegerOverflow)?;
for (offset, byte) in packed.iter().copied().enumerate() { for (offset, byte) in packed.iter().copied().enumerate() {
let pos = start.checked_add(offset).ok_or(Error::IntegerOverflow)?; let pos = start.checked_add(offset).ok_or(Error::IntegerOverflow)?;
if pos >= out.len() { if pos >= out.len() {
return Err(Error::PackedSizePastEof { return Err(Error::PackedSizePastEof {
id: u32::try_from(idx).expect("entry count validated at parse"), id,
offset: u64::from(entry.data_offset_raw), offset: u64::from(entry.data_offset_raw),
packed_size: entry.packed_size_declared, packed_size: entry.packed_size_declared,
file_len: u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?, file_len: u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?,
@@ -342,8 +397,8 @@ impl Library {
} }
} }
if let Some(trailer) = self.trailer_raw { if let Some(trailer) = &self.ao_trailer {
out.extend_from_slice(&trailer); out.extend_from_slice(&trailer.raw);
} }
Ok(out) Ok(out)
} }
@@ -407,5 +462,9 @@ fn needs_xor_key(method: PackMethod) -> bool {
) )
} }
fn saturating_u32_len(len: usize) -> u32 {
u32::try_from(len).unwrap_or(u32::MAX)
}
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
+28 -17
View File
@@ -1,6 +1,8 @@
use crate::compress::xor::xor_stream; use crate::compress::xor::xor_stream;
use crate::error::Error; use crate::error::Error;
use crate::{EntryMeta, EntryRecord, Library, OpenOptions, PackMethod, Result}; use crate::{
AoTrailer, EntryMeta, EntryRecord, Library, LibraryHeader, OpenOptions, PackMethod, Result,
};
use std::cmp::Ordering; use std::cmp::Ordering;
use std::sync::Arc; use std::sync::Arc;
@@ -16,13 +18,17 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
let mut header_raw = [0u8; 32]; let mut header_raw = [0u8; 32];
header_raw.copy_from_slice(&bytes[0..32]); header_raw.copy_from_slice(&bytes[0..32]);
if &bytes[0..2] != b"NL" { let mut magic = [0u8; 2];
magic.copy_from_slice(&bytes[0..2]);
if &magic != b"NL" {
let mut got = [0u8; 2]; let mut got = [0u8; 2];
got.copy_from_slice(&bytes[0..2]); got.copy_from_slice(&bytes[0..2]);
return Err(Error::InvalidMagic { got }); return Err(Error::InvalidMagic { got });
} }
if bytes[3] != 0x01 { let reserved = bytes[2];
return Err(Error::UnsupportedVersion { got: bytes[3] }); let version = bytes[3];
if version != 0x01 {
return Err(Error::UnsupportedVersion { got: version });
} }
let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]); let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]);
@@ -36,7 +42,17 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
return Err(Error::TooManyEntries { got: count }); return Err(Error::TooManyEntries { got: count });
} }
let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
let header = LibraryHeader {
raw: header_raw,
magic,
reserved,
version,
entry_count,
presorted_flag,
xor_seed,
};
let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?; let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?;
let table_offset = 32usize; let table_offset = 32usize;
@@ -58,8 +74,6 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
} }
let (overlay, trailer_raw) = parse_ao_trailer(&bytes, opts.allow_ao_trailer)?; 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); let mut entries = Vec::with_capacity(count);
for idx in 0..count { for idx in 0..count {
@@ -67,6 +81,8 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
let mut name_raw = [0u8; 12]; let mut name_raw = [0u8; 12];
name_raw.copy_from_slice(&row[0..12]); name_raw.copy_from_slice(&row[0..12]);
let mut service_tail = [0u8; 4];
service_tail.copy_from_slice(&row[12..16]);
let flags_signed = i16::from_le_bytes([row[16], row[17]]); let flags_signed = i16::from_le_bytes([row[16], row[17]]);
let sort_to_original = i16::from_le_bytes([row[18], row[19]]); let sort_to_original = i16::from_le_bytes([row[18], row[19]]);
@@ -100,12 +116,12 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
.ok_or(Error::IntegerOverflow)?; .ok_or(Error::IntegerOverflow)?;
} else { } else {
return Err(Error::DeflateEofPlusOneQuirkRejected { return Err(Error::DeflateEofPlusOneQuirkRejected {
id: u32::try_from(idx).expect("entry count validated at parse"), id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?,
}); });
} }
} else { } else {
return Err(Error::PackedSizePastEof { return Err(Error::PackedSizePastEof {
id: u32::try_from(idx).expect("entry count validated at parse"), id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?,
offset: effective_offset_u64, offset: effective_offset_u64,
packed_size: packed_size_declared, packed_size: packed_size_declared,
file_len: file_len_u64, file_len: file_len_u64,
@@ -118,7 +134,7 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
.ok_or(Error::IntegerOverflow)?; .ok_or(Error::IntegerOverflow)?;
if available_end > bytes.len() { if available_end > bytes.len() {
return Err(Error::EntryDataOutOfBounds { return Err(Error::EntryDataOutOfBounds {
id: u32::try_from(idx).expect("entry count validated at parse"), id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?,
offset: effective_offset_u64, offset: effective_offset_u64,
size: packed_size_declared, size: packed_size_declared,
file_len: file_len_u64, file_len: file_len_u64,
@@ -137,9 +153,9 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
unpacked_size, unpacked_size,
}, },
name_raw, name_raw,
service_tail,
sort_to_original, sort_to_original,
key16: sort_to_original as u16, key16: sort_to_original as u16,
#[cfg(test)]
data_offset_raw, data_offset_raw,
packed_size_declared, packed_size_declared,
packed_size_available, packed_size_available,
@@ -147,7 +163,6 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
}); });
} }
let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
if presorted_flag == 0xABBA { if presorted_flag == 0xABBA {
let mut seen = vec![false; count]; let mut seen = vec![false; count];
for entry in &entries { for entry in &entries {
@@ -196,16 +211,12 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
Ok(Library { Ok(Library {
bytes, bytes,
entries, entries,
#[cfg(test)] header,
header_raw, ao_trailer: trailer_raw.map(|raw| AoTrailer { raw, overlay }),
#[cfg(test)] #[cfg(test)]
table_plain_original, table_plain_original,
#[cfg(test)] #[cfg(test)]
xor_seed,
#[cfg(test)]
source_size, source_size,
#[cfg(test)]
trailer_raw,
}) })
} }
+15 -14
View File
@@ -1,14 +1,17 @@
use super::*; use super::*;
use crate::compress::lzh::{LZH_MAX_FREQ, LZH_N_CHAR, LZH_R, LZH_T}; use crate::compress::lzh::{LZH_MAX_FREQ, LZH_N_CHAR, LZH_R, LZH_T};
use crate::compress::xor::xor_stream; use crate::compress::xor::xor_stream;
use common::collect_files_recursive;
use flate2::write::DeflateEncoder; use flate2::write::DeflateEncoder;
use flate2::write::ZlibEncoder; use flate2::write::ZlibEncoder;
use flate2::Compression; use flate2::Compression;
use proptest::prelude::*;
use std::any::Any; use std::any::Any;
use std::fs; use std::fs;
use std::io::Write as _; use std::io::Write as _;
use std::panic::{catch_unwind, AssertUnwindSafe}; use std::panic::{catch_unwind, AssertUnwindSafe};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct SyntheticRsliEntry { struct SyntheticRsliEntry {
@@ -37,20 +40,6 @@ impl Default for RsliBuildOptions {
} }
} }
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 rsli_test_files() -> Vec<PathBuf> { fn rsli_test_files() -> Vec<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR")) let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..") .join("..")
@@ -1335,3 +1324,15 @@ fn rsli_validation_error_cases() {
} }
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
} }
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn parse_library_is_panic_free_on_random_bytes(data in proptest::collection::vec(any::<u8>(), 0..4096)) {
let _ = crate::parse::parse_library(
Arc::from(data.into_boxed_slice()),
OpenOptions::default(),
);
}
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "terrain-core"
version = "0.1.0"
edition = "2021"
[dependencies]
nres = { path = "../nres" }
[dev-dependencies]
common = { path = "../common" }
+281
View File
@@ -0,0 +1,281 @@
use nres::Archive;
use std::fmt;
use std::path::Path;
pub const TERRAIN_UV_SCALE: f32 = 1024.0;
pub type Result<T> = core::result::Result<T, Error>;
#[derive(Debug)]
pub enum Error {
Nres(nres::error::Error),
MissingChunk(&'static str),
InvalidChunkSize {
label: &'static str,
size: usize,
stride: usize,
},
VertexCountOverflow {
count: usize,
},
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Nres(err) => write!(f, "{err}"),
Self::MissingChunk(label) => write!(f, "missing required terrain chunk: {label}"),
Self::InvalidChunkSize {
label,
size,
stride,
} => write!(
f,
"invalid chunk size for {label}: {size} (must be divisible by {stride})"
),
Self::VertexCountOverflow { count } => {
write!(f, "terrain vertex count {count} exceeds u16 range")
}
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Nres(err) => Some(err),
_ => None,
}
}
}
impl From<nres::error::Error> for Error {
fn from(value: nres::error::Error) -> Self {
Self::Nres(value)
}
}
#[derive(Clone, Debug)]
pub struct TerrainMesh {
pub positions: Vec<[f32; 3]>,
pub uv0: Vec<[f32; 2]>,
pub faces: Vec<TerrainFace>,
}
#[derive(Copy, Clone, Debug)]
pub struct TerrainFace {
pub indices: [u16; 3],
pub flags: u32,
pub material_tag: u16,
pub aux_tag: u16,
}
#[derive(Clone, Debug)]
pub struct TerrainRenderMesh {
pub vertices: Vec<TerrainRenderVertex>,
pub indices: Vec<u16>,
pub face_count_raw: usize,
pub face_count_kept: usize,
pub face_count_dropped_invalid: usize,
}
#[derive(Copy, Clone, Debug)]
pub struct TerrainRenderVertex {
pub position: [f32; 3],
pub uv0: [f32; 2],
}
pub fn load_land_mesh(path: impl AsRef<Path>) -> Result<TerrainMesh> {
let archive = Archive::open_path(path.as_ref())?;
let positions_entry = archive
.entries()
.find(|entry| entry.meta.kind == 3)
.ok_or(Error::MissingChunk("type=3 (positions)"))?;
let uv_entry = archive.entries().find(|entry| entry.meta.kind == 5);
let faces_entry = archive
.entries()
.find(|entry| entry.meta.kind == 21)
.ok_or(Error::MissingChunk("type=21 (faces)"))?;
let positions_payload = archive.read(positions_entry.id)?.into_owned();
if positions_payload.len() % 12 != 0 {
return Err(Error::InvalidChunkSize {
label: "type=3 (positions)",
size: positions_payload.len(),
stride: 12,
});
}
let mut positions = Vec::with_capacity(positions_payload.len() / 12);
for chunk in positions_payload.chunks_exact(12) {
let x = f32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4]));
let y = f32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0; 4]));
let z = f32::from_le_bytes(chunk[8..12].try_into().unwrap_or([0; 4]));
positions.push([x, y, z]);
}
let mut uv0 = vec![[0.0f32, 0.0f32]; positions.len()];
if let Some(uv_entry) = uv_entry {
let uv_payload = archive.read(uv_entry.id)?.into_owned();
if uv_payload.len() % 4 != 0 {
return Err(Error::InvalidChunkSize {
label: "type=5 (uv)",
size: uv_payload.len(),
stride: 4,
});
}
let uv_count = uv_payload.len() / 4;
for idx in 0..uv_count.min(uv0.len()) {
let off = idx * 4;
let u = i16::from_le_bytes([uv_payload[off], uv_payload[off + 1]]) as f32;
let v = i16::from_le_bytes([uv_payload[off + 2], uv_payload[off + 3]]) as f32;
uv0[idx] = [u / TERRAIN_UV_SCALE, v / TERRAIN_UV_SCALE];
}
}
let face_payload = archive.read(faces_entry.id)?.into_owned();
if face_payload.len() % 28 != 0 {
return Err(Error::InvalidChunkSize {
label: "type=21 (faces)",
size: face_payload.len(),
stride: 28,
});
}
let mut faces = Vec::with_capacity(face_payload.len() / 28);
for chunk in face_payload.chunks_exact(28) {
let flags = u32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4]));
let material_tag = u16::from_le_bytes(chunk[4..6].try_into().unwrap_or([0; 2]));
let aux_tag = u16::from_le_bytes(chunk[6..8].try_into().unwrap_or([0; 2]));
let i0 = u16::from_le_bytes(chunk[8..10].try_into().unwrap_or([0; 2]));
let i1 = u16::from_le_bytes(chunk[10..12].try_into().unwrap_or([0; 2]));
let i2 = u16::from_le_bytes(chunk[12..14].try_into().unwrap_or([0; 2]));
if usize::from(i0) >= positions.len()
|| usize::from(i1) >= positions.len()
|| usize::from(i2) >= positions.len()
{
continue;
}
faces.push(TerrainFace {
indices: [i0, i1, i2],
flags,
material_tag,
aux_tag,
});
}
Ok(TerrainMesh {
positions,
uv0,
faces,
})
}
pub fn build_render_mesh(mesh: &TerrainMesh) -> Result<TerrainRenderMesh> {
if mesh.positions.len() > usize::from(u16::MAX) + 1 {
return Err(Error::VertexCountOverflow {
count: mesh.positions.len(),
});
}
let vertices = mesh
.positions
.iter()
.enumerate()
.map(|(idx, &position)| TerrainRenderVertex {
position,
uv0: mesh.uv0.get(idx).copied().unwrap_or([0.0, 0.0]),
})
.collect::<Vec<_>>();
let mut indices = Vec::with_capacity(mesh.faces.len() * 3);
for face in &mesh.faces {
indices.extend_from_slice(&face.indices);
}
Ok(TerrainRenderMesh {
vertices,
indices,
face_count_raw: mesh.faces.len(),
face_count_kept: mesh.faces.len(),
face_count_dropped_invalid: 0,
})
}
#[cfg(test)]
mod tests {
use super::*;
use common::collect_files_recursive;
use std::path::{Path, PathBuf};
fn game_root() -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata")
.join("Parkan - Iron Strategy");
root.is_dir().then_some(root)
}
#[test]
fn loads_known_land_mesh() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let land = root
.join("DATA")
.join("MAPS")
.join("Tut_1")
.join("Land.msh");
if !land.is_file() {
eprintln!("skipping missing sample {}", land.display());
return;
}
let mesh = load_land_mesh(&land)
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", land.display()));
assert!(mesh.positions.len() > 1000);
assert!(mesh.faces.len() > 1000);
let render = build_render_mesh(&mesh).expect("failed to build render mesh");
assert_eq!(render.vertices.len(), mesh.positions.len());
assert_eq!(render.indices.len(), mesh.faces.len() * 3);
}
#[test]
fn loads_all_retail_land_meshes() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let maps_root = root.join("DATA").join("MAPS");
let mut files = Vec::new();
collect_files_recursive(&maps_root, &mut files);
files.sort();
let mut parsed = 0usize;
for path in files {
if !path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.eq_ignore_ascii_case("Land.msh"))
{
continue;
}
let mesh = load_land_mesh(&path)
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
assert!(
!mesh.positions.is_empty() && !mesh.faces.is_empty(),
"{} parsed but empty",
path.display()
);
parsed += 1;
}
assert!(parsed > 0, "no Land.msh files parsed");
}
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "texm"
version = "0.1.0"
edition = "2021"
[dev-dependencies]
common = { path = "../common" }
nres = { path = "../nres" }
proptest = "1"
+15
View File
@@ -0,0 +1,15 @@
# texm
Парсер формата текстур `Texm`.
Покрывает:
- header (`width/height/mipCount/flags/format`);
- core size расчёт;
- optional `Page` chunk;
- строгую валидацию layout.
Тесты:
- прогон по реальным `Texm` из `testdata`;
- синтетические edge-cases (indexed + page, minimal rgba).
+86
View File
@@ -0,0 +1,86 @@
use core::fmt;
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
HeaderTooSmall {
size: usize,
},
InvalidMagic {
got: u32,
},
InvalidDimensions {
width: u32,
height: u32,
},
InvalidMipCount {
mip_count: u32,
},
UnknownFormat {
format: u32,
},
IntegerOverflow,
CoreDataOutOfBounds {
expected_end: usize,
actual_size: usize,
},
MipIndexOutOfRange {
requested: usize,
mip_count: usize,
},
MipDataOutOfBounds {
offset: usize,
size: usize,
payload_size: usize,
},
InvalidPageMagic,
InvalidPageSize {
expected: usize,
actual: usize,
},
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::HeaderTooSmall { size } => {
write!(f, "Texm payload too small for header: {size}")
}
Self::InvalidMagic { got } => write!(f, "invalid Texm magic: 0x{got:08X}"),
Self::InvalidDimensions { width, height } => {
write!(f, "invalid Texm dimensions: {width}x{height}")
}
Self::InvalidMipCount { mip_count } => write!(f, "invalid Texm mip_count={mip_count}"),
Self::UnknownFormat { format } => write!(f, "unknown Texm format={format}"),
Self::IntegerOverflow => write!(f, "integer overflow"),
Self::CoreDataOutOfBounds {
expected_end,
actual_size,
} => write!(
f,
"Texm core data out of bounds: expected_end={expected_end}, actual_size={actual_size}"
),
Self::MipIndexOutOfRange {
requested,
mip_count,
} => write!(
f,
"Texm mip index out of range: requested={requested}, mip_count={mip_count}"
),
Self::MipDataOutOfBounds {
offset,
size,
payload_size,
} => write!(
f,
"Texm mip data out of bounds: offset={offset}, size={size}, payload_size={payload_size}"
),
Self::InvalidPageMagic => write!(f, "Texm tail exists but Page magic is missing"),
Self::InvalidPageSize { expected, actual } => {
write!(f, "invalid Page chunk size: expected={expected}, actual={actual}")
}
}
}
}
impl std::error::Error for Error {}
+417
View File
@@ -0,0 +1,417 @@
pub mod error;
use crate::error::Error;
pub type Result<T> = core::result::Result<T, Error>;
pub const TEXM_MAGIC: u32 = 0x6D78_6554;
pub const PAGE_MAGIC: u32 = 0x6567_6150;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum PixelFormat {
Indexed8,
Rgb565,
Rgb556,
Argb4444,
LuminanceAlpha88,
Rgb888,
Argb8888,
}
impl PixelFormat {
pub fn from_raw(raw: u32) -> Option<Self> {
match raw {
0 => Some(Self::Indexed8),
565 => Some(Self::Rgb565),
556 => Some(Self::Rgb556),
4444 => Some(Self::Argb4444),
88 => Some(Self::LuminanceAlpha88),
888 => Some(Self::Rgb888),
8888 => Some(Self::Argb8888),
_ => None,
}
}
pub fn bytes_per_pixel(self) -> usize {
match self {
Self::Indexed8 => 1,
Self::Rgb565 | Self::Rgb556 | Self::Argb4444 | Self::LuminanceAlpha88 => 2,
// Parkan stores format 888 as 32-bit RGBX in texture payloads.
Self::Rgb888 | Self::Argb8888 => 4,
}
}
}
#[derive(Clone, Debug)]
pub struct Header {
pub width: u32,
pub height: u32,
pub mip_count: u32,
pub flags4: u32,
pub flags5: u32,
pub unk6: u32,
pub format_raw: u32,
pub format: PixelFormat,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct MipLevel {
pub width: u32,
pub height: u32,
pub offset: usize,
pub size: usize,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct PageRect {
pub x: i16,
pub w: i16,
pub y: i16,
pub h: i16,
}
#[derive(Clone, Debug)]
pub struct Texture {
pub header: Header,
pub palette: Option<[u8; 1024]>,
pub mip_levels: Vec<MipLevel>,
pub page_rects: Vec<PageRect>,
}
impl Texture {
pub fn core_size(&self) -> usize {
let mut size = 32usize;
if self.palette.is_some() {
size += 1024;
}
for level in &self.mip_levels {
size += level.size;
}
size
}
}
#[derive(Clone, Debug)]
pub struct DecodedMip {
pub width: u32,
pub height: u32,
pub rgba8: Vec<u8>,
}
pub fn parse_texm(payload: &[u8]) -> Result<Texture> {
if payload.len() < 32 {
return Err(Error::HeaderTooSmall {
size: payload.len(),
});
}
let magic = read_u32(payload, 0)?;
if magic != TEXM_MAGIC {
return Err(Error::InvalidMagic { got: magic });
}
let width = read_u32(payload, 4)?;
let height = read_u32(payload, 8)?;
let mip_count = read_u32(payload, 12)?;
let flags4 = read_u32(payload, 16)?;
let flags5 = read_u32(payload, 20)?;
let unk6 = read_u32(payload, 24)?;
let format_raw = read_u32(payload, 28)?;
if width == 0 || height == 0 {
return Err(Error::InvalidDimensions { width, height });
}
if mip_count == 0 {
return Err(Error::InvalidMipCount { mip_count });
}
let format =
PixelFormat::from_raw(format_raw).ok_or(Error::UnknownFormat { format: format_raw })?;
let bytes_per_pixel = format.bytes_per_pixel();
let mut offset = 32usize;
let palette = if format == PixelFormat::Indexed8 {
let end = offset.checked_add(1024).ok_or(Error::IntegerOverflow)?;
if end > payload.len() {
return Err(Error::CoreDataOutOfBounds {
expected_end: end,
actual_size: payload.len(),
});
}
let mut pal = [0u8; 1024];
pal.copy_from_slice(&payload[offset..end]);
offset = end;
Some(pal)
} else {
None
};
let mut mip_levels =
Vec::with_capacity(usize::try_from(mip_count).map_err(|_| Error::IntegerOverflow)?);
let mut w = width;
let mut h = height;
for _ in 0..mip_count {
let pixel_count_u64 = u64::from(w)
.checked_mul(u64::from(h))
.ok_or(Error::IntegerOverflow)?;
let level_size_u64 = pixel_count_u64
.checked_mul(u64::try_from(bytes_per_pixel).map_err(|_| Error::IntegerOverflow)?)
.ok_or(Error::IntegerOverflow)?;
let level_size = usize::try_from(level_size_u64).map_err(|_| Error::IntegerOverflow)?;
let level_offset = offset;
offset = offset
.checked_add(level_size)
.ok_or(Error::IntegerOverflow)?;
if offset > payload.len() {
return Err(Error::CoreDataOutOfBounds {
expected_end: offset,
actual_size: payload.len(),
});
}
mip_levels.push(MipLevel {
width: w,
height: h,
offset: level_offset,
size: level_size,
});
w = (w >> 1).max(1);
h = (h >> 1).max(1);
}
let page_rects = parse_page_tail(payload, offset)?;
Ok(Texture {
header: Header {
width,
height,
mip_count,
flags4,
flags5,
unk6,
format_raw,
format,
},
palette,
mip_levels,
page_rects,
})
}
pub fn decode_mip_rgba8(texture: &Texture, payload: &[u8], mip_index: usize) -> Result<DecodedMip> {
let Some(level) = texture.mip_levels.get(mip_index).copied() else {
return Err(Error::MipIndexOutOfRange {
requested: mip_index,
mip_count: texture.mip_levels.len(),
});
};
let end = level
.offset
.checked_add(level.size)
.ok_or(Error::IntegerOverflow)?;
let Some(level_data) = payload.get(level.offset..end) else {
return Err(Error::MipDataOutOfBounds {
offset: level.offset,
size: level.size,
payload_size: payload.len(),
});
};
let pixel_count = usize::try_from(level.width)
.ok()
.and_then(|w| {
usize::try_from(level.height)
.ok()
.map(|h| w.saturating_mul(h))
})
.ok_or(Error::IntegerOverflow)?;
let mut rgba = vec![0u8; pixel_count.saturating_mul(4)];
match texture.header.format {
PixelFormat::Indexed8 => {
let palette = texture.palette.as_ref().ok_or(Error::IntegerOverflow)?;
for (i, &index) in level_data.iter().enumerate() {
if i >= pixel_count {
break;
}
let poff = usize::from(index).saturating_mul(4);
// Keep this form to accept the last palette item (index 255).
if poff + 4 > palette.len() {
continue;
}
let out = i.saturating_mul(4);
rgba[out] = palette[poff];
rgba[out + 1] = palette[poff + 1];
rgba[out + 2] = palette[poff + 2];
rgba[out + 3] = palette[poff + 3];
}
}
PixelFormat::Rgb565 => {
decode_words(level_data, pixel_count, &mut rgba, decode_rgb565);
}
PixelFormat::Rgb556 => {
decode_words(level_data, pixel_count, &mut rgba, decode_rgb556);
}
PixelFormat::Argb4444 => {
decode_words(level_data, pixel_count, &mut rgba, decode_argb4444);
}
PixelFormat::LuminanceAlpha88 => {
decode_words(level_data, pixel_count, &mut rgba, decode_luminance_alpha88);
}
PixelFormat::Rgb888 => {
decode_dwords(level_data, pixel_count, &mut rgba, decode_rgb888x);
}
PixelFormat::Argb8888 => {
decode_dwords(level_data, pixel_count, &mut rgba, decode_argb8888);
}
}
Ok(DecodedMip {
width: level.width,
height: level.height,
rgba8: rgba,
})
}
fn parse_page_tail(payload: &[u8], core_end: usize) -> Result<Vec<PageRect>> {
if core_end == payload.len() {
return Ok(Vec::new());
}
if payload.len().saturating_sub(core_end) < 8 {
return Err(Error::InvalidPageSize {
expected: 8,
actual: payload.len().saturating_sub(core_end),
});
}
let magic = read_u32(payload, core_end)?;
if magic != PAGE_MAGIC {
return Err(Error::InvalidPageMagic);
}
let rect_count = read_u32(payload, core_end + 4)?;
let rect_count_usize = usize::try_from(rect_count).map_err(|_| Error::IntegerOverflow)?;
let expected_size = 8usize
.checked_add(
rect_count_usize
.checked_mul(8)
.ok_or(Error::IntegerOverflow)?,
)
.ok_or(Error::IntegerOverflow)?;
let actual = payload.len().saturating_sub(core_end);
if expected_size != actual {
return Err(Error::InvalidPageSize {
expected: expected_size,
actual,
});
}
let mut rects = Vec::with_capacity(rect_count_usize);
for i in 0..rect_count_usize {
let off = core_end
.checked_add(8)
.and_then(|v| v.checked_add(i * 8))
.ok_or(Error::IntegerOverflow)?;
rects.push(PageRect {
x: read_i16(payload, off)?,
w: read_i16(payload, off + 2)?,
y: read_i16(payload, off + 4)?,
h: read_i16(payload, off + 6)?,
});
}
Ok(rects)
}
fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
let bytes = data.get(offset..offset + 4).ok_or(Error::IntegerOverflow)?;
let arr: [u8; 4] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
Ok(u32::from_le_bytes(arr))
}
fn read_i16(data: &[u8], offset: usize) -> Result<i16> {
let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?;
let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
Ok(i16::from_le_bytes(arr))
}
fn decode_words(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u16) -> [u8; 4]) {
for i in 0..pixel_count {
let off = i.saturating_mul(2);
let Some(bytes) = data.get(off..off + 2) else {
break;
};
let word = u16::from_le_bytes([bytes[0], bytes[1]]);
let px = decode(word);
let out = i.saturating_mul(4);
rgba[out..out + 4].copy_from_slice(&px);
}
}
fn decode_dwords(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u32) -> [u8; 4]) {
for i in 0..pixel_count {
let off = i.saturating_mul(4);
let Some(bytes) = data.get(off..off + 4) else {
break;
};
let dword = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
let px = decode(dword);
let out = i.saturating_mul(4);
rgba[out..out + 4].copy_from_slice(&px);
}
}
fn expand5(v: u16) -> u8 {
((u32::from(v) * 255 + 15) / 31) as u8
}
fn expand6(v: u16) -> u8 {
((u32::from(v) * 255 + 31) / 63) as u8
}
fn expand4(v: u16) -> u8 {
(u32::from(v) * 17) as u8
}
fn decode_rgb565(word: u16) -> [u8; 4] {
let r = expand5((word >> 11) & 0x1F);
let g = expand6((word >> 5) & 0x3F);
let b = expand5(word & 0x1F);
[r, g, b, 255]
}
fn decode_rgb556(word: u16) -> [u8; 4] {
let r = expand5((word >> 11) & 0x1F);
let g = expand5((word >> 6) & 0x1F);
let b = expand6(word & 0x3F);
[r, g, b, 255]
}
fn decode_argb4444(word: u16) -> [u8; 4] {
let a = expand4((word >> 12) & 0x0F);
let r = expand4((word >> 8) & 0x0F);
let g = expand4((word >> 4) & 0x0F);
let b = expand4(word & 0x0F);
[r, g, b, a]
}
fn decode_luminance_alpha88(word: u16) -> [u8; 4] {
let l = ((word >> 8) & 0xFF) as u8;
let a = (word & 0xFF) as u8;
[l, l, l, a]
}
fn decode_rgb888x(dword: u32) -> [u8; 4] {
let r = (dword & 0xFF) as u8;
let g = ((dword >> 8) & 0xFF) as u8;
let b = ((dword >> 16) & 0xFF) as u8;
[r, g, b, 255]
}
fn decode_argb8888(dword: u32) -> [u8; 4] {
let a = (dword & 0xFF) as u8;
let r = ((dword >> 8) & 0xFF) as u8;
let g = ((dword >> 16) & 0xFF) as u8;
let b = ((dword >> 24) & 0xFF) as u8;
[r, g, b, a]
}
#[cfg(test)]
mod tests;
+330
View File
@@ -0,0 +1,330 @@
use super::*;
use common::collect_files_recursive;
use nres::Archive;
use proptest::prelude::*;
use std::fs;
use std::path::{Path, PathBuf};
fn nres_test_files() -> Vec<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata");
let mut files = Vec::new();
collect_files_recursive(&root, &mut files);
files.sort();
files
.into_iter()
.filter(|path| {
fs::read(path)
.map(|bytes| bytes.get(0..4) == Some(b"NRes"))
.unwrap_or(false)
})
.collect()
}
fn build_texm_payload(
width: u32,
height: u32,
format_raw: u32,
flags5: u32,
palette: Option<[u8; 1024]>,
mip_levels: &[&[u8]],
) -> Vec<u8> {
let mut payload = Vec::new();
payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes());
payload.extend_from_slice(&width.to_le_bytes());
payload.extend_from_slice(&height.to_le_bytes());
payload.extend_from_slice(
&u32::try_from(mip_levels.len())
.expect("mip level count overflow in test")
.to_le_bytes(),
);
payload.extend_from_slice(&0u32.to_le_bytes()); // flags4
payload.extend_from_slice(&flags5.to_le_bytes());
payload.extend_from_slice(&0u32.to_le_bytes()); // unk6
payload.extend_from_slice(&format_raw.to_le_bytes());
if let Some(palette) = palette {
payload.extend_from_slice(&palette);
}
for level in mip_levels {
payload.extend_from_slice(level);
}
payload
}
#[test]
fn texm_parse_all_game_textures() {
let archives = nres_test_files();
if archives.is_empty() {
eprintln!("skipping texm_parse_all_game_textures: no NRes files in testdata");
return;
}
let mut texm_total = 0usize;
let mut texm_with_page = 0usize;
for archive_path in archives {
let archive = Archive::open_path(&archive_path)
.unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display()));
for entry in archive.entries() {
if entry.meta.kind != TEXM_MAGIC {
continue;
}
texm_total += 1;
let payload = archive.read(entry.id).unwrap_or_else(|err| {
panic!(
"failed to read Texm entry '{}' in {}: {err}",
entry.meta.name,
archive_path.display()
)
});
let texture = parse_texm(payload.as_slice()).unwrap_or_else(|err| {
panic!(
"failed to parse Texm '{}' in {}: {err}",
entry.meta.name,
archive_path.display()
)
});
if !texture.page_rects.is_empty() {
texm_with_page += 1;
}
assert!(
texture.core_size() <= payload.as_slice().len(),
"core size must be within payload for '{}' in {}",
entry.meta.name,
archive_path.display()
);
assert_eq!(
usize::try_from(texture.header.mip_count).ok(),
Some(texture.mip_levels.len()),
"mip count mismatch for '{}' in {}",
entry.meta.name,
archive_path.display()
);
}
}
assert!(texm_total > 0, "no Texm textures found");
assert!(
texm_with_page > 0,
"expected at least one Texm texture with Page chunk"
);
}
#[test]
fn texm_parse_minimal_argb8888_no_page() {
let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[1, 2, 3, 4]]);
let parsed = parse_texm(&payload).expect("failed to parse minimal texm");
assert_eq!(parsed.header.width, 1);
assert_eq!(parsed.header.height, 1);
assert_eq!(parsed.mip_levels.len(), 1);
assert!(parsed.page_rects.is_empty());
}
#[test]
fn texm_decode_minimal_argb8888_no_page() {
let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[0x40, 0x11, 0x22, 0x33]]);
let parsed = parse_texm(&payload).expect("failed to parse minimal texm");
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode mip");
assert_eq!(decoded.width, 1);
assert_eq!(decoded.height, 1);
assert_eq!(decoded.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
}
#[test]
fn texm_decode_rgb565() {
let word = 0xFFE0u16; // r=31 g=63 b=0
let payload = build_texm_payload(1, 1, 565, 0, None, &[&word.to_le_bytes()]);
let parsed = parse_texm(&payload).expect("failed to parse rgb565 texm");
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb565 texm");
assert_eq!(decoded.rgba8, vec![255, 255, 0, 255]);
}
#[test]
fn texm_decode_rgb556() {
let word = 0xF800u16; // r=31 g=0 b=0
let payload = build_texm_payload(1, 1, 556, 0, None, &[&word.to_le_bytes()]);
let parsed = parse_texm(&payload).expect("failed to parse rgb556 texm");
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb556 texm");
assert_eq!(decoded.rgba8, vec![255, 0, 0, 255]);
}
#[test]
fn texm_decode_argb4444() {
let word = 0xF12Eu16; // a=F r=1 g=2 b=E
let payload = build_texm_payload(1, 1, 4444, 0, None, &[&word.to_le_bytes()]);
let parsed = parse_texm(&payload).expect("failed to parse argb4444 texm");
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode argb4444 texm");
assert_eq!(decoded.rgba8, vec![17, 34, 238, 255]);
}
#[test]
fn texm_decode_luminance_alpha88() {
let word = 0x7F40u16; // luminance=0x7F alpha=0x40
let payload = build_texm_payload(1, 1, 88, 0, None, &[&word.to_le_bytes()]);
let parsed = parse_texm(&payload).expect("failed to parse la88 texm");
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode la88 texm");
assert_eq!(decoded.rgba8, vec![0x7F, 0x7F, 0x7F, 0x40]);
}
#[test]
fn texm_decode_rgb888x() {
let payload = build_texm_payload(1, 1, 888, 0, None, &[&[0x11, 0x22, 0x33, 0x99]]);
let parsed = parse_texm(&payload).expect("failed to parse rgb888 texm");
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb888 texm");
assert_eq!(decoded.rgba8, vec![0x11, 0x22, 0x33, 255]);
}
#[test]
fn texm_parse_indexed_with_page_chunk() {
let mut palette = [0u8; 1024];
palette[4..8].copy_from_slice(&[10, 20, 30, 255]);
let mut payload = build_texm_payload(2, 2, 0, 0, Some(palette), &[&[1, 1, 1, 1]]);
payload.extend_from_slice(&PAGE_MAGIC.to_le_bytes());
payload.extend_from_slice(&1u32.to_le_bytes()); // rect_count
payload.extend_from_slice(&0i16.to_le_bytes()); // x
payload.extend_from_slice(&2i16.to_le_bytes()); // w
payload.extend_from_slice(&0i16.to_le_bytes()); // y
payload.extend_from_slice(&2i16.to_le_bytes()); // h
let parsed = parse_texm(&payload).expect("failed to parse indexed texm");
assert!(parsed.palette.is_some());
assert_eq!(parsed.page_rects.len(), 1);
assert_eq!(
parsed.page_rects[0],
PageRect {
x: 0,
w: 2,
y: 0,
h: 2
}
);
}
#[test]
fn texm_decode_indexed_with_palette_last_entry() {
let mut palette = [0u8; 1024];
palette[4..8].copy_from_slice(&[10, 20, 30, 255]); // index 1
palette[8..12].copy_from_slice(&[40, 50, 60, 200]); // index 2
palette[1020..1024].copy_from_slice(&[1, 2, 3, 4]); // index 255 (last)
let payload = build_texm_payload(3, 1, 0, 0, Some(palette), &[&[1u8, 2u8, 255u8]]);
let parsed = parse_texm(&payload).expect("failed to parse indexed texm");
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode indexed texm");
assert_eq!(decoded.width, 3);
assert_eq!(decoded.height, 1);
assert_eq!(
decoded.rgba8,
vec![10, 20, 30, 255, 40, 50, 60, 200, 1, 2, 3, 4]
);
}
#[test]
fn texm_parse_multi_mip_offsets() {
let mip0 = [0x10u8; 32]; // 4*2*4
let mip1 = [0x20u8; 8]; // 2*1*4
let mip2 = [0x30u8; 4]; // 1*1*4
let payload = build_texm_payload(4, 2, 8888, 0, None, &[&mip0, &mip1, &mip2]);
let parsed = parse_texm(&payload).expect("failed to parse multi-mip texm");
assert_eq!(parsed.header.mip_count, 3);
assert_eq!(parsed.mip_levels.len(), 3);
assert_eq!(
parsed.mip_levels,
vec![
MipLevel {
width: 4,
height: 2,
offset: 32,
size: 32
},
MipLevel {
width: 2,
height: 1,
offset: 64,
size: 8
},
MipLevel {
width: 1,
height: 1,
offset: 72,
size: 4
},
]
);
}
#[test]
fn texm_preserves_flags5_for_mip_skip_metadata() {
let payload = build_texm_payload(1, 1, 8888, 0x0000_00A5, None, &[&[0, 0, 0, 0]]);
let parsed = parse_texm(&payload).expect("failed to parse texm");
assert_eq!(parsed.header.flags5, 0x0000_00A5);
}
#[test]
fn texm_errors_for_invalid_header_values() {
let mut bad_magic = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]);
bad_magic[0..4].copy_from_slice(&0u32.to_le_bytes());
assert!(matches!(
parse_texm(&bad_magic),
Err(Error::InvalidMagic { .. })
));
let zero_dims = build_texm_payload(0, 1, 8888, 0, None, &[&[]]);
assert!(matches!(
parse_texm(&zero_dims),
Err(Error::InvalidDimensions { .. })
));
let mut bad_mips = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]);
bad_mips[12..16].copy_from_slice(&0u32.to_le_bytes());
assert!(matches!(
parse_texm(&bad_mips),
Err(Error::InvalidMipCount { .. })
));
let bad_format = build_texm_payload(1, 1, 12345, 0, None, &[&[0, 0, 0, 0]]);
assert!(matches!(
parse_texm(&bad_format),
Err(Error::UnknownFormat { .. })
));
}
#[test]
fn texm_errors_for_page_chunk_and_mip_bounds() {
let mut bad_page = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]);
bad_page.extend_from_slice(b"X");
assert!(matches!(
parse_texm(&bad_page),
Err(Error::InvalidPageSize { .. })
));
let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[1, 2, 3, 4]]);
let parsed = parse_texm(&payload).expect("failed to parse valid texm");
assert!(matches!(
decode_mip_rgba8(&parsed, &payload, 7),
Err(Error::MipIndexOutOfRange { .. })
));
let truncated = &payload[..payload.len() - 1];
assert!(matches!(
decode_mip_rgba8(&parsed, truncated, 0),
Err(Error::MipDataOutOfBounds { .. })
));
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn parse_texm_is_panic_free_on_random_bytes(payload in proptest::collection::vec(any::<u8>(), 0..4096)) {
if let Ok(texture) = parse_texm(&payload) {
for mip_index in 0..texture.mip_levels.len() {
let _ = decode_mip_rgba8(&texture, &payload, mip_index);
}
}
}
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "tma"
version = "0.1.0"
edition = "2021"
[dependencies]
encoding_rs = "0.8"
[dev-dependencies]
common = { path = "../common" }
+485
View File
@@ -0,0 +1,485 @@
use encoding_rs::WINDOWS_1251;
use std::fmt;
use std::fs;
use std::path::Path;
const OBJECT_RECORD_FLAGS: u32 = 0x8000_0002;
const FOOTER_MAGIC: &[u8; 4] = b"MtPr";
const MAP_PATH_TOKEN: &[u8; 10] = b"DATA\\MAPS\\";
pub type Result<T> = core::result::Result<T, Error>;
#[derive(Debug)]
pub enum Error {
Io(std::io::Error),
FooterNotFound,
FooterCorrupt(&'static str),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(err) => write!(f, "{err}"),
Self::FooterNotFound => write!(f, "footer magic 'MtPr' not found"),
Self::FooterCorrupt(reason) => write!(f, "corrupt mission footer: {reason}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
_ => None,
}
}
}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
#[derive(Clone, Debug)]
pub struct MissionFile {
pub footer: MissionFooter,
pub objects: Vec<MissionObject>,
}
#[derive(Clone, Debug)]
pub struct MissionFooter {
pub map_path: String,
pub title: String,
pub version: u32,
}
#[derive(Clone, Debug)]
pub struct MissionObject {
pub offset: usize,
pub group_id: u32,
pub flags: u32,
pub resource_name: String,
pub logical_id: i32,
pub clan_id: i32,
pub position: [f32; 3],
pub orientation: [f32; 3],
pub scale: [f32; 3],
pub alias: String,
}
pub fn parse_path(path: impl AsRef<Path>) -> Result<MissionFile> {
let bytes = fs::read(path.as_ref())?;
parse_bytes(&bytes)
}
pub fn parse_bytes(bytes: &[u8]) -> Result<MissionFile> {
let footer = parse_footer(bytes)?;
let objects = parse_objects(bytes);
Ok(MissionFile { footer, objects })
}
fn parse_footer(bytes: &[u8]) -> Result<MissionFooter> {
let map_positions = find_all_map_path_positions(bytes);
if map_positions.is_empty() {
return Err(Error::FooterNotFound);
}
for map_start in map_positions.into_iter().rev() {
if map_start < 4 {
continue;
}
let map_end = scan_path_end(bytes, map_start);
if map_end <= map_start {
continue;
}
let map_len = map_end - map_start;
let Some(declared_map_len) = read_u32(bytes, map_start - 4).map(|v| v as usize) else {
continue;
};
if declared_map_len != map_len {
continue;
}
let Some(zero_pad) = read_u32(bytes, map_end) else {
continue;
};
if zero_pad != 0 {
continue;
}
let title_len_off = map_end + 4;
let Some(title_len) = read_u32(bytes, title_len_off).map(|v| v as usize) else {
continue;
};
if title_len == 0 || title_len > 256 {
continue;
}
let title_start = title_len_off + 4;
let Some(title_end) = title_start.checked_add(title_len) else {
continue;
};
if title_end > bytes.len() {
continue;
}
let map_path = decode_cp1251(&bytes[map_start..map_end]);
if !map_path.to_ascii_uppercase().contains("DATA\\MAPS\\") {
continue;
}
let title = decode_title(&bytes[title_start..title_end]);
let version = parse_footer_version(bytes, title_end)?;
return Ok(MissionFooter {
map_path,
title,
version,
});
}
// Fallback for multiplayer/legacy variants where the footer tail differs,
// but map path is still present in clear text near EOF.
let Some(map_start) = bytes
.windows(MAP_PATH_TOKEN.len())
.rposition(|window| window == MAP_PATH_TOKEN)
else {
return Err(Error::FooterCorrupt("failed to decode map/title envelope"));
};
let map_end = scan_path_end(bytes, map_start);
if map_end <= map_start {
return Err(Error::FooterCorrupt("failed to decode map/title envelope"));
}
let map_path = decode_cp1251(&bytes[map_start..map_end]);
if !map_path.to_ascii_uppercase().contains("DATA\\MAPS\\") {
return Err(Error::FooterCorrupt("failed to decode map/title envelope"));
}
let mut title = String::new();
if let Some(title_len) = read_u32(bytes, map_end + 8).map(|v| v as usize) {
let title_start = map_end + 12;
let title_end = title_start.saturating_add(title_len);
if title_len > 0 && title_len <= 256 && title_end <= bytes.len() {
let raw = &bytes[title_start..title_end];
if raw.iter().all(|b| b.is_ascii_graphic() || *b == b' ') {
title = decode_title(raw);
}
}
}
let version = if let Some(magic_off) = bytes
.windows(FOOTER_MAGIC.len())
.rposition(|window| window == FOOTER_MAGIC)
{
read_u32(bytes, magic_off + 4).unwrap_or(1)
} else {
read_u32(bytes, map_end).unwrap_or(1)
};
Ok(MissionFooter {
map_path,
title,
version,
})
}
fn parse_footer_version(bytes: &[u8], after_title_off: usize) -> Result<u32> {
if after_title_off + 8 <= bytes.len()
&& &bytes[after_title_off..after_title_off + 4] == FOOTER_MAGIC
{
let version = read_u32(bytes, after_title_off + 4)
.ok_or(Error::FooterCorrupt("missing version after MtPr"))?;
return Ok(version);
}
let version = read_u32(bytes, after_title_off)
.ok_or(Error::FooterCorrupt("missing version after title"))?;
Ok(version)
}
fn find_all_map_path_positions(bytes: &[u8]) -> Vec<usize> {
bytes
.windows(MAP_PATH_TOKEN.len())
.enumerate()
.filter_map(|(idx, window)| (window == MAP_PATH_TOKEN).then_some(idx))
.collect()
}
fn scan_path_end(bytes: &[u8], start: usize) -> usize {
let mut off = start;
while off < bytes.len() && is_path_byte(bytes[off]) {
off += 1;
}
off
}
fn is_path_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'/' | b'\\' | b'-' | b' ' | b':')
}
fn parse_objects(bytes: &[u8]) -> Vec<MissionObject> {
let mut objects = Vec::new();
let min_record_tail = 48usize;
for offset in 0..bytes.len().saturating_sub(16) {
let Some(flags) = read_u32(bytes, offset + 4) else {
continue;
};
if flags != OBJECT_RECORD_FLAGS {
continue;
}
let Some(name_len) = read_u32(bytes, offset + 8).map(|v| v as usize) else {
continue;
};
if !(3..=260).contains(&name_len) {
continue;
}
let name_start = offset + 12;
let Some(name_end) = name_start.checked_add(name_len) else {
continue;
};
if name_end + min_record_tail > bytes.len() {
continue;
}
let name_raw = &bytes[name_start..name_end];
if !is_object_name_bytes(name_raw) {
continue;
}
let resource_name = decode_cp1251(name_raw);
if !looks_like_object_name(&resource_name) {
continue;
}
let Some(group_id) = read_u32(bytes, offset) else {
continue;
};
let Some(logical_id) = read_i32(bytes, name_end) else {
continue;
};
let Some(clan_id) = read_i32(bytes, name_end + 4) else {
continue;
};
let Some(position) = read_vec3(bytes, name_end + 8) else {
continue;
};
let Some(orientation) = read_vec3(bytes, name_end + 20) else {
continue;
};
let Some(scale) = read_vec3(bytes, name_end + 32) else {
continue;
};
if !all_finite(&position) || !all_finite(&orientation) || !all_finite(&scale) {
continue;
}
let alias = parse_alias(bytes, name_end + 44);
objects.push(MissionObject {
offset,
group_id,
flags,
resource_name,
logical_id,
clan_id,
position,
orientation,
scale,
alias,
});
}
objects.sort_by_key(|obj| obj.offset);
objects.dedup_by_key(|obj| obj.offset);
objects
}
fn parse_alias(bytes: &[u8], alias_len_off: usize) -> String {
let Some(alias_len) = read_u32(bytes, alias_len_off).map(|v| v as usize) else {
return String::new();
};
if alias_len == 0 || alias_len > 96 {
return String::new();
}
let alias_start = alias_len_off + 4;
let Some(alias_end) = alias_start.checked_add(alias_len) else {
return String::new();
};
if alias_end > bytes.len() {
return String::new();
}
let alias_raw = &bytes[alias_start..alias_end];
if !alias_raw
.iter()
.all(|&b| b == b'_' || b == b'-' || b == b'.' || b.is_ascii_alphanumeric())
{
return String::new();
}
decode_cp1251(alias_raw)
}
fn looks_like_object_name(name: &str) -> bool {
if name.ends_with(".dat") {
return true;
}
name.contains('_')
}
fn is_object_name_bytes(bytes: &[u8]) -> bool {
bytes
.iter()
.all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'_' | b'.' | b'/' | b'\\' | b'-'))
}
fn all_finite(v: &[f32; 3]) -> bool {
v.iter().all(|c| c.is_finite())
}
fn decode_cp1251(bytes: &[u8]) -> String {
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
decoded.into_owned()
}
fn decode_title(bytes: &[u8]) -> String {
let end = bytes
.iter()
.rposition(|b| *b != 0 && *b != 0xCD)
.map(|idx| idx + 1)
.unwrap_or(0);
decode_cp1251(&bytes[..end]).trim().to_string()
}
fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
let end = offset.checked_add(4)?;
let chunk = bytes.get(offset..end)?;
Some(u32::from_le_bytes(chunk.try_into().ok()?))
}
fn read_i32(bytes: &[u8], offset: usize) -> Option<i32> {
read_u32(bytes, offset).map(|v| v as i32)
}
fn read_f32(bytes: &[u8], offset: usize) -> Option<f32> {
let end = offset.checked_add(4)?;
let chunk = bytes.get(offset..end)?;
Some(f32::from_le_bytes(chunk.try_into().ok()?))
}
fn read_vec3(bytes: &[u8], offset: usize) -> Option<[f32; 3]> {
Some([
read_f32(bytes, offset)?,
read_f32(bytes, offset + 4)?,
read_f32(bytes, offset + 8)?,
])
}
#[cfg(test)]
mod tests {
use super::*;
use common::collect_files_recursive;
use std::path::{Path, PathBuf};
fn game_root() -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata")
.join("Parkan - Iron Strategy");
root.is_dir().then_some(root)
}
#[test]
fn parses_known_mission_footer_and_objects() {
let Some(root) = game_root() else {
eprintln!("skipping: game root is missing");
return;
};
let path = root
.join("MISSIONS")
.join("CAMPAIGN")
.join("CAMPAIGN.00")
.join("Mission.01")
.join("data.tma");
if !path.is_file() {
eprintln!("skipping: sample mission is missing ({})", path.display());
return;
}
let mission = parse_path(&path).expect("parse mission failed");
assert_eq!(mission.footer.version, 1);
assert!(
mission
.footer
.map_path
.eq_ignore_ascii_case("DATA\\MAPS\\Tut_1\\land"),
"unexpected map path: {}",
mission.footer.map_path
);
assert!(mission.objects.len() >= 20);
assert!(mission
.objects
.iter()
.any(|obj| obj.resource_name.eq_ignore_ascii_case("s_tree_04")));
assert!(mission.objects.iter().any(|obj| {
obj.resource_name
.eq_ignore_ascii_case("UNITS\\UNITS\\HERO\\tut1_p.dat")
}));
}
#[test]
fn parses_all_retail_missions() {
let Some(root) = game_root() else {
eprintln!("skipping: game root is missing");
return;
};
let mission_root = root.join("MISSIONS");
let mut files = Vec::new();
collect_files_recursive(&mission_root, &mut files);
files.sort();
let mut mission_count = 0usize;
for path in files {
if !path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.eq_ignore_ascii_case("data.tma"))
{
continue;
}
mission_count += 1;
let mission = parse_path(&path)
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
assert!(
mission
.footer
.map_path
.to_ascii_uppercase()
.contains("DATA\\MAPS\\"),
"{}: invalid map path '{}'",
path.display(),
mission.footer.map_path
);
assert!(
!mission.objects.is_empty(),
"{}: mission has no parsed object records",
path.display()
);
assert!(
mission
.objects
.iter()
.all(|obj| obj.position.iter().all(|v| v.is_finite())),
"{}: mission has non-finite position",
path.display()
);
}
assert!(mission_count > 0, "no data.tma files found");
}
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "unitdat"
version = "0.1.0"
edition = "2021"
[dependencies]
encoding_rs = "0.8"
[dev-dependencies]
common = { path = "../common" }
+180
View File
@@ -0,0 +1,180 @@
use encoding_rs::WINDOWS_1251;
use std::fmt;
use std::fs;
use std::path::Path;
const MIN_SIZE: usize = 0x48;
const MAGIC: u32 = 0x0000_F0F1;
pub type Result<T> = core::result::Result<T, Error>;
#[derive(Debug)]
pub enum Error {
Io(std::io::Error),
TooSmall { got: usize },
InvalidMagic { got: u32 },
MissingArchiveName,
MissingModelKey,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(err) => write!(f, "{err}"),
Self::TooSmall { got } => write!(f, "unit .dat is too small: {got} bytes"),
Self::InvalidMagic { got } => write!(f, "invalid .dat magic: 0x{got:08X}"),
Self::MissingArchiveName => write!(f, "unit .dat has empty archive name"),
Self::MissingModelKey => write!(f, "unit .dat has empty model key"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
_ => None,
}
}
}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
#[derive(Clone, Debug)]
pub struct UnitDat {
pub magic: u32,
pub flags: u32,
pub archive_name: String,
pub model_key: String,
}
pub fn parse_path(path: impl AsRef<Path>) -> Result<UnitDat> {
let bytes = fs::read(path.as_ref())?;
parse_bytes(&bytes)
}
pub fn parse_bytes(bytes: &[u8]) -> Result<UnitDat> {
if bytes.len() < MIN_SIZE {
return Err(Error::TooSmall { got: bytes.len() });
}
let magic = read_u32(bytes, 0).ok_or(Error::TooSmall { got: bytes.len() })?;
if magic != MAGIC {
return Err(Error::InvalidMagic { got: magic });
}
let flags = read_u32(bytes, 4).ok_or(Error::TooSmall { got: bytes.len() })?;
let archive_name = decode_c_string_fixed(&bytes[0x08..0x28]);
if archive_name.is_empty() {
return Err(Error::MissingArchiveName);
}
let model_key = decode_c_string_fixed(&bytes[0x28..0x48]);
if model_key.is_empty() {
return Err(Error::MissingModelKey);
}
Ok(UnitDat {
magic,
flags,
archive_name,
model_key,
})
}
fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
let end = offset.checked_add(4)?;
let chunk = bytes.get(offset..end)?;
Some(u32::from_le_bytes(chunk.try_into().ok()?))
}
fn decode_c_string_fixed(bytes: &[u8]) -> String {
let used = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
let (decoded, _, _) = WINDOWS_1251.decode(&bytes[..used]);
decoded.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use common::collect_files_recursive;
use std::path::{Path, PathBuf};
fn game_root() -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata")
.join("Parkan - Iron Strategy");
root.is_dir().then_some(root)
}
#[test]
fn parses_known_dat_files() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let samples = [
root.join("UNITS/UNITS/HERO/tut1_p.dat"),
root.join("UNITS/UNITS/BATTLE/l_targ.dat"),
root.join("UNITS/BUILDS/BRIDGE/m_bridge.dat"),
];
for path in samples {
if !path.is_file() {
eprintln!("skipping missing sample {}", path.display());
continue;
}
let dat = parse_path(&path)
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
assert_eq!(dat.magic, MAGIC);
assert!(dat.archive_name.to_ascii_lowercase().ends_with(".rlb"));
assert!(dat.model_key.contains('_'));
}
}
#[test]
fn parses_retail_dat_corpus() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let units_root = root.join("UNITS");
let mut files = Vec::new();
collect_files_recursive(&units_root, &mut files);
files.sort();
let mut parsed = 0usize;
for path in files {
if !path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("dat"))
{
continue;
}
let dat = parse_path(&path)
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
assert!(
!dat.archive_name.is_empty(),
"{} empty archive",
path.display()
);
assert!(
!dat.model_key.is_empty(),
"{} empty model key",
path.display()
);
parsed += 1;
}
assert!(parsed > 0, "no .dat files parsed");
}
}
+200
View File
@@ -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.
+120
View File
@@ -0,0 +1,120 @@
# Границы знания
Этот раздел перечисляет области, где контракт ещё не закрыт полностью. Они не
мешают безопасному чтению и 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 и версии
инструментов.
## Closure criteria
Вопрос считается закрытым только при наличии build fingerprint, raw trace,
parser trace-а, минимального воспроизводимого input/resource/save/message,
формального контракта или явно ограниченной гипотезы, differential test для
изменённых DLL, обновления тематической главы и regression case, запускаемого
без ручного анализа.
+46 -12
View File
@@ -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. Если нужна реализация совместимого движка, читайте тома III--VII линейно:
docs/ ресурсы, миссии, мир, рендер, интерактивные подсистемы и порядок работ.
index.md # The documentation homepage.
... # Other markdown pages, images and other files. Если вы проверяете выводы, переходите к тому 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
сохранение.
+69
View File
@@ -0,0 +1,69 @@
# WEAR и MAT0
MSH batch хранит только `material_index`. WEAR переводит этот индекс в имя
материала, а MAT0 по этому имени описывает phases, parameters и texture
references.
```text
Batch20.material_index
-> WEAR row
-> MAT0 entry
-> active phase
-> textureName
```
## WEAR
WEAR -- текстовый ресурс type ID `0x52414557`, обычно `*.wea` рядом с моделью.
```text
<wearCount>
<legacyId> <materialName>
...
[empty line]
[LIGHTMAPS
<lightmapCount>
<legacyId> <lightmapName>
...]
```
`legacyId` сохраняется, но выбор выполняется по позиции строки и имени. Между
основной таблицей и `LIGHTMAPS` нужен пустой разделитель.
## MAT0
MAT0 имеет type ID `0x3054414D`, обычно расположен в `Material.lib`. `attr1`
содержит runtime flags, `attr2` -- версию payload.
```c
#pragma pack(push, 1)
struct Mat0PrefixV4Plus {
uint16_t phase_count;
uint16_t animation_block_count;
uint8_t metadata_a;
uint8_t metadata_b;
uint32_t metadata_c_raw;
uint32_t metadata_d_raw;
};
struct Phase34 {
uint8_t parameters[18];
char texture_name[16];
};
#pragma pack(pop)
```
Versioned fields читаются только если версия их содержит. Для старых версий
используются runtime defaults, а raw values сохраняются.
## Fallback
Material resolve:
1. имя из WEAR;
2. `DEFAULT`;
3. entry с индексом 0.
Пустое texture name означает намеренно нетекстурированную поверхность. Lightmap
fallback отдельный: отсутствующий lightmap даёт slot `-1`.
+82
View File
@@ -0,0 +1,82 @@
# MSH
Файл `*.msh` является NRes-контейнером. Geometry, узлы, slots, batches,
animation и служебные streams лежат в entries с разными `type_id`.
## Entry map
```text
type 1 nodes and slot selection
type 2 header 0x8C + Slot68 records
type 3 positions float3
type 4 packed normals
type 5 packed UV0
type 6 index buffer u16
type 7 triangle descriptors
type 8 animation keys
type 9 service stream
type 10 strings and node names
type 13 Batch20 records
type 15 auxiliary stream
type 17 auxiliary data
type 18 rare stream
type 19 animation frame map
type 20 rare auxiliary table
```
Reader ищет entries по type, но сохраняет исходный порядок для roundtrip.
## Node and slot selection
Type 1 обычно состоит из records по 38 bytes:
```c
struct Node38 {
uint16_t hdr0;
uint16_t parent_or_link;
uint16_t anim_map_start;
uint16_t fallback_key;
uint16_t slot_index[15];
};
```
`slot_index[lod * 5 + group]` выбирает geometry slot. `0xFFFF` означает
отсутствие геометрии для комбинации LOD/group.
## Slot and batch
Type 2 содержит header `0x8C`, затем `Slot68`:
```c
struct Slot68 {
uint16_t tri_start;
uint16_t tri_count;
uint16_t batch_start;
uint16_t batch_count;
float aabb_min[3];
float aabb_max[3];
float sphere_center[3];
float sphere_radius;
uint32_t opaque[5];
};
```
Type 13 задаёт draw ranges:
```c
#pragma pack(push, 1)
struct Batch20 {
uint16_t batch_flags;
uint16_t material_index;
uint16_t opaque4;
uint16_t opaque6;
uint16_t index_count;
uint32_t index_start;
uint16_t opaque14;
uint32_t base_vertex;
};
#pragma pack(pop)
```
Index check выполняется как `base_vertex + index < vertex_count` для всего
используемого slice.
+61
View File
@@ -0,0 +1,61 @@
# NRes
`NRes` -- основной контейнер ресурсов Iron3D. Он используется как внешний
архив и как внутренний контейнер модели `*.msh`.
```text
[Header: 16 bytes]
[Data region: payload with alignment]
[Directory: entry_count * 64 bytes]
```
## Header
```c
struct NResHeader16 {
char magic[4]; // "NRes"
uint32_t version; // 0x00000100
int32_t entry_count; // >= 0
uint32_t total_size; // equals file size
};
```
`directory_offset = total_size - entry_count * 64`. Reader проверяет отсутствие
переполнений, `directory_offset >= 16` и точное окончание каталога на
`total_size`.
## Entry
```c
#pragma pack(push, 1)
struct NResEntry64 {
uint32_t type_id;
uint32_t attr1;
uint32_t attr2;
uint32_t size;
uint32_t attr3;
char name[36];
uint32_t data_offset;
uint32_t sort_index;
};
#pragma pack(pop)
```
Имя содержит bounded C-string до 35 полезных bytes. `sort_index` задаёт
отображение из sorted position в original entry index. В строгом режиме все
`sort_index` образуют перестановку `0..N-1`.
## Data region
Payload каждой записи лежит после header и до начала каталога. Игровые архивы
выравнивают следующий payload до 8 bytes нулями, но reader не должен требовать
плотного покрытия data region.
Различаются:
- active payload -- диапазон, на который указывает entry;
- gap/padding -- bytes между активными диапазонами;
- unindexed preserved region -- произвольные bytes, не принадлежащие entry.
Lossless editor сохраняет все три категории. Compact writer может исключить
unindexed regions только при явной операции repack.
+56
View File
@@ -0,0 +1,56 @@
# Render frame
Кадр является последней стадией цикла, а не самостоятельной функцией renderer-а.
До draw calls уже накоплен input, рассчитан tick, применены отложенные операции,
выбрана камера и обновлён 3D sound listener.
## Frame skeleton
```text
system messages and input
-> simulation calculation
-> deferred object operations
-> animation and transforms
-> camera and sound listener
-> visibility and render queues
-> materials and draw passes
-> renderer completion
-> end-of-render callbacks and UI
```
В `World3D::stdRenderGame` доказан крупный порядок: camera передаётся Terrain,
настраиваются viewport/matrices, вызываются renderer boundary slots,
устанавливается `in_render`, выполняется traversal мира, закрывается world/shade
pass, вызывается renderer completion, снимается `in_render`, рассылается
end-of-render.
## Draw item
Подготовленный draw item содержит:
- node world matrix;
- batch flags and index range;
- WEAR material handle;
- MAT0 active phase and coefficients;
- texture handle;
- optional lightmap handle;
- render phase and sorting key;
- legacy pipeline state.
Подготовленный item должен ссылаться на immutable данные кадра. Изменение phase
или texture cache посреди прохода не должно менять уже собранную очередь.
## Parity risks
- x87 precision and rounding;
- scalar/SIMD `g_FastProc` differences;
- object, batch and transparent primitive order;
- depth, cull, alpha test and blend transitions;
- mip-skip, palette and Page coordinates;
- material fallback and phase selection;
- RNG sequence for FX and atmosphere;
- device capability fallback;
- simulation time quantization.
Для отладки нужен deterministic frame capture: camera state, visible object IDs,
draw-item list, pipeline keys, matrices и hashes промежуточных buffers.
+69
View File
@@ -0,0 +1,69 @@
# RsLi
`RsLi` -- библиотечный архив Iron3D с каталогом в начале файла и payloads после
него.
```text
[Header: 32 bytes]
[Entry table: entry_count * 32 bytes]
[Payloads]
[optional trailer]
```
## Header fields
```text
+0x00 char[2] "NL"
+0x02 u8 reserved
+0x03 u8 version = 1
+0x04 i16 entry_count
+0x0E u16 presorted_flag = 0xABBA
+0x14 u32 xor_seed
```
Остальные bytes сохраняются без нормализации.
## Entry
```c
struct RsLiEntry32 {
char name[12];
uint8_t service[4];
int16_t flags;
int16_t sort_to_original;
uint32_t unpacked_size;
uint32_t data_offset_raw;
uint32_t packed_size;
};
```
Имя обычно хранится в uppercase ASCII. `sort_to_original` связывает sorted
position с исходной записью.
## Table transform
Entry table проходит обратимое потоковое XOR-преобразование. Начальное
состояние берётся из младших 16 bits `xor_seed` и продолжается через всю
таблицу, не сбрасываясь на границе записи.
## Storage methods
```text
0x000 raw block
0x020 byte transform only
0x040 LZSS
0x060 transform + LZSS
0x080 adaptive Huffman + LZSS
0x0A0 transform + adaptive Huffman + LZSS
0x100 raw Deflate
```
После любого пути должно получиться ровно `unpacked_size` bytes. Методы
`0x080` и `0x0A0` подтверждены decoder-кодом, но не живыми payload демоверсии
или обеих частей.
## Compatibility quirk
`sprites.lib::INTERF8.TEX` объявляет Deflate range на один byte дальше EOF.
Совместимый reader допускает `packed_size - 1` только для этого именованного
случая. Строгий режим сообщает `deflate_eof_plus_one`.
+67
View File
@@ -0,0 +1,67 @@
# Texm
`Texm` -- основной формат изображений Iron3D. Payload содержит header,
необязательную палитру, mip chain и иногда `Page` chunk.
```c
struct TexmHeader32 {
uint32_t magic; // 'Texm'
uint32_t width;
uint32_t height;
uint32_t mip_count;
uint32_t flags4;
uint32_t flags5;
uint32_t unknown6;
uint32_t format;
};
```
## Pixel formats
```text
0 Indexed8 + palette 256 * 4 bytes
565 R5 G6 B5
556 R5 G5 B6
4444 A4 R4 G4 B4
88 L8 A8
888 RGB8 in four-byte element
8888 A8 R8 G8 B8
```
Короткие каналы расширяются до 8 bits повторением значимых bits. Для 888
служебный четвёртый byte сохраняется при roundtrip.
## Layout
```text
TexmHeader32
[palette 1024 bytes, only for format 0]
level 0 pixels
level 1 pixels
...
level mip_count-1 pixels
[optional Page chunk]
```
Размер mip level вычисляется через `max(1, width >> i)` и
`max(1, height >> i)`. Parser суммирует размеры с проверкой переполнения до
чтения данных.
## Page chunk
```c
struct PageHeader8 {
uint32_t magic; // 'Page'
uint32_t rect_count;
};
struct PageRect8 {
int16_t x;
int16_t width;
int16_t y;
int16_t height;
};
```
Chunk обязан иметь размер `8 + rect_count * 8`. Rectangles находятся в pixel
space базового mip и масштабируются после mip-skip.
+64
View File
@@ -0,0 +1,64 @@
# TMA
`data.tma` -- основное описание расстановки и логической конфигурации миссии.
Файл перечисляет paths, clans, objects, свойства, ссылку на ландшафт и extras.
## String primitive
```c
struct LpString {
uint32_t byte_length;
uint8_t bytes[byte_length];
};
```
Reader продвигается ровно на `4 + byte_length`. Завершающий NUL не является
обязательной частью framing. Для человекочитаемого вида используется legacy
ANSI/CP1251 view, но исходные bytes сохраняются.
## Top level
```text
u32 format_version
u32 path_count
PathRecord paths[path_count]
u32 clan_section_version
u32 clan_count
ClanRecord clans[clan_count]
u32 object_section_version
u32 object_count
PlacedObject objects[object_count]
LpString land_path
u32 mission_flag
LpString description_raw
u32 extra_section_version
u32 extra_count
ExtraRecord28 extras[extra_count]
```
Все 60 TMA Частей 1 и 2 проходят parser до точного EOF. Версии стабильны:
верхний уровень `1`, clan section `6`, object section `10`, property schema
`1`, trailing section `1`.
## PlacedObject
```text
u32 raw_kind
u32 class_or_flags
LpString resource_name
u32 raw_after_resource
u32 identity_or_clan_raw
f32 position[3]
f32 orientation[3]
f32 scale[3]
LpString instance_name
u32 raw_after_name
i32 link0
i32 link1
u32 property_schema_version
u32 property_count
Property properties[property_count]
```
`Property` состоит из четырёх raw `u32` и имени. Typed views разрешены только
для доказанных property names и consumers.
-5
View File
@@ -1,5 +0,0 @@
# AI system
Документ описывает подсистему искусственного интеллекта: принятие решений, pathfinding и стратегическое поведение противников.
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ai.dll`.
-5
View File
@@ -1,5 +0,0 @@
# ArealMap
Документ описывает формат и структуру карты мира: зоны/сектора, координаты, размещение объектов и связь с terrain и миссиями.
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ArealMap.dll`.
-5
View File
@@ -1,5 +0,0 @@
# Behavior system
Документ описывает поведенческую логику юнитов: state machine/behavior-паттерны, взаимодействия и базовые правила боевого поведения.
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Behavior.dll`.
-5
View File
@@ -1,5 +0,0 @@
# Control system
Документ описывает подсистему управления: mapping ввода (клавиатура, мышь, геймпад), обработку событий и буферизацию команд.
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Control.dll`.
-834
View File
@@ -1,834 +0,0 @@
# FXID
Документ фиксирует спецификацию ресурса эффекта `FXID` на уровне, достаточном для:
- 1:1 загрузки и исполнения в совместимом runtime;
- построения валидатора payload;
- создания lossless-конвертера (`binary -> IR -> binary`);
- создания редактора с безопасным редактированием полей.
Связанный контейнер: [NRes / RsLi](nres.md).
---
## 1. Источники и статус восстановления
Спецификация восстановлена по:
- `tmp/disassembler1/Effect.dll.c`;
- `tmp/disassembler2/Effect.dll.asm`;
- интеграционным вызовам из `tmp/disassembler1/Terrain.dll.c`;
- проверке реальных архивов `testdata/nres`.
Ключевые функции:
- parser FXID: `Effect.dll!sub_10007650`;
- runtime loop: `sub_10003D30(case 28)`, `sub_10006170`, `sub_10008120`, `sub_10007D10`;
- alpha/time: `sub_10005C60`;
- exports: `CreateFxManager`, `InitializeSettings`.
Проверка по данным:
- `923/923` FXID payload валидны в `testdata/nres`.
---
## 2. Контейнер и runtime API
### 2.1. NRes entry
FXID хранится как NRes-entry:
- `type_id = 0x44495846` (`"FXID"`).
Наблюдение по датасету (923 эффекта):
- `attr1 = 0`, `attr2 = 0`, `attr3 = 1`.
### 2.2. Export API `Effect.dll`
Экспортируются:
- `CreateFxManager(int a1, int a2, int owner)`;
- `InitializeSettings()`.
`CreateFxManager` создаёт manager-объект (`0xB8` байт), инициализирует через `sub_10003AE0`, возвращает интерфейсный указатель (`base + 4`).
### 2.3. Интерфейс менеджера
Рабочая vtable (`off_1001E478`):
| Смещение | Функция | Назначение |
|---|---|---|
| +0x08 | `sub_10003D30` | Event dispatcher (`4/20/23/24/28`) |
| +0x10 | `sub_10004320` | Открыть/закэшировать FX resource |
| +0x14 | `sub_10004590` | Создать runtime instance |
| +0x18 | `sub_10004780` | Удалить instance |
| +0x1C | `sub_100047B0` | Установить time/interp mode |
| +0x20 | `sub_100047D0` | Установить scale |
| +0x24 | `sub_10004830` | Установить позицию |
| +0x28 | `sub_10004930` | Установить matrix transform |
| +0x2C | `sub_10004B00` | Restart/retime |
| +0x38 | `sub_10004BA0` | Duration modifier |
| +0x3C | `sub_10004BD0` | Start/Enable |
| +0x40 | `sub_10004C10` | Stop/Disable |
| +0x44 | `sub_10004C50` | Bind emitter/context |
| +0x48 | `sub_10004D50` | Сброс frame flags |
`Terrain.dll` использует `QueryInterface(id=19)` для получения рабочего интерфейса.
---
## 3. Бинарный формат FXID payload
Все значения little-endian.
### 3.1. Header (60 байт, `0x3C`)
```c
struct FxHeader60 {
uint32_t cmd_count; // 0x00
uint32_t time_mode; // 0x04
float duration_sec; // 0x08
float phase_jitter; // 0x0C
uint32_t flags; // 0x10
uint32_t settings_id; // 0x14
float rand_shift_x; // 0x18
float rand_shift_y; // 0x1C
float rand_shift_z; // 0x20
float pivot_x; // 0x24
float pivot_y; // 0x28
float pivot_z; // 0x2C
float scale_x; // 0x30
float scale_y; // 0x34
float scale_z; // 0x38
};
```
Командный поток начинается строго с `offset = 0x3C`.
### 3.2. Header-поля (подтвержденная семантика)
- `cmd_count`: число команд (engine итерирует ровно столько шагов).
- `time_mode`: базовый режим вычисления alpha/time (`sub_10005C60`).
- `duration_sec`: в runtime -> `duration_ms = duration_sec * 1000`.
- `phase_jitter`: используется при `flags & 0x1`.
- `flags`: runtime-gating/alpha/visibility (см. ниже).
- `settings_id`: в `sub_1000EC40` используется `settings_id & 0xFF`.
- `rand_shift_*`: используется при `flags & 0x8`.
- `pivot_*`: используется в ветках `sub_10007D10`.
- `scale_*`: копируется в runtime scale и влияет на матрицы.
### 3.3. `flags` (битовая карта)
| Бит | Маска | Наблюдаемое поведение |
|---|---:|---|
| 0 | `0x0001` | Random phase jitter (`phase_jitter`) |
| 3 | `0x0008` | Random positional shift (`rand_shift_*`) |
| 4 | `0x0010` | Visibility/occlusion ветки |
| 5 | `0x0020` | Triangular remap в `sub_10005C60` |
| 6 | `0x0040` | Инверсия начального active-state |
| 7 | `0x0080` | Day/night filter (ветка A) |
| 8 | `0x0100` | Day/night filter (ветка B, инверсия) |
| 9 | `0x0200` | Alpha *= normalized lifetime |
| 10 | `0x0400` | Установка manager bit1 (`+0xA0`) |
| 11 | `0x0800` | Изменение gating в `sub_10007D10` |
| 12 | `0x1000` | Установка manager-state bit `0x10` |
Нерасшифрованные биты должны сохраняться 1:1.
### 3.4. `time_mode` (`0..17`)
Обозначения (`sub_10005C60`):
- `t0 = instance.start_ms`, `t1 = instance.end_ms`;
- `tn = (now_ms - t0) / (t1 - t0)`;
- `prev = instance.cached_alpha` (`v4+52` в дизассембле).
Режимы:
- `0`: constant (`instance.alpha_const`, поле `v4+40`);
- `1`: `tn`;
- `2`: `fract(tn)`;
- `3`: `1 - tn`;
- `4`: external value из queue/world API (manager `+36`, id из `this+104[a2]`);
- `5`: `|param33.xyz| / |param17.vecA.xyz|`;
- `6`: `param33.x / param17.vecA.x`;
- `7`: `param33.y / param17.vecA.y`;
- `8`: `param33.z / param17.vecA.z`;
- `9`: `|param36.xyz| / |param17.vecB.xyz|`;
- `10`: `param36.x / param17.vecB.x`;
- `11`: `param36.y / param17.vecB.y`;
- `12`: `param36.z / param17.vecB.z`;
- `13`: `1 - external_resource_value`;
- `14`: `1 - queue_param(49)`;
- `15`: `max(norm(param33/vecA), norm(param36/vecB))`;
- `16`: external (`mode 4`) с нижним clamp к `prev` (`0` не зажимается);
- `17`: external (`mode 4`) с верхним clamp к `prev` (`1` не зажимается).
Post-обработка после mode:
- если `flags & 0x200`: `alpha *= tn`;
- если `flags & 0x20`: triangular remap (`alpha = (alpha < 0.5 ? alpha : 1-alpha) * 2`).
---
## 4. Командный поток
### 4.1. Общий формат команды
Каждая команда:
- `uint32 cmd_word`;
- далее body фиксированного размера по opcode.
`cmd_word`:
- `opcode = cmd_word & 0xFF`;
- `enabled = (cmd_word >> 8) & 1`;
- `bits 9..31` в датасете нулевые, но их надо сохранять 1:1.
Выравнивания между командами нет.
### 4.2. Размеры
| Opcode | Размер записи |
|---:|---:|
| 1 | 224 |
| 2 | 148 |
| 3 | 200 |
| 4 | 204 |
| 5 | 112 |
| 6 | 4 |
| 7 | 208 |
| 8 | 248 |
| 9 | 208 |
| 10 | 208 |
### 4.3. Opcode -> runtime-класс (vtable)
| Opcode | `new(size)` | vtable |
|---:|---:|---|
| 1 | `0xF0` | `off_1001E78C` |
| 2 | `0xA0` | `off_1001F048` |
| 3 | `0xFC` | `off_1001E770` |
| 4 | `0x104` | `off_1001E754` |
| 5 | `0x54` | `off_1001E360` |
| 6 | `0x1C` | `off_1001E738` |
| 7 | `0x48` | `off_1001E228` |
| 8 | `0xAC` | `off_1001E71C` |
| 9 | `0x100` | `off_1001E700` |
| 10 | `0x48` | `off_1001E24C` |
### 4.4. Общий вызовной контракт команды
После создания команды (`sub_10007650`):
1. `cmd->enabled = cmd_word.bit8`.
2. `cmd->Init(fx_queue, fx_instance)` (`vfunc +4`).
3. команда добавляется в список инстанса.
В runtime cycle:
- `vfunc +8`: update/compute (bool);
- `vfunc +12`: emission/render callback;
- `vfunc +20`: toggle active;
- `vfunc +16`/`+24`: служебные функции (зависят от opcode).
---
## 5. Загрузка FXID (engine-accurate)
`sub_10007650`:
```c
void FxLoad(FxInstance* fx, uint8_t* payload) {
FxHeader60* h = (FxHeader60*)payload;
fx->raw_header = h;
fx->mode = h->time_mode;
fx->end_ms = fx->start_ms + h->duration_sec * 1000.0f;
fx->scale = {h->scale_x, h->scale_y, h->scale_z};
fx->active_default = ((h->flags & 0x40) == 0);
uint8_t* ptr = payload + 0x3C;
for (uint32_t i = 0; i < h->cmd_count; ++i) {
uint32_t w = *(uint32_t*)ptr;
uint8_t op = (uint8_t)(w & 0xFF);
Command* cmd = CreateByOpcode(op, ptr); // может вернуть null
if (cmd) {
cmd->enabled = (w >> 8) & 1;
if (h->flags & 0x400) fx->manager_flags |= 0x0100;
if ((h->flags & 0x400) || cmd->enabled) fx->manager_flags |= 0x0010;
cmd->Init(fx->queue, fx);
fx->commands.push_back(cmd);
}
ptr += size_by_opcode(op); // без bounds checks в оригинале
}
}
```
Критичные edge-case оригинала:
- bounds checks отсутствуют;
- при unknown opcode `ptr` не двигается (`advance = 0`);
- при `new == null` команда пропускается, но `ptr` двигается.
Фактический `advance` в `sub_10007650` задан hardcoded в DWORD:
- `op1:+56`, `op2:+37`, `op3:+50`, `op4:+51`, `op5:+28`,
- `op6:+1`, `op7:+52`, `op8:+62`, `op9:+52`, `op10:+52`,
- `default:+0`.
---
## 6. Runtime lifecycle
- `sub_10007470`: ctor instance.
- `sub_10003D30(case 28)`: per-frame update manager.
- `sub_10006170`: gate + alpha/time + command updates.
- `sub_10008120` / `sub_10007D10`: update/render branches.
- Start/Stop: `sub_10004BD0` / `sub_10004C10`.
Event-codes `sub_10003D30`:
- `4`: bootstrap/time init;
- `20`: range-removal + index repair;
- `23`: set manager bit0;
- `24`: clear manager bit0;
- `28`: main tick.
---
## 7. Общий тип `ResourceRef64`
Для opcode `2/3/4/5/7/8/9/10` присутствует ссылка вида:
```c
struct ResourceRef64 {
char archive[32]; // null-terminated ASCII, case-insensitive compare
char name[32]; // null-terminated ASCII
};
```
Поведение loader'а:
- оба имени обязаны быть непустыми;
- кэширование по `(_strcmpi archive, _strcmpi name)`;
- загрузка/резолв через manager resource API.
Наблюдение по данным:
- для `opcode 2`: обычно `sounds.lib` + `*.wav`;
- для остальных: обычно `material.lib` + material name.
---
## 8. Полная карта body по opcode (field-level)
Смещения указаны от начала команды (включая `cmd_word`).
### 8.1. Opcode 1 (`off_1001E78C`, size=224)
Основные методы:
- init: `sub_1000F4B0`;
- update: `sub_1000F6E0`;
- emit: `nullsub_2`;
- toggle: `sub_1000F490`.
```c
struct FxCmd01 {
uint32_t word; // +0
uint32_t mode; // +4 (enum, см. ниже)
float t_start; // +8
float t_end; // +12
float p0_min[3]; // +16..24
float p0_max[3]; // +28..36
float p1_min[3]; // +40..48
float p1_max[3]; // +52..60
float q0_min[4]; // +64..76
float q0_max[4]; // +80..92
float q0_rand_span[4]; // +96..108 (все 4 читаются в sub_1000F6E0)
float scalar_min; // +112
float scalar_max; // +116
float scalar_rand_amp; // +120
float color_rgb[3]; // +124..132 (вызов manager+16)
float opaque_tail6[6]; // +136..156 (сохранять 1:1; в датасете почти всегда 0)
char opt_archive[32]; // +160..191 (редко, напр. "material.lib")
char opt_name[32]; // +192..223 (редко, напр. "light_w")
};
```
Замечания по полям op1:
- `+108` не резерв: участвует в random-выборке как 4-я компонента блока `+96..108`;
- `+136..156` не читается vtable-методами класса `off_1001E78C` в `Effect.dll` (init/update/toggle/accessor), но должно сохраняться 1:1;
- редкий кейс с ненулевыми `+136..156` и строками `+160/+192` зафиксирован в `effects.rlb:r_lightray_w`.
`mode` (`+4`) -> параметры вызова manager (`sub_1000F4B0`):
- `1 -> create_kind=1, flags=0x80000000`;
- `2/5 -> create_kind=1, flags=0x00000000`;
- `3 -> create_kind=3, flags=0x00000000`;
- `4 -> create_kind=4, flags=0x00000000`;
- `6 -> create_kind=1, flags=0xA0000000`;
- `7 -> create_kind=1, flags=0x20000000`.
### 8.2. Opcode 2 (`off_1001F048`, size=148)
Основные методы:
- init: `sub_10012D10`;
- update: `sub_10012EB0`;
- emit: `nullsub_2`;
- toggle: `sub_10013170`.
```c
struct FxCmd02 {
uint32_t word; // +0
uint32_t mode; // +4 (0..3; влияет на sub_100065A0 mapping)
float t_start; // +8
float t_end; // +12
float a_min[3]; // +16..24
float a_max[3]; // +28..36
float b_min[3]; // +40..48
float b_max[3]; // +52..60
float c0_base; // +64
float c1_base; // +68
float c2_base; // +72
float c2_max; // +76
uint32_t param_910; // +80 (передаётся в manager cmd=910)
ResourceRef64 ref; // +84..147 (обычно sounds.lib + wav)
};
```
`mode` -> внутренний map в `sub_100065A0`:
- `0 -> 0`, `1 -> 512`, `2 -> 2`, `3 -> 514`.
### 8.3. Opcode 3 (`off_1001E770`, size=200)
Методы:
- init: `sub_100103B0`;
- update: `sub_100105F0`;
- emit: `sub_100106C0`.
```c
struct FxCmd03 {
uint32_t word; // +0
uint32_t mode; // +4
float alpha_source; // +8 (>=0: norm time, <0: global time)
float alpha_pow_a; // +12
float alpha_pow_b; // +16
float out_min; // +20
float out_max; // +24
float out_pow; // +28
float active_t0; // +32
float active_t1; // +36
float v0_min[3]; // +40..48
float v0_max[3]; // +52..60
float pow0[3]; // +64..72
float v1_min[3]; // +76..84
float v1_max[3]; // +88..96
float v2_min[3]; // +100..108
float v2_max[3]; // +112..120
float pow1[3]; // +124..132
ResourceRef64 ref; // +136..199
};
```
### 8.4. Opcode 4 (`off_1001E754`, size=204)
Layout как opcode 3 + последний коэффициент:
```c
struct FxCmd04 {
FxCmd03 base; // +0..199
float dist_norm_inv_base; // +200 (используется в sub_100108C0/100109B0)
};
```
`sub_100108C0`: `obj->inv = 1.0 / raw[200]`.
### 8.5. Opcode 5 (`off_1001E360`, size=112)
Методы:
- init: `sub_100028A0`;
- update: `sub_10002A20`;
- emit: `sub_10002BE0`;
- context update: `sub_10003070`.
```c
struct FxCmd05 {
uint32_t word; // +0
uint32_t mode; // +4 (в данных обычно 1)
uint32_t unused_08; // +8 (в текущем коде opcode5 не читается)
uint32_t unused_0C; // +12 (в текущем коде opcode5 не читается)
float active_t0; // +16
uint32_t max_segments; // +20
float active_t1_min; // +24
float active_t1_max; // +28
float step_norm; // +32
float segment_len; // +36
float alpha_source; // +40 (>=0 norm, <0 random)
float alpha_pow; // +44
ResourceRef64 ref; // +48..111
};
```
### 8.6. Opcode 6 (`off_1001E738`, size=4)
Только `cmd_word`:
```c
struct FxCmd06 {
uint32_t word; // +0
};
```
`init/update/emit` фактически no-op (`sub_100030B0` возвращает `0`).
### 8.7. Opcode 7 (`off_1001E228`, size=208)
Методы:
- init: `sub_10001720`;
- update: `sub_10001230`;
- emit: `sub_10001300`;
- element accessor: `sub_10002780`.
```c
struct FxCmd07 {
uint32_t word; // +0
uint32_t mode; // +4
float eval_min; // +8
float eval_max; // +12
float eval_pow; // +16
float active_t0; // +20
float active_t1; // +24
float phase_span; // +28
float phase_rate; // +32
uint32_t count_a; // +36
uint32_t count_b; // +40
float set0_min[3]; // +44..52
float set0_max[3]; // +56..64
float set0_rand[3]; // +68..76
float set0_pow[3]; // +80..88
float set1_min[3]; // +92..100
float set1_max[3]; // +104..112
float set1_rand[3]; // +116..124
float set1_pow[3]; // +128..136
float gravity_or_drag_k; // +140
ResourceRef64 ref; // +144..207
};
```
### 8.8. Opcode 8 (`off_1001E71C`, size=248)
Методы:
- init: `sub_10011230`;
- update: `sub_100115C0`;
- emit: `sub_10012030`.
```c
struct FxCmd08 {
uint32_t word; // +0
uint32_t mode; // +4
float eval_t0; // +8
float eval_t1; // +12
float gate_t0; // +16
float gate_t1; // +20
float period_min; // +24
float period_max; // +28
float phase_pow; // +32
uint32_t slots; // +36
float set0_min[3]; // +40..48
float set0_max[3]; // +52..60
float set0_rand[3]; // +64..72
float set1_min[3]; // +76..84
float set1_max[3]; // +88..96
float set1_rand[3]; // +100..108
float set2_rand[3]; // +112..120
float set2_pow[3]; // +124..132
float rmax_set0[3]; // +136..144 (bound/radius calc)
float rmax_set1[3]; // +148..156 (bound/radius calc)
float rmax_set2[3]; // +160..168 (bound/radius calc)
float render_pow[3]; // +172..180
ResourceRef64 ref; // +184..247
};
```
### 8.9. Opcode 9 (`off_1001E700`, size=208)
Layout как opcode 3 с двумя final-полями:
```c
struct FxCmd09 {
FxCmd03 base; // +0..199
uint32_t render_kind; // +200 (0/1/2 -> 3/5/6 in sub_100138C0)
uint32_t render_flag; // +204 (0 -> добавляет bit 0x08000000)
};
```
Методы:
- init/update как у opcode 3 (`sub_100103B0`, `sub_100105F0`);
- emit: `sub_100138C0` -> формирует код рендера и вызывает `sub_100106C0`.
### 8.10. Opcode 10 (`off_1001E24C`, size=208)
Body-layout совпадает с opcode 7 (`FxCmd07`), но другой runtime класс.
- init: `sub_10001A40`;
- update: `sub_10001230`;
- emit: `sub_10001300`;
- element accessor: `sub_10002830`.
Наблюдение по данным:
- `mode` (`+4`) встречается как `16` или `32`.
---
## 9. Runtime-специфика по opcode (важные отличия)
### 9.1. Opcode 1
- создаёт handle через manager (`vfunc +48`);
- задаёт флаги handle (`vfunc +52`);
- в update пушит:
- позиционный вектор 1 (`vfunc +32`),
- позиционный вектор 2 (`vfunc +36`),
- 4-компонентный параметр (`vfunc +12`),
- scalar+rgb (`vfunc +16`).
### 9.2. Opcode 2
- `ResourceRef64` резолвится через `sub_100065A0` (режим-зависимая загрузка, в данных обычно `sounds.lib`/`wav`);
- использует manager-команду id `910`.
### 9.3. Opcode 3/4/9
- общий core-emitter в `sub_100106C0`;
- opcode 4 добавляет нормализацию по `raw+200`;
- opcode 9 добавляет переключение render-кода (`raw+200/+204`).
### 9.4. Opcode 5
- держит массив внутренних сегментов (`332` байта/элемент, ctor `sub_100099F0`);
- context-matrix приходит через `vfunc +24` (`sub_10003070`).
### 9.5. Opcode 7/10
- общий update/render (`sub_10001230`, `sub_10001300`);
- разные внутренние element-форматы:
- opcode 7: `204` байта/элемент (`sub_100092D0`),
- opcode 10: `492` байта/элемент (`sub_1000BB40`).
### 9.6. Opcode 8
- самый тяжёлый спавнер, хранит ring/slot-структуры;
- emit фаза (`sub_10012030`) использует `mode`, `render_pow`, per-slot transforms.
---
## 10. Спецификация инструментов
### 10.1. Reader (strict)
Алгоритм:
1. `len(payload) >= 60`;
2. читаем `cmd_count`;
3. `ptr = 0x3C`;
4. цикл `cmd_count`:
- `ptr + 4 <= len`;
- `opcode in 1..10`;
- `ptr + size(opcode) <= len`;
- `ptr += size(opcode)`;
5. strict-tail: `ptr == len(payload)`.
### 10.2. Reader (engine-compatible)
Legacy-режим (опасный, только при необходимости byte-совместимости):
- без bounds-check;
- tolerant к unknown opcode как в оригинале.
### 10.3. Writer (canonical)
1. записать `FxHeader60`;
2. `cmd_count = commands.len()`;
3. команды сериализуются как `cmd_word + fixed-body`;
4. размер payload: `0x3C + sum(size(op_i))`;
5. без хвостовых байт.
### 10.4. Editor (lossless)
Правила:
- все поля little-endian;
- не менять fixed size команды;
- не добавлять padding;
- сохранять неизвестные биты (`cmd_word`, `header.flags`) copy-through;
- для частично-известных полей поддерживать режим `opaque`.
### 10.5. IR/JSON (рекомендуемая форма)
```json
{
"header": {
"time_mode": 1,
"duration_sec": 2.5,
"phase_jitter": 0.2,
"flags": 22,
"settings_id": 785,
"rand_shift": [0.0, 0.0, 0.0],
"pivot": [0.0, 0.0, 0.0],
"scale": [1.0, 1.0, 1.0]
},
"commands": [
{
"opcode": 8,
"word_raw": 264,
"enabled": 1,
"fields": {
"mode": 1065353216,
"eval_t0": 0.0,
"eval_t1": 1.0,
"resource": {"archive": "material.lib", "name": "fire_smoke"}
},
"opaque_extra_hex": "..."
}
]
}
```
---
## 11. Проверка на реальных данных
`testdata/nres`:
- FXID payload: `923`;
- валидация parser'а: `923/923 valid`.
Распределение opcode:
- `1: 618`
- `2: 517`
- `3: 1545`
- `4: 202`
- `5: 31`
- `6: 0` (в датасете не встречен, но поддержан)
- `7: 1161`
- `8: 237`
- `9: 266`
- `10: 160`
Подтверждённые `ResourceRef64` оффсеты:
- op2 `+84`, op3/4/9 `+136`, op5 `+48`, op7/10 `+144`, op8 `+184`.
Для op1 найден редкий расширенный хвост (`+160/+192`) в `effects.rlb:r_lightray_w`:
- `material.lib` / `light_w`.
---
## 12. Практический чек-лист 1:1
Для runtime-порта:
- реализовать `FxHeader60` и parser `sub_10007650`;
- реализовать opcode-классы с методами как в vtable;
- учитывать start/stop/restart контракт manager API;
- воспроизвести `sub_10005C60` + post-flags (`0x20`, `0x200`);
- воспроизвести event loop `sub_10003D30(case 28)`.
Для toolchain:
- strict validator по разделу 10.1;
- canonical writer по разделу 10.3;
- field-aware editor + opaque fallback для неизвестных зон.
---
## 13. Что считать «полной» совместимостью
Практический критерий завершения:
1. Парсер и writer дают byte-identical round-trip для всех 923 FXID.
2. Runtime-порт выдаёт совпадающие state transitions на одинаковом `dt/seed` (по ключевым полям instance + command state).
3. Все opcode `1..10` поддержаны (включая `6`, даже если отсутствует в текущем датасете).
4. `ResourceRef64` и mode-ветки (`op1`, `op2`, `op9`) совпадают с оригиналом.
Эта страница покрывает весь наблюдаемый контракт формата/рантайма и полную карту body-полей по всем opcode.
---
## 14. Что осталось до «абсолютных 100%»
Для практического 1:1 (парсер/writer/runtime на известном контенте) покрытие уже достаточно.
Для «абсолютных 100%» на любых входах и во всех краевых режимах остаются 3 пункта:
1. FP-детерминизм: оригинал опирается на x87-style вычисления; SSE/fast-math могут давать расхождения в alpha/таймингах.
2. RNG parity: используется `sub_10002220` (16-bit генератор) и глобальные seed-состояния; для bit-exact воспроизведения нужны контрольные трассы оригинала.
3. Редкие ветки данных: в текущем датасете нет opcode `6`, и почти не встречаются хвосты op1 (`+136..223`); для исчерпывающей валидации нужны дополнительные FXID-образцы.
Что нужно собрать, чтобы закрыть это полностью:
- frame-by-frame dump из оригинального runtime (alpha, manager flags, per-command state);
- контрольные прогоны при фиксированном `dt` и seed;
- минимум по одному ресурсу на каждую редкую ветку (`op6`, op1-tail с ненулевыми `+136..223`).
-874
View File
@@ -1,874 +0,0 @@
# Materials, WEAR, MAT0 и Texm
Документ описывает материальную подсистему движка (World3D/Ngi32) на уровне, достаточном для:
- реализации runtime 1:1;
- создания инструментов чтения/валидации;
- создания инструментов конвертации и редактирования с lossless round-trip.
Источник: дизассемблированные `tmp/disassembler1/*.c` и `tmp/disassembler2/*.asm`, плюс проверка на `tmp/gamedata`.
---
## 1. Идентификаторы и сущности
| Сущность | ID (LE uint32) | ASCII | Где используется |
|---|---:|---|---|
| Material resource | `0x3054414D` | `MAT0` | `Material.lib` |
| Wear resource | `0x52414557` | `WEAR` | `.wea` записи в world/mission `.rlb` |
| Texture resource | `0x6D786554` | `Texm` | `Textures.lib`, `lightmap.lib`, другие `.lib/.rlb` |
| Atlas tail chunk | `0x65676150` | `Page` | хвост payload `Texm` |
Дополнительно: палитры загружаются отдельным путём (через `SetPalettesLib` + `sub_10002B40`) и не являются `Texm`.
---
## 2. Архитектура подсистемы
### 2.1 Экспортируемые точки входа (World3D)
- `LoadMatManager`
- `SetPalettesLib`
- `SetTexturesLib`
- `SetMaterialLib`
- `SetLightMapLib`
- `SetGameTime`
- `UnloadAllTextures`
`Set*Lib` просто копируют строки путей в глобальные буферы; валидации пути нет.
### 2.2 Дефолтные библиотеки (из `iron3d.dll`)
- `Textures.lib`
- `Material.lib`
- `LightMap.lib`
- `palettes.lib` (строка собирается как `'p' + "alettes.lib"`)
### 2.3 Ключевые runtime-хранилища
1. Менеджер материалов (`LoadMatManager`) — объект `0x470` байт.
2. Кэш текстурных объектов.
3. Кэш lightmap-объектов.
4. Банк загруженных палитр.
5. Глобальный пул определений материалов (`MAT0`).
---
## 3. Layout `MatManager` (0x470)
Объект содержит 70 таблиц wear/lightmaps (не 140).
```c
// int-индексы относительно this (DWORD*), размер 284 DWORD = 0x470
// [0] vtable
// [1] callback iface
// [2] callback data
// [3..72] wearTablePtrs[70] // ptr на массив по 8 байт
// [73..142] wearCounts[70]
// [143] tableCount
// [144..213] lightmapTablePtrs[70] // ptr на массив по 4 байта
// [214..283] lightmapCounts[70]
```
### 3.1 Vtable методов (`off_100209E4`)
| Индекс | Функция | Назначение |
|---:|---|---|
| 0 | `loc_10002CE0` | служебный/RTTI-заглушка |
| 1 | `sub_10002D10` | деструктор + освобождение таблиц |
| 2 | `PreLoadAllTextures` | экспорт, но фактически `retn 4` (заглушка) |
| 3 | `sub_100031F0` | получить материал-фазу по `gameTime` |
| 4 | `sub_10003AE0` | сбросить startTime записи wear к `SetGameTime()` |
| 5 | `sub_10003680` | получить материал-фазу по нормализованному `t` |
| 6 | `sub_10003B10` | загрузить wear/lightmaps (файл/ресурс) |
| 7 | `sub_10003F80` | загрузить wear/lightmaps из буфера |
| 8 | `sub_100031A0` | получить указатель на lightmap texture object |
| 9 | `sub_10003AB0` | получить runtime-метаданные материала |
| 10 | `sub_100031D0` | получить `wearCount` для таблицы |
### 3.2 Кодирование material-handle
`uint32 handle = (tableIndex << 16) | wearIndex`.
- `HIWORD(handle)` -> индекс таблицы `0..69`
- `LOWORD(handle)` -> индекс материала в wear-таблице
---
## 4. Глобальные кэши и их ёмкость
Ёмкости подтверждены границами циклов/адресов в дизассемблере.
### 4.1 Кэш текстур (`dword_1014E910`...)
- Размер слота: `5 DWORD` (20 байт)
- Ёмкость: `777`
```c
struct TextureSlot {
int32_t resIndex; // +0 индекс записи в NRes (не hash), -1 = свободно
void* textureObject; // +4
int32_t refCount; // +8
uint32_t lastZeroRefTime;// +12 время, когда refCount стал 0
uint32_t loadFlags; // +16 флаги загрузки
};
```
`lastZeroRefTime` реально используется: texture-слоты с `refCount==0` освобождаются отложенно периодическим GC.
### 4.2 Кэш lightmaps (`dword_10029C98`...)
- Тот же layout `5 DWORD`
- Ёмкость: `100`
Для lightmap-слотов аналогичного периодического GC по `lastZeroRefTime` в `World3D` не наблюдается.
### 4.3 Пул материалов (`dword_100669F0`...)
- Шаг: `92 DWORD` (`368` байт)
- Ёмкость: `700`
Фиксированные поля на шаг `i*92`:
| DWORD offset | Byte offset | Поле |
|---:|---:|---|
| 0 | 0 | `nameResIndex` (`MAT0` entry index), `-1` = free |
| 1 | 4 | `refCount` |
| 2 | 8 | `phaseCount` |
| 3 | 12 | `phaseArrayPtr` (`phaseCount * 76`) |
| 4 | 16 | `animBlockCount` (`< 20`) |
| 5..84 | 20..339 | `animBlocks[20]` по 16 байт |
| 85 | 340 | metaA (`dword_10066B44`) |
| 86 | 344 | metaB (`dword_10066B48`) |
| 87 | 348 | metaC (`dword_10066B4C`) |
| 88 | 352 | metaD (`dword_10066B50`) |
| 89 | 356 | flagA (`dword_10066B54`) |
| 90 | 360 | nibbleMode (`dword_10066B58`) |
| 91 | 364 | flagB (`dword_10066B5C`) |
### 4.4 Банк палитр
- `dword_1013DA58[]`
- Загружается до `286` элементов (26 букв * 11 вариантов)
---
## 5. Загрузка палитр (`sub_10002B40`)
### 5.1 Генерация имён
Движок перебирает:
- буквы `'A'..'Z'`
- суффиксы: `""`, `"0"`, `"1"`, ..., `"9"`
И формирует имя:
- `<Letter><Suffix>.PAL`
- примеры: `A.PAL`, `A0.PAL`, ..., `Z9.PAL`
### 5.2 Индекс палитры
`paletteIndex = letterIndex * 11 + variantIndex`
- `letterIndex = 0..25`
- `variantIndex = 0..10` (`""`=0, `"0"`=1, ..., `"9"`=10)
### 5.3 Поведение
- Если запись не найдена: `paletteSlots[idx] = 0`
- Если найдена: payload отдаётся в рендер (`render->method+60`)
---
## 6. Формат `MAT0` (`Material.lib`)
### 6.1 Атрибуты NRes entry
`sub_10004310` использует:
- `entry.type` = `MAT0`
- `entry.attr1` (bitfield runtime-флагов)
- `entry.attr2` (версия/вариант заголовка payload)
- `entry.attr3` не используется в runtime-парсере
Маппинг `attr1`:
- bit0 (`0x01`) -> добавить флаг `0x200000` в загрузку текстур фазы
- bit1 (`0x02`) -> `flagA=1`; при некоторых HW-условиях дополнительно OR `0x80000`
- bits2..5 -> `nibbleMode = (attr1 >> 2) & 0xF`
- bit6 (`0x40`) -> `flagB=1`
### 6.2 Payload layout
```c
struct Mat0Payload {
uint16_t phaseCount;
uint16_t animBlockCount; // должно быть < 20, иначе "Too many animations for material."
// Если attr2 >= 2:
uint8_t metaA8;
uint8_t metaB8;
// Если attr2 >= 3:
uint32_t metaC32;
// Если attr2 >= 4:
uint32_t metaD32;
PhaseRecordByte34 phases[phaseCount];
AnimBlockRaw anim[animBlockCount];
};
```
Если `attr2 < 2`, runtime-значения по умолчанию:
- `metaA = 255`
- `metaB = 255`
- `metaC = 1.0f` (`0x3F800000`)
- `metaD = 0`
### 6.3 `PhaseRecordByte34` -> runtime `76 bytes`
Сырые 34 байта:
```c
struct PhaseRecordByte34 {
uint8_t p[18]; // параметры
char textureName[16];// если textureName[0]==0, текстуры нет
};
```
Преобразование в runtime-структуру (точный порядок):
| Из `p[i]` | В offset runtime | Преобразование |
|---:|---:|---|
| `p[0]` | `+16` | `p[0] / 255.0f` |
| `p[1]` | `+20` | `p[1] / 255.0f` |
| `p[2]` | `+24` | `p[2] / 255.0f` |
| `p[3]` | `+28` | `p[3] * 0.01f` |
| `p[4]` | `+0` | `p[4] / 255.0f` |
| `p[5]` | `+4` | `p[5] / 255.0f` |
| `p[6]` | `+8` | `p[6] / 255.0f` |
| `p[7]` | `+12` | `p[7] / 255.0f` |
| `p[8]` | `+32` | `p[8] / 255.0f` |
| `p[9]` | `+36` | `p[9] / 255.0f` |
| `p[10]` | `+40` | `p[10] / 255.0f` |
| `p[11]` | `+44` | `p[11] / 255.0f` |
| `p[12]` | `+48` | `p[12] / 255.0f` |
| `p[13]` | `+52` | `p[13] / 255.0f` |
| `p[14]` | `+56` | `p[14] / 255.0f` |
| `p[15]` | `+60` | `p[15] / 255.0f` |
| `p[16]` | `+64` | `uint32 = p[16]` |
| `p[17]` | `+72` | `int32 = p[17]` |
Текстура:
- `textureName[0] == 0` -> `runtime[+68] = -1` и `runtime[+72] = -1`
- иначе `runtime[+68] = LoadTexture(textureName, flags)`
### 6.4 Runtime-запись фазы (76 байт)
```c
struct MaterialPhase76 {
float f0; // +0
float f1; // +4
float f2; // +8
float f3; // +12
float f4; // +16
float f5; // +20
float f6; // +24
float f7; // +28
float f8; // +32
float f9; // +36
float f10; // +40
float f11; // +44
float f12; // +48
float f13; // +52
float f14; // +56
float f15; // +60
uint32_t u16; // +64
int32_t texSlot; // +68 (индекс в texture cache, либо -1)
int32_t i18; // +72
};
```
### 6.5 Анимационные блоки (`animBlockCount`, максимум 19)
Каждый блок в payload:
```c
struct AnimBlockRaw {
uint32_t headerRaw; // mode = headerRaw & 7; interpMask = headerRaw >> 3
uint16_t keyCount;
struct KeyRaw {
uint16_t k0;
uint16_t k1;
uint16_t k2;
} keys[keyCount];
};
```
Runtime-представление блока = 16 байт:
```c
struct AnimBlockRuntime {
uint32_t mode; // headerRaw & 7
uint32_t interpMask;// headerRaw >> 3
int32_t keyCount;
void* keysPtr; // массив keyCount * 8
};
```
Ключи в runtime занимают 8 байт/ключ (с расширением `k0` до `uint32`).
`k2` в `sub_100031F0/sub_10003680` не используется.
Поле нужно сохранять lossless, т.к. оно присутствует в бинарном формате.
### 6.6 Поиск и fallback
При `LoadMaterial(name)`:
- сначала точный поиск в `Material.lib`;
- при промахе лог: `"Material %s not found."`;
- fallback на `DEFAULT`;
- если и `DEFAULT` не найден, берётся индекс `0`.
---
## 7. Выбор текущей material-фазы
### 7.1 Интерполяция (`sub_10003030`)
Интерполируются только следующие поля (по `interpMask`):
- bit `0x02`: `+4,+8,+12`
- bit `0x01`: `+20,+24,+28`
- bit `0x04`: `+36,+40,+44`
- bit `0x08`: `+52,+56,+60`
- bit `0x10`: `+32`
Не интерполируются и копируются из «текущей» фазы:
- `+0,+16,+48,+64,+68,+72`
### 7.2 Выбор по времени (`sub_100031F0`)
Вход:
- `handle` (`tableIndex|wearIndex`)
- `animBlockIndex`
- глобальное время `SetGameTime()` (`dword_10032A38`)
Для каждой wear-записи хранится `startTime` (второй DWORD пары `8-byte`).
Режимы `mode = headerRaw & 7`:
- `0`: loop
- `1`: ping-pong
- `2`: one-shot clamp
- `3`: random (`rand() % cycleLength`)
Важные детали 1:1:
- деление/остаток по циклу реализованы через unsigned `div` (`edx=0` перед делением);
- в `mode=3` вычисленное `rand() % cycleLength` записывается прямо в `startTime` записи (не в локальную переменную).
- при `gameTime < startTime` применяется unsigned-wrap семантика (важно для точного воспроизведения edge-case).
После выбора сегмента интерполяции `sub_10003030` строит scratch-материал (`unk_1013B300`), который возвращается через out-параметр.
### 7.3 Выбор по нормализованному `t` (`sub_10003680`)
Аналогично `sub_100031F0`, но time берётся как `t * cycleLength`.
Перед вычислением времени применяется runtime-нормализация:
- если `t < 0.0` или `t > 1.0`, используется `t = 0.5`.
### 7.4 Сброс времени записи
`sub_10003AE0` обновляет `startTime` конкретной wear-записи значением текущего `SetGameTime()`.
---
## 8. Формат `WEAR` (текст)
`WEAR` хранится как текст в NRes entry типа `WEAR` (`0x52414557`), обычно имя `*.wea`.
### 8.1 Грамматика
```text
<wearCount:int>\n
<legacyId:int> <materialName>\n // повторить wearCount раз
[\n] // для buffer-парсера с LIGHTMAPS фактически обязательна пустая строка
[LIGHTMAPS\n
<lightmapCount:int>\n
<legacyId:int> <lightmapName>\n // повторить lightmapCount раз]
```
- `<legacyId>` читается, но как ключ не используется.
- Идентификатором реально является имя (`materialName` / `lightmapName`).
### 8.2 Парсеры
1. `sub_10003B10`: файл/ресурсный режим.
2. `sub_10003F80`: парсер из строкового буфера.
Различие важно для совместимости:
- `sub_10003B10` после `LIGHTMAPS` сразу читает `lightmapCount` через `fscanf`.
- `sub_10003F80` после детекта `LIGHTMAPS` делает два последовательных skip до `\n`; поэтому при наличии блока `LIGHTMAPS` нужен пустой разделитель перед строкой `LIGHTMAPS`, иначе парсинг может съехать.
### 8.3 Поведение и ошибки
- `wearCount <= 0` (в текстовом файловом режиме) -> `"Illegal wear length."`
- при невозможности открыть wear-файл/entry -> `"Wear <%s> doesn't exist."`
- если найден блок `LIGHTMAPS` и `lightmapCount <= 0` -> `"Illegal lightmaps length."`
- отсутствующий материал -> `"Material %s not found."` + fallback `DEFAULT`
- отсутствующая lightmap -> `"LightMap %s not found."` и slot `-1`
- в buffer-режиме неверная структура вокруг `LIGHTMAPS` может дать некорректный `lightmapCount` и каскадные ошибки чтения.
### 8.4 Ограничения runtime
- Таблиц в `MatManager`: максимум 70 (физический layout).
- Жёсткой проверки на overflow таблиц в `sub_10003B10/sub_10003F80` нет.
Инструментам нужно явно валидировать `tableCount < 70`.
---
## 9. Загрузка texture/lightmap по имени
Общие функции:
- `sub_10004B10` — texture (`Textures.lib`)
- `sub_10004CB0` — lightmap (`LightMap.lib`)
### 9.1 Валидация имени
Алгоритм требует наличие `'.'` в позиции `0..16`.
Иначе:
- `"Bad texture name."`
- возврат `-1`
### 9.2 Palette index из суффикса
После точки разбирается:
- `L = toupper(name[dot+1])`
- `D = name[dot+2]` (опционально)
- `idx = (L - 'A') * 11 + (D ? (D - '0' + 1) : 0)`
Если `idx < 0`, палитра не подставляется (`0`).
Верхняя граница `idx` в runtime не проверяется.
Практически в стоковых ассетах имена часто вида `NAME.0`; это даёт `idx < 0`, т.е. без палитровой привязки.
Для невалидных суффиксов это потенциально даёт OOB-чтение палитрового массива.
### 9.3 Кэширование
- Дедупликация по `resIndex`.
- При повторном запросе увеличивается `refCount`, `lastZeroRefTime` сбрасывается в `0`.
- При освобождении материала `refCount` texture/lightmap уменьшается.
- texture: при `refCount -> 0` запоминается `lastZeroRefTime`; периодический sweep (примерно раз в 20 секунд) удаляет слот, если прошло больше `~60` секунд.
- lightmap: явного аналогичного sweep-пути нет; освобождение в основном происходит при teardown таблиц (`MatManager` dtor).
---
## 10. Формат `Texm`
### 10.1 Заголовок 32 байта
```c
struct TexmHeader32 {
uint32_t magic; // 'Texm' = 0x6D786554
uint32_t width;
uint32_t height;
uint32_t mipCount;
uint32_t flags4;
uint32_t flags5;
uint32_t unk6;
uint32_t format;
};
```
### 10.2 Поддерживаемые `format`
Подтверждённые в данных:
- `0` (палитровый 8-bit)
- `565`
- `4444`
- `888`
- `8888`
Поддерживается loader-ветками Ngi32 (может встречаться в runtime-генерации):
- `556`
- `88`
### 10.3 Layout payload
1. `TexmHeader32`
2. если `format == 0`: palette table `256 * 4 = 1024` байта
3. mip-chain пикселей
4. опциональный `Page` chunk
Расчёт:
```c
bytesPerPixel =
(format == 0) ? 1 :
(format == 565 || format == 556 || format == 4444 || format == 88) ? 2 :
4;
pixelCount = sum_{i=0..mipCount-1}(max(1, width>>i) * max(1, height>>i));
sizeCore = 32 + (format == 0 ? 1024 : 0) + bytesPerPixel * pixelCount;
```
### 10.4 `Page` chunk
```c
struct PageChunk {
uint32_t magic; // 'Page'
uint32_t rectCount;
struct Rect16 {
int16_t x;
int16_t w;
int16_t y;
int16_t h;
} rects[rectCount];
};
```
Runtime конвертирует `Rect16` в:
- пиксельные прямоугольники;
- UV-границы с учётом возможного `mipSkip`.
Формулы (`s = mipSkip`):
- `x0 = x << s`, `x1 = (x + w) << s`
- `y0 = y << s`, `y1 = (y + h) << s`
- `u0 = x / (width << s)`, `du = w / (width << s)`
- `v0 = y / (height << s)`, `dv = h / (height << s)`
Также всегда добавляется базовый rect `[0]` на всю текстуру: пиксели `(0,0,width,height)`, UV `(0,0,1,1)`.
### 10.5 Loader-поведение (`sub_1000FB30`)
- Читает header в внутренние поля (`+56..+84`) напрямую:
- `+56 magic`, `+60 width`, `+64 height`, `+68 mipCount`,
- `+72 flags4`, `+76 flags5`, `+80 unk6`, `+84 format`.
- Для `format==0` считывает palette и переставляет каналы в runtime-таблицу.
- Считает `sizeCore`, находит tail.
- `Page` разбирается только если включён флаг загрузки `0x400000` и tail содержит `Page`.
- Может уменьшать стартовый mip (`sub_1000F580`) в зависимости от размеров/формата/флагов.
- При `DisableMipmap == 0` и допустимых условиях может строить mips в runtime.
### 10.6 Политика `mipSkip` (`sub_1000F580`)
`mipSkip` зависит от `flags5 & 0x72000000`, `width`, `height`, `mipCount`:
- если `mipCount <= 1` -> `0`
- если `flags5Mask == 0x02000000` -> `2` при `mipCount > 2`, иначе `1`
- если `flags5Mask == 0x10000000` -> `1`
- если `flags5Mask == 0x20000000`:
- `1`, если `width >= 256` или `height >= 256`
- иначе `0`
- если `flags5Mask == 0x40000000`:
- если `width > 128` и `height > 128`: `2` при `mipCount > 2`, иначе `1`
- если `width == 128` или `height == 128`: `1`
- иначе `0`
- иначе `0`
Применение в loader:
- `mipCount -= mipSkip`
- `width >>= mipSkip`, `height >>= mipSkip`
- `pixelDataOffset += bytesPerPixel * origWidth * origHeight` для `mipSkip==1`
- `pixelDataOffset += bytesPerPixel * origWidth * origHeight * 1.25` для `mipSkip==2` (первые два уровня)
---
## 11. Флаги профиля/рендера (Ngi32)
Ключ реестра: `HKCU\Software\Nikita\NgiTool`.
Подтверждённые значения:
- `Disable MultiTexturing`
- `DisableMipmap`
- `Force 16-bit textures`
- `UseFirstCard`
- `DisableD3DCalls`
- `DisableDSound`
- `ForceCpu`
Они напрямую влияют на выбор texture format path, mip handling и fallback-ветки.
---
## 12. Спецификация для toolchain (read/edit/write)
### 12.1 Каноническая модель данных
1. `MAT0`:
- хранить исходные `attr1/attr2/attr3`;
- хранить сырой payload + декодированную структуру;
- при записи сохранять порядок/размеры секций точно.
2. `WEAR`:
- хранить строки wear/lightmaps как текст;
- сохранять порядок строк;
- допускать отсутствие блока `LIGHTMAPS`.
- если нужен полный runtime-parity с buffer-парсером (`sub_10003F80`) и есть `LIGHTMAPS`, сохранять пустую строку-разделитель перед строкой `LIGHTMAPS`.
3. `Texm`:
- хранить header поля как есть (`flags4/flags5/unk6` не нормализовать);
- хранить palette (если есть), mip data, `Page`.
### 12.2 Правила lossless записи
- Не менять значения `flags4/flags5/unk6` без явной причины.
- Не менять `NRes` entry attrs, если цель — бинарный round-trip.
- Для `MAT0`:
- `animBlockCount < 20`.
- `phaseCount` и фактический размер секции должны совпадать.
- textureName в фазе всегда укладывать в 16 байт и NUL-терминировать.
- Для `Texm`:
- `magic == 'Texm'`.
- `mipCount > 0`, `width>0`, `height>0`.
- tail либо отсутствует, либо ровно один корректный `Page` chunk без лишних байт.
- при эмуляции runtime-загрузчика учитывать, что `Page` обрабатывается только при load-flag `0x400000`.
### 12.3 Рекомендованные валидации редактора
- `WEAR`:
- `wearCount > 0`.
- число строк wear соответствует `wearCount`.
- если есть `LIGHTMAPS`, то `lightmapCount > 0` и число строк совпадает.
- для buffer-совместимого текста с `LIGHTMAPS` проверять наличие пустой строки перед `LIGHTMAPS`.
- `MAT0`:
- не выходить за payload при распаковке.
- все ссылки фаз/keys проверять на диапазоны.
- `Texm`:
- `sizeCore <= payload_size`.
- проверка `Page` как `8 + rectCount*8`.
- предупреждать/блокировать невалидные palette suffix, которые могут дать `idx >= 286` в runtime.
---
## 13. Проверка на реальных данных (`tmp/gamedata`)
### 13.1 `Material.lib`
- `905` entries, все `type=MAT0`
- `attr2 = 6` у всех
- `attr3 = 0` у всех
- `phaseCount` до `29`
- `animBlockCount` до `8` (ограничение runtime `<20` соблюдается)
### 13.2 `Textures.lib`
- `393` entries, все `type=Texm`
- форматы: `8888(237), 888(52), 565(47), 4444(42), 0(15)`
- `flags4`: `32(361), 0(32)`
- `flags5`: `0(312), 0x04000000(81)`
- `Page` chunk присутствует у `65` текстур
### 13.3 `lightmap.lib`
- `25` entries, все `Texm`
- формат: `565`
- `mipCount=1`
- `flags5`: в основном `0`, встречается `0x00800000`
### 13.4 `WEAR`
- `439` entries `type=WEAR`
- `attr1=0, attr2=0, attr3=1`
- `21` entry содержит блок `LIGHTMAPS` (в текущем наборе везде `lightmapCount=1`)
- для всех `21` entry с `LIGHTMAPS` присутствует пустая строка перед `LIGHTMAPS`.
---
## 14. Opaque-поля и границы знания
Для 1:1 runtime/toolchain достаточно фиксировать следующие поля как `opaque-but-required`:
- `MAT0`:
- `k2` в `AnimBlockRaw::KeyRaw` (хранить/писать без изменений);
- `metaA/metaB/metaC/metaD``World3D` заполняются и возвращаются наружу; внутренних consumers этих мета-полей не найдено).
- `Texm`:
- `flags4/flags5/unk6` (часть веток разобрана, но полная доменная семантика не требуется для 1:1).
Это не блокирует реализацию движка/конвертеров 1:1.
---
## 15. Минимальные псевдокоды для реализации
### 15.1 `parse_mat0(payload, attr2)`
```python
def parse_mat0(payload: bytes, attr2: int):
cur = 0
phase_count = u16(payload, cur); cur += 2
anim_count = u16(payload, cur); cur += 2
if anim_count >= 20:
raise ValueError("Too many animations for material")
if attr2 < 2:
metaA, metaB, metaC, metaD = 255, 255, 0x3F800000, 0
else:
metaA = u8(payload, cur); cur += 1
metaB = u8(payload, cur); cur += 1
metaC = u32(payload, cur) if attr2 >= 3 else 0x3F800000
cur += 4 if attr2 >= 3 else 0
metaD = u32(payload, cur) if attr2 >= 4 else 0
cur += 4 if attr2 >= 4 else 0
phases = [payload[cur + i*34 : cur + (i+1)*34] for i in range(phase_count)]
cur += 34 * phase_count
anim = []
for _ in range(anim_count):
raw = u32(payload, cur); cur += 4
key_count = u16(payload, cur); cur += 2
keys = [payload[cur + k*6 : cur + (k+1)*6] for k in range(key_count)]
cur += 6 * key_count
anim.append((raw, keys))
if cur != len(payload):
raise ValueError("MAT0 tail bytes")
return phase_count, anim_count, metaA, metaB, metaC, metaD, phases, anim
```
### 15.2 `parse_texm(payload)`
```python
def parse_texm(payload: bytes):
magic, w, h, mips, f4, f5, unk6, fmt = unpack_u32x8(payload, 0)
if magic != 0x6D786554:
raise ValueError("not Texm")
bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444, 88) else 4)
pix = 0
mw, mh = w, h
for _ in range(mips):
pix += mw * mh
mw = max(1, mw >> 1)
mh = max(1, mh >> 1)
core = 32 + (1024 if fmt == 0 else 0) + bpp * pix
if core > len(payload):
raise ValueError("truncated")
page = None
if core < len(payload):
if core + 8 > len(payload) or payload[core:core+4] != b"Page":
raise ValueError("tail without Page")
n = u32(payload, core + 4)
need = 8 + n * 8
if core + need != len(payload):
raise ValueError("invalid Page size")
page = [unpack_i16x4(payload, core + 8 + i*8) for i in range(n)]
return (w, h, mips, fmt, f4, f5, unk6, page)
```
### 15.3 `mip_skip_policy(flags5, width, height, mip_count)`
```python
def mip_skip_policy(flags5: int, width: int, height: int, mip_count: int) -> int:
if mip_count <= 1:
return 0
m = flags5 & 0x72000000
if m == 0x02000000:
return 2 if mip_count > 2 else 1
if m == 0x10000000:
return 1
if m == 0x20000000:
return 1 if (width >= 256 or height >= 256) else 0
if m == 0x40000000:
if width > 128 and height > 128:
return 2 if mip_count > 2 else 1
if width == 128 or height == 128:
return 1
return 0
```
### 15.4 `parse_wear_buffer_compatible(text)`
```python
def parse_wear_buffer_compatible(text: str):
lines = text.splitlines()
i = 0
wear_count = int(lines[i].strip()); i += 1
if wear_count <= 0:
raise ValueError("Illegal wear length.")
wear = []
for _ in range(wear_count):
legacy, name = lines[i].split(maxsplit=1)
wear.append((int(legacy), name.strip()))
i += 1
lightmaps = []
tail = lines[i:] if i < len(lines) else []
if tail and tail[0].strip() == "":
# sub_10003F80-совместимый разделитель перед LIGHTMAPS
i += 1
tail = lines[i:]
if tail and tail[0].strip().upper() == "LIGHTMAPS":
i += 1
if i >= len(lines):
raise ValueError("Illegal lightmaps length.")
light_count = int(lines[i].strip()); i += 1
if light_count <= 0:
raise ValueError("Illegal lightmaps length.")
for _ in range(light_count):
legacy, name = lines[i].split(maxsplit=1)
lightmaps.append((int(legacy), name.strip()))
i += 1
return wear, lightmaps
```
### 15.5 `select_phase_time_1to1(...)`
```python
def select_phase_time_1to1(game_time: int, start_time: int, keys, mode: int):
# keys: list[(phase_index, t_start, t_end)], t_end последнего = cycle_len
cycle_len = keys[-1][2]
if cycle_len <= 0:
return 0, 0.0
# unsigned div/mod как в runtime
delta = (game_time - start_time) & 0xFFFFFFFF
q = delta // cycle_len
r = delta % cycle_len
if mode == 1: # ping-pong
if q & 1:
r = cycle_len - r
elif mode == 2: # one-shot
if q > 0:
k = len(keys) - 1
return k, 0.0
elif mode == 3: # random
r = rand32() % cycle_len
start_time = r # side effect как в sub_100031F0
k = find_segment(keys, r) # t_start <= r < t_end
kn = 0 if (k + 1 == len(keys)) else (k + 1)
t0, t1 = keys[k][1], keys[k][2]
alpha = 0.0 if t1 == t0 else (r - t0) / float(t1 - t0)
return (k, kn), alpha
```
-5
View File
@@ -1,5 +0,0 @@
# Missions
Документ описывает формат миссий и сценариев: начальное состояние, триггеры и связь миссий с картой мира.
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `MisLoad.dll`.
-105
View File
@@ -1,105 +0,0 @@
# MSH animation
Документ описывает анимационные ресурсы MSH: `Res8`, `Res19` и runtime-интерполяцию.
---
## 1.13. Ресурсы анимации: Res8 и Res19
- **Res8** — массив анимационных ключей фиксированного размера 24 байта.
- **Res19**`uint16` mapping‑массив «frame → keyIndex` (с per-node смещением).
### 1.13.1. Формат Res8 (ключ 24 байта)
```c
struct AnimKey24 {
float posX; // +0x00
float posY; // +0x04
float posZ; // +0x08
float time; // +0x0C
int16_t qx; // +0x10
int16_t qy; // +0x12
int16_t qz; // +0x14
int16_t qw; // +0x16
};
```
Декодирование quaternion-компонент:
```c
q = s16 * (1.0f / 32767.0f)
```
### 1.13.2. Формат Res19
Res19 читается как непрерывный массив `uint16`:
```c
uint16_t map[]; // размер = size(Res19)/2
```
Per-node управление mapping'ом берётся из заголовка узла Res1:
- `node.hdr2` (`Res1 + 0x04`) = `mapStart` (`0xFFFF` => map отсутствует);
- `node.hdr3` (`Res1 + 0x06`) = `fallbackKeyIndex` и одновременно верхняя граница валидного `map`‑значения.
### 1.13.3. Выбор ключа для времени `t` (`sub_10012880`)
1) Вычислить frame‑индекс:
```c
frame = (int64)(t - 0.5f); // x87 FISTP-путь
```
Для строгой 1:1 эмуляции используйте именно поведение x87 `FISTP` (а не «упрощённый floor»), т.к. путь в оригинале опирается на FPU rounding mode.
2) Проверка условий fallback:
- `frame >= model.animFrameCount` (`model+0x9C`, из `NResEntry(Res19).attr2`);
- `mapStart == 0xFFFF`;
- `map[mapStart + frame] >= fallbackKeyIndex`.
Если любое условие истинно:
```c
keyIndex = fallbackKeyIndex;
```
Иначе:
```c
keyIndex = map[mapStart + frame];
```
3) Сэмплирование:
- `k0 = Res8[keyIndex]`
- `k1 = Res8[keyIndex + 1]` (для интерполяции сегмента)
Пути:
- если `t == k0.time` → взять `k0`;
- если `t == k1.time` → взять `k1`;
- иначе `alpha = (t - k0.time) / (k1.time - k0.time)`, `pos = lerp(k0.pos, k1.pos, alpha)`, rotation смешивается через fastproc‑интерполятор quaternion.
### 1.13.4. Межкадровое смешивание (`sub_10012560`)
Функция смешивает два сэмпла (например, из двух animation time-позиций) с коэффициентом `blend`:
1) получить два `(quat, pos)` через `sub_10012880`;
2) выполнить shortest‑path коррекцию знака quaternion:
```c
if (|q0 + q1|^2 < |q0 - q1|^2) q1 = -q1;
```
3) смешать quaternion (fastproc) и построить orientation‑матрицу;
4) translation писать отдельно как `lerp(pos0, pos1, blend)` в ячейки `m[3], m[7], m[11]`.
### 1.13.5. Что хранится в `Res19.attr2`
При загрузке `sub_10015FD0` записывает `NResEntry(Res19).attr2` в `model+0x9C`.
Это поле используется как верхняя граница frame‑индекса в п.1.13.3.
---
-492
View File
@@ -1,492 +0,0 @@
# MSH core
Документ описывает core-часть формата MSH: геометрию, узлы, батчи, LOD и slot-матрицу.
Связанный формат контейнера: [NRes / RsLi](nres.md).
---
## 1.1. Общая архитектура
Модель состоит из набора именованных ресурсов внутри одного NRes‑архива. Каждый ресурс идентифицируется **целочисленным типом** (`resource_type`), который передаётся API функции `niReadData` (vtable‑метод `+0x18`) через связку `niFind` (vtable‑метод `+0x0C`, `+0x20`).
Рендер‑модель использует **rigid‑скининг по узлам** (нет pervertex bone weights). Каждый batch геометрии привязан к одному узлу и рисуется с матрицей этого узла.
## 1.2. Общая структура файла модели
```
┌────────────────────────────────────┐
│ NRes‑заголовок (16 байт) │
├────────────────────────────────────┤
│ Ресурсы (произвольный порядок): │
│ Res1 — Node table │
│ Res2 — Model header + Slots │
│ Res3 — Vertex positions │
│ Res4 — Packed normals │
│ Res5 — Packed UV0 │
│ Res6 — Index buffer │
│ Res7 — Triangle descriptors │
│ Res8 — Keyframe data │
│ Res10 — String table │
│ Res13 — Batch table │
│ Res19 — Animation mapping │
│ [Res15] — UV1 / доп. поток │
│ [Res16] — Tangent/Bitangent │
│ [Res18] — Vertex color │
│ [Res20] — Доп. таблица │
├────────────────────────────────────┤
│ NRes‑каталог │
└────────────────────────────────────┘
```
Ресурсы в квадратных скобках — **опциональные**. Загрузчик проверяет их наличие перед чтением (`niFindRes` возвращает `1` при отсутствии).
## 1.3. Порядок загрузки ресурсов (из `sub_10015FD0` в AniMesh.dll)
Функция `sub_10015FD0` выполняет инициализацию внутренней структуры модели размером **0xA4** (164 байта). Ниже приведён точный порядок загрузки и маппинг ресурсов на поля структуры:
| Шаг | Тип ресурса | Поле структуры | Описание |
|-----|-------------|----------------|-----------------------------------------|
| 1 | 1 | `+0x00` | Node table (Res1) |
| 2 | 2 | `+0x04` | Model header (Res2) |
| 3 | 3 | `+0x0C` | Vertex positions (Res3) |
| 4 | 4 | `+0x10` | Packed normals (Res4) |
| 5 | 5 | `+0x14` | Packed UV0 (Res5) |
| 6 | 10 (0x0A) | `+0x20` | String table (Res10) |
| 7 | 8 | `+0x18` | Keyframe / animation track data (Res8) |
| 8 | 19 (0x13) | `+0x1C` | Animation mapping (Res19) |
| 9 | 7 | `+0x24` | Triangle descriptors (Res7) |
| 10 | 13 (0x0D) | `+0x28` | Batch table (Res13) |
| 11 | 6 | `+0x2C` | Index buffer (Res6) |
| 12 | 15 (0x0F) | `+0x34` | Доп. vertex stream (Res15), опционально |
| 13 | 16 (0x10) | `+0x38` | Доп. vertex stream (Res16), опционально |
| 14 | 18 (0x12) | `+0x64` | Vertex color (Res18), опционально |
| 15 | 20 (0x14) | `+0x30` | Доп. таблица (Res20), опционально |
### Производные поля (вычисляются после загрузки)
| Поле | Формула | Описание |
|---------|-------------------------|------------------------------------------------------------------------------------------------|
| `+0x08` | `Res2_ptr + 0x8C` | Указатель на slot table (140 байт от начала Res2) |
| `+0x3C` | `= Res3_ptr` | Копия указателя positions (stream ptr) |
| `+0x40` | `= 0x0C` (12) | Stride позиций: `sizeof(float3)` |
| `+0x44` | `= Res4_ptr` | Копия указателя normals (stream ptr) |
| `+0x48` | `= 4` | Stride нормалей: 4 байта |
| `+0x4C` | `Res16_ptr` или `0` | Stream A Res16 (tangent) |
| `+0x50` | `= 8` если `+0x4C != 0` | Stride stream A (используется только при наличии Res16) |
| `+0x54` | `Res16_ptr + 4` или `0` | Stream B Res16 (bitangent) |
| `+0x58` | `= 8` если `+0x54 != 0` | Stride stream B (используется только при наличии Res16) |
| `+0x5C` | `= Res5_ptr` | Копия указателя UV0 (stream ptr) |
| `+0x60` | `= 4` | Stride UV0: 4 байта |
| `+0x68` | `= 4` или `0` | Stride Res18 (если найден) |
| `+0x8C` | `= Res15_ptr` | Копия указателя Res15 |
| `+0x90` | `= 8` | Stride Res15: 8 байт |
| `+0x94` | `= 0` | Зарезервировано/unk94: инициализируется нулём при загрузке; не является флагом Res18 |
| `+0x9C` | NRes entry Res19 `+8` | Метаданные из каталожной записи Res19 |
| `+0xA0` | NRes entry Res20 `+4` | Метаданные из каталожной записи Res20 (заполняется только если Res20 найден и открыт, иначе 0) |
**Примечание к метаданным:** поле `+0x9C` читается из каталожной записи NRes для ресурса 19 (смещение `+8` в записи каталога, т.е. `attribute_2`). Поле `+0xA0` — из каталожной записи для ресурса 20 (смещение `+4`, т.е. `attribute_1`) **только если Res20 найден и `niOpenRes` вернул ненулевой указатель**; иначе `+0xA0 = 0`. Индекс записи определяется как `entry_index * 64`, после чего считывается поле.
---
### 1.3.1. Ссылки на функции и паттерны вызовов (для проверки реверса)
- `AniMesh.dll!sub_10015FD0` — загрузка ресурсов модели через vtable интерфейса NRes:
- `niFindRes(type, ...)` вызывается через `call [vtable+0x20]`
- `niOpenRes(...)` / чтение указателя — через `call [vtable+0x18]`
- `AniMesh.dll!sub_10015FD0` выставляет производные поля (`Res2_ptr+0x8C`, stride'ы), обнуляет `model+0x94`, и при отсутствии Res16 обнуляет только указатели потоков (`+0x4C`, `+0x54`).
- `AniMesh.dll!sub_10004840` / `sub_10004870` / `sub_100048A0` — использование runtime mapping‑таблицы (`+0x18`, индекс `boneId*4`) и таблицы указателей треков (`+0x08`) после построения анимационного объекта.
## 1.4. Ресурс Res2 — Model Header (140 байт) + Slot Table
Ресурс Res2 содержит:
```
┌───────────────────────────────────┐ Смещение 0
│ Model Header (140 байт = 0x8C) │
├───────────────────────────────────┤ Смещение 140 (0x8C)
│ Slot Table │
│ (slot_count × 68 байт) │
└───────────────────────────────────┘
```
### 1.4.1. Model Header (первые 140 байт)
Поле `Res2[0x00..0x8B]` используется как **35 float** (без внутренних таблиц/индексов). Это подтверждено прямыми копированиями в `AniMesh.dll!sub_1000A460`:
- `qmemcpy(this+0x54, Res2+0x00, 0x60)` — первые 24 float;
- копирование `Res2+0x60` размером `0x10` — ещё 4 float;
- `qmemcpy(this+0x134, Res2+0x70, 0x1C)` — ещё 7 float.
Итоговая раскладка:
| Диапазон | Размер | Тип | Семантика |
|--------------|--------|-------------|----------------------------------------------------------------------|
| `0x00..0x5F` | `0x60` | `float[24]` | 8 вершин глобального boundinghull (`vec3[8]`) |
| `0x60..0x6F` | `0x10` | `float[4]` | Глобальная boundingsphere: `center.xyz + radius` |
| `0x70..0x8B` | `0x1C` | `float[7]` | Глобальный «капсульный»/сегментный bound: `A.xyz`, `B.xyz`, `radius` |
Для рендера и broadphase движок использует как слот‑bounds (`Res2 slot`), так и этот глобальный набор bounds (в зависимости от контекста вызова/LOD и наличия слота).
### 1.4.2. Slot Table (массив записей по 68 байт)
Slot — ключевая структура, связывающая узел иерархии с конкретной геометрией для конкретного LOD и группы. Каждая запись — **68 байт** (0x44).
**Важно:** смещения в таблице ниже указаны в **десятичном формате** (байты). В скобках приведён hex‑эквивалент (например, 48 (0x30)).
| Смещение | Размер | Тип | Описание |
|-----------|--------|----------|-----------------------------------------------------|
| 0 | 2 | uint16 | `triStart` — индекс первого треугольника в Res7 |
| 2 | 2 | uint16 | `triCount` — длина диапазона треугольников (`Res7`) |
| 4 | 2 | uint16 | `batchStart` — индекс первого batch'а в Res13 |
| 6 | 2 | uint16 | `batchCount` — количество batch'ей |
| 8 | 4 | float | `aabbMin.x` |
| 12 | 4 | float | `aabbMin.y` |
| 16 | 4 | float | `aabbMin.z` |
| 20 | 4 | float | `aabbMax.x` |
| 24 | 4 | float | `aabbMax.y` |
| 28 | 4 | float | `aabbMax.z` |
| 32 | 4 | float | `sphereCenter.x` |
| 36 | 4 | float | `sphereCenter.y` |
| 40 | 4 | float | `sphereCenter.z` |
| 44 (0x2C) | 4 | float | `sphereRadius` |
| 48 (0x30) | 20 | 5×uint32 | Хвостовые поля: `unk30..unk40` (см. §1.4.2.1) |
**AABB** — axisaligned bounding box в локальных координатах узла.
**Bounding Sphere** — описанная сфера в локальных координатах узла.
#### 1.4.2.1. Точная семантика `triStart/triCount`
В `AniMesh.dll!sub_1000B2C0` слот считается «владельцем» треугольника `triId`, если:
```c
triId >= slot.triStart && triId < slot.triStart + slot.triCount
```
Это прямое доказательство, что `slot +0x02` — именно **count диапазона**, а не флаги.
#### 1.4.2.2. Хвост слота (20 байт = 5×uint32)
Последние 20 байт записи слота трактуем как 5 последовательных 32‑битных значений (little‑endian). Их назначение пока не подтверждено; для инструментов рекомендуется сохранять и восстанавливать их «как есть».
- `+48 (0x30)`: `unk30` (uint32)
- `+52 (0x34)`: `unk34` (uint32)
- `+56 (0x38)`: `unk38` (uint32)
- `+60 (0x3C)`: `unk3C` (uint32)
- `+64 (0x40)`: `unk40` (uint32)
Для culling при рендере: AABB/sphere трансформируются матрицей узла и инстанса. При неравномерном scale радиус сферы масштабируется по `max(scaleX, scaleY, scaleZ)` (подтверждено по коду).
---
### 1.4.3. Восстановление счётчиков элементов по размерам ресурсов (практика для инструментов)
Для toolchain надёжнее считать count'ы по размерам ресурсов (а не по дублирующим полям других таблиц). Это полностью совпадает с тем, как рантайм использует fixed stride'ы в `sub_10015FD0`.
Берите **unpacked_size** (или фактический размер распакованного блока) соответствующего ресурса и вычисляйте:
- `node_count` = `size(Res1) / 38`
- `vertex_count` = `size(Res3) / 12`
- `normals_count` = `size(Res4) / 4`
- `uv0_count` = `size(Res5) / 4`
- `index_count` = `size(Res6) / 2`
- `tri_count` = `index_count / 3` (если примитивы — список треугольников)
- `tri_desc_count` = `size(Res7) / 16`
- `batch_count` = `size(Res13) / 20`
- `slot_count` = `(size(Res2) - 0x8C) / 0x44`
- `anim_key_count` = `size(Res8) / 24`
- `anim_map_count` = `size(Res19) / 2`
- `uv1_count` = `size(Res15) / 8` (если Res15 присутствует)
- `tbn_count` = `size(Res16) / 8` (если Res16 присутствует; tangent/bitangent по 4 байта, stride 8)
- `color_count` = `size(Res18) / 4` (если Res18 присутствует)
**Валидация:**
- Любое деление должно быть **без остатка**; иначе ресурс повреждён или stride неверно угадан.
- Если присутствуют Res4/Res5/Res15/Res16/Res18, их count'ы по смыслу должны совпадать с `vertex_count` (или быть ≥ него, если формат допускает хвостовые данные — пока не наблюдалось).
- Для `slot_count` дополнительно проверьте, что `size(Res2) >= 0x8C`.
**Проверка на реальных данных (435 MSH):**
- `Res2.attr1 == (size-140)/68`, `Res2.attr2 == 0`, `Res2.attr3 == 68`;
- `Res7.attr1 == size/16`, `Res7.attr3 == 16`;
- `Res8.attr1 == size/24`, `Res8.attr3 == 4`;
- `Res19.attr1 == size/2`, `Res19.attr3 == 2`;
- для `Res1` почти всегда `attr3 == 38` (один служебный outlier: `MTCHECK.MSH` с `attr3 == 24`).
Эти формулы достаточны, чтобы реализовать распаковщик/просмотрщик геометрии и батчей даже без полного понимания полей заголовка Res2.
## 1.5. Ресурс Res1 — Node Table (38 байт на узел)
Node table — компактная карта слотов по уровням LOD и группам. Каждый узел занимает **38 байт** (19 × `uint16`).
### Адресация слота
Движок вычисляет индекс слова в таблице:
```
word_index = nodeIndex × 19 + lod × 5 + group + 4
slot_index = node_table[word_index] // uint16, 0xFFFF = нет слота
```
Параметры:
- `lod`: 0..2 (три уровня детализации). Значение `1` → подставляется `current_lod` из инстанса.
- `group`: 0..4 (пять групп). На практике чаще всего используется `group = 0`.
### Раскладка записи узла (38 байт)
```
┌───────────────────────────────────────────────────────┐
│ Header: 4 × uint16 (8 байт) │
│ hdr0, hdr1, hdr2, hdr3 │
├───────────────────────────────────────────────────────┤
│ SlotIndex matrix: 3 LOD × 5 groups = 15 × uint16 │
│ LOD 0: group[0..4] │
│ LOD 1: group[0..4] │
│ LOD 2: group[0..4] │
└───────────────────────────────────────────────────────┘
```
| Смещение | Размер | Тип | Описание |
|----------|--------|------------|-----------------------------------------|
| 0 | 8 | uint16[4] | Заголовок узла (`hdr0..hdr3`, см. ниже) |
| 8 | 30 | uint16[15] | Матрица слотов: `slotIndex[lod][group]` |
`slotIndex = 0xFFFF` означает «слот отсутствует» — узел при данном LOD и группе не рисуется.
Подтверждённые семантики полей `hdr*`:
- `hdr1` (`+0x02`) — parent/index-link при построении инстанса (в `sub_1000A460` читается как индекс связанного узла, `0xFFFF` = нет связи).
- `hdr2` (`+0x04`) — `mapStart` для Res19 (`0xFFFF` = нет карты; fallback по `hdr3`).
- `hdr3` (`+0x06`) — `fallbackKeyIndex`/верхняя граница для map‑значений (используется в `sub_10012880`).
`hdr0` (`+0x00`) по коду участвует в битовых проверках (`&0x40`, `byte+1 & 8`) и несёт флаги узла.
**Группы (group 0..4):** в рантайме это ортогональный индекс к LOD (матрица 5×3 на узел). Имена групп в оригинальных ресурсах не подписаны; для 1:1 нужно сохранять группы как «сырой» индекс 0..4 без переинтерпретации.
---
## 1.6. Ресурс Res3 — Vertex Positions
**Формат:** массив `float3` (IEEE 754 singleprecision).
**Stride:** 12 байт.
```c
struct Position {
float x; // +0
float y; // +4
float z; // +8
};
```
Чтение: `pos = *(float3*)(res3_data + 12 * vertexIndex)`.
---
## 1.7. Ресурс Res4 — Packed Normals
**Формат:** 4 байта на вершину.
**Stride:** 4 байта.
```c
struct PackedNormal {
int8_t nx; // +0
int8_t ny; // +1
int8_t nz; // +2
int8_t nw; // +3 (назначение не подтверждено: паддинг / знак / индекс)
};
```
### Алгоритм декодирования (подтверждено по AniMesh.dll)
> В движке используется делитель **127.0**, а не 128.0 (см. константу `127.0` рядом с `1024.0`/`32767.0`).
```
normal.x = clamp((float)nx / 127.0, -1.0, 1.0)
normal.y = clamp((float)ny / 127.0, -1.0, 1.0)
normal.z = clamp((float)nz / 127.0, -1.0, 1.0)
```
**Множитель:** `1.0 / 127.0 ≈ 0.0078740157`.
**Диапазон входных значений:** −128..+127 → выход ≈ 1.007874..+1.0 → **после клампа** 1.0..+1.0.
**Почему нужен кламп:** значение `-128` при делении на `127.0` даёт модуль чуть больше 1.
**4‑й байт (nw):** используется ли он как часть нормали, как индекс или просто как выравнивание — не подтверждено. Рекомендация: игнорировать при первичном импорте.
---
## 1.8. Ресурс Res5 — Packed UV0
**Формат:** 4 байта на вершину (два `int16`).
**Stride:** 4 байта.
```c
struct PackedUV {
int16_t u; // +0
int16_t v; // +2
};
```
### Алгоритм декодирования
```
uv.u = (float)u / 1024.0
uv.v = (float)v / 1024.0
```
**Множитель:** `1.0 / 1024.0 = 0.0009765625`.
**Диапазон входных значений:** −32768..+32767 → выход ≈ 32.0..+31.999.
Значения >1.0 или <0.0 означают wrapping/repeat текстурных координат.
### Алгоритм кодирования (для экспортёра)
```
packed_u = (int16_t)round(uv.u * 1024.0)
packed_v = (int16_t)round(uv.v * 1024.0)
```
Результат обрезается (clamp) до диапазона `int16` (32768..+32767).
---
## 1.9. Ресурс Res6 — Index Buffer
**Формат:** массив `uint16` (беззнаковые 16‑битные индексы).
**Stride:** 2 байта.
Максимальное число вершин в одном batch: 65535.
Индексы используются совместно с `baseVertex` из batch table:
```
actual_vertex_index = index_buffer[indexStart + i] + baseVertex
```
---
## 1.10. Ресурс Res7 — Triangle Descriptors
**Формат:** массив записей по 16 байт. Одна запись на треугольник.
| Смещение | Размер | Тип | Описание |
|----------|--------|----------|---------------------------------------------|
| `+0x00` | 2 | `uint16` | `triFlags` — фильтрация/материал tri‑уровня |
| `+0x02` | 2 | `uint16` | `linkTri0` — tri‑ref для связанного обхода |
| `+0x04` | 2 | `uint16` | `linkTri1` — tri‑ref для связанного обхода |
| `+0x06` | 2 | `uint16` | `linkTri2` — tri‑ref для связанного обхода |
| `+0x08` | 2 | `int16` | `nX` (packed, scale `1/32767`) |
| `+0x0A` | 2 | `int16` | `nY` (packed, scale `1/32767`) |
| `+0x0C` | 2 | `int16` | `nZ` (packed, scale `1/32767`) |
| `+0x0E` | 2 | `uint16` | `selPacked` — 3 селектора по 2 бита |
Расшифровка `selPacked` (`AniMesh.dll!sub_10013680`):
```c
sel0 = selPacked & 0x3; if (sel0 == 3) sel0 = 0xFFFF;
sel1 = (selPacked >> 2) & 0x3; if (sel1 == 3) sel1 = 0xFFFF;
sel2 = (selPacked >> 4) & 0x3; if (sel2 == 3) sel2 = 0xFFFF;
```
`linkTri*` передаются в `sub_1000B2C0` и используются для построения соседнего набора треугольников при коллизии/пикинге.
**Важно:** дескрипторы не хранят индексы вершин треугольника. Индексы берутся из Res6 (index buffer) через `indexStart`/`indexCount` соответствующего batch'а.
Дескрипторы используются при обходе треугольников для коллизии и пикинга. `triStart` из slot table указывает, с какого дескриптора начинать обход для данного слота.
---
## 1.11. Ресурс Res13 — Batch Table
**Формат:** массив записей по 20 байт. Batch — минимальная единица отрисовки.
| Смещение | Размер | Тип | Описание |
|----------|--------|--------|---------------------------------------------------------|
| 0 | 2 | uint16 | `batchFlags` — битовая маска для фильтрации |
| 2 | 2 | uint16 | `materialIndex` — индекс материала |
| 4 | 2 | uint16 | `unk4` — неподтверждённое поле |
| 6 | 2 | uint16 | `unk6` — вероятный `nodeIndex` (привязка batch к кости) |
| 8 | 2 | uint16 | `indexCount` — число индексов (кратно 3) |
| 10 | 4 | uint32 | `indexStart` — стартовый индекс в Res6 (в элементах) |
| 14 | 2 | uint16 | `unk14` — неподтверждённое поле |
| 16 | 4 | uint32 | `baseVertex` — смещение вершинного индекса |
### Использование при рендере
```
for i in 0 .. indexCount-1:
raw_index = index_buffer[indexStart + i]
vertex_index = raw_index + baseVertex
position = res3[vertex_index]
normal = decode_normal(res4[vertex_index])
uv = decode_uv(res5[vertex_index])
```
**Примечание:** движок читает `indexStart` как `uint32` и умножает на 2 для получения байтового смещения в массиве `uint16`.
---
## 1.12. Ресурс Res10 — String Table
Res10 — это **последовательность записей, индексируемых по `nodeIndex`** (см. `AniMesh.dll!sub_10012530`).
Формат одной записи:
```c
struct Res10Record {
uint32_t len; // число символов без терминирующего '\0'
char text[]; // если len > 0: хранится len+1 байт (включая '\0')
// если len == 0: payload отсутствует
};
```
Переход к следующей записи:
```c
next = cur + 4 + (len ? (len + 1) : 0);
```
`sub_10012530` возвращает:
- `NULL`, если `len == 0`;
- `record + 4`, если `len > 0` (указатель на C‑строку).
Это значение используется в `sub_1000A460` для проверки имени текущего узла (например, поиск подстроки `"central"` при обработке node‑флагов).
---
---
## 1.14. Опциональные vertex streams
### Res15 — Дополнительный vertex stream (stride 8)
- **Stride:** 8 байт на вершину.
- **Кандидаты:** `float2 uv1` (lightmap / second UV layer), 4 × `int16` (2 UV‑пары), либо иной формат.
- Загружается условно — если ресурс 15 отсутствует, указатель равен `NULL`.
### Res16 — Tangent / Bitangent (stride 8, split 2×4)
- **Stride:** 8 байт на вершину (2 подпотока по 4 байта).
- При загрузке движок создаёт **два перемежающихся (interleaved) подпотока**:
- Stream A: `base + 0`, stride 8 — 4 байта (кандидат: packed tangent, `int8 × 4`)
- Stream B: `base + 4`, stride 8 — 4 байта (кандидат: packed bitangent, `int8 × 4`)
- Если ресурс 16 отсутствует, оба указателя обнуляются.
- **Важно:** в оригинальном `sub_10015FD0` при отсутствии Res16 страйды `+0x50/+0x58` явным образом не обнуляются; это безопасно, потому что оба указателя равны `NULL` и код не должен обращаться к потокам без проверки указателя.
- Декодирование предположительно аналогично нормалям: `component / 127.0` (как Res4), но требует подтверждения; при импорте — кламп в [-1..1].
### Res18 — Vertex Color (stride 4)
- **Stride:** 4 байта на вершину.
- **Кандидаты:** `D3DCOLOR` (BGRA), packed параметры освещения, vertex AO.
- Загружается условно (через проверку `niFindRes` на возврат `1`).
### Res20 — Дополнительная таблица
- Присутствует не всегда.
- Из каталожной записи NRes считывается поле `attribute_1` (смещение `+4`) и сохраняется как метаданные.
- **Кандидаты:** vertex remap, дополнительные данные для эффектов/деформаций.
---
-277
View File
@@ -1,277 +0,0 @@
# 3D implementation notes
Контрольные заметки, сводки алгоритмов и остаточные семантические вопросы по 3D-подсистемам.
---
## 5.1. Порядок байт
Все значения хранятся в **littleendian** порядке (платформа x86/Win32).
## 5.2. Выравнивание
- **NRes‑ресурсы:** данные каждого ресурса внутри NRes‑архива выровнены по границе **8 байт** (0padding).
- **Внутренняя структура ресурсов:** таблицы Res1/Res2/Res7/Res13 не имеют межзаписевого выравнивания — записи идут подряд.
- **Vertex streams:** stride'ы фиксированы (12/4/8 байт) — вершинные данные идут подряд без паддинга.
## 5.3. Размеры записей на диске
| Ресурс | Запись | Размер (байт) | Stride |
|--------|-----------|---------------|-------------------------|
| Res1 | Node | 38 | 38 (19×u16) |
| Res2 | Slot | 68 | 68 |
| Res3 | Position | 12 | 12 (3×f32) |
| Res4 | Normal | 4 | 4 (4×s8) |
| Res5 | UV0 | 4 | 4 (2×s16) |
| Res6 | Index | 2 | 2 (u16) |
| Res7 | TriDesc | 16 | 16 |
| Res8 | AnimKey | 24 | 24 |
| Res10 | StringRec | переменный | `4 + (len ? len+1 : 0)` |
| Res13 | Batch | 20 | 20 |
| Res19 | AnimMap | 2 | 2 (u16) |
| Res15 | VtxStr | 8 | 8 |
| Res16 | VtxStr | 8 | 8 (2×4) |
| Res18 | VtxStr | 4 | 4 |
## 5.4. Вычисление количества элементов
Количество записей вычисляется из размера ресурса:
```
count = resource_data_size / record_stride
```
Например:
- `vertex_count = res3_size / 12`
- `index_count = res6_size / 2`
- `batch_count = res13_size / 20`
- `slot_count = (res2_size - 140) / 68`
- `node_count = res1_size / 38`
- `tri_desc_count = res7_size / 16`
- `anim_key_count = res8_size / 24`
- `anim_map_count = res19_size / 2`
Для Res10 нет фиксированного stride: нужно последовательно проходить записи `u32 len` + `(len ? len+1 : 0)` байт.
## 5.5. Идентификация ресурсов в NRes
Ресурсы модели идентифицируются по полю `type` (смещение 0) в каталожной записи NRes. Загрузчик использует `niFindRes(archive, type, subtype)` для поиска, где `type` — число (1, 2, 3, ... 20), а `subtype` (byte) — уточнение (из аргумента загрузчика).
## 5.6. Минимальный набор для рендера
Для статической модели без анимации достаточно:
| Ресурс | Обязательность |
|--------|------------------------------------------------|
| Res1 | Да |
| Res2 | Да |
| Res3 | Да |
| Res4 | Рекомендуется |
| Res5 | Рекомендуется |
| Res6 | Да |
| Res7 | Для коллизии |
| Res13 | Да |
| Res10 | Желательно (узловые имена/поведенческие ветки) |
| Res8 | Нет (анимация) |
| Res19 | Нет (анимация) |
| Res15 | Нет |
| Res16 | Нет |
| Res18 | Нет |
| Res20 | Нет |
## 5.7. Сводка алгоритмов декодирования
### Позиции (Res3)
```python
def decode_position(data, vertex_index):
offset = vertex_index * 12
x = struct.unpack_from('<f', data, offset)[0]
y = struct.unpack_from('<f', data, offset + 4)[0]
z = struct.unpack_from('<f', data, offset + 8)[0]
return (x, y, z)
```
### Нормали (Res4)
```python
def decode_normal(data, vertex_index):
offset = vertex_index * 4
nx = struct.unpack_from('<b', data, offset)[0] # int8
ny = struct.unpack_from('<b', data, offset + 1)[0]
nz = struct.unpack_from('<b', data, offset + 2)[0]
# nw = data[offset + 3] # не используется
return (
max(-1.0, min(1.0, nx / 127.0)),
max(-1.0, min(1.0, ny / 127.0)),
max(-1.0, min(1.0, nz / 127.0)),
)
```
### UV‑координаты (Res5)
```python
def decode_uv(data, vertex_index):
offset = vertex_index * 4
u = struct.unpack_from('<h', data, offset)[0] # int16
v = struct.unpack_from('<h', data, offset + 2)[0]
return (u / 1024.0, v / 1024.0)
```
### Кодирование нормали (для экспортёра)
```python
def encode_normal(nx, ny, nz):
return (
max(-128, min(127, int(round(nx * 127.0)))),
max(-128, min(127, int(round(ny * 127.0)))),
max(-128, min(127, int(round(nz * 127.0)))),
0 # nw = 0 (безопасное значение)
)
```
### Кодирование UV (для экспортёра)
```python
def encode_uv(u, v):
return (
max(-32768, min(32767, int(round(u * 1024.0)))),
max(-32768, min(32767, int(round(v * 1024.0))))
)
```
### Строки узлов (Res10)
```python
def parse_res10_for_nodes(buf: bytes, node_count: int) -> list[str | None]:
out = []
off = 0
for _ in range(node_count):
ln = struct.unpack_from('<I', buf, off)[0]
off += 4
if ln == 0:
out.append(None)
continue
raw = buf[off:off + ln + 1] # len + '\0'
out.append(raw[:-1].decode('ascii', errors='replace'))
off += ln + 1
return out
```
### Ключ анимации (Res8) и mapping (Res19)
```python
def decode_anim_key24(buf: bytes, idx: int):
o = idx * 24
px, py, pz, t = struct.unpack_from('<4f', buf, o)
qx, qy, qz, qw = struct.unpack_from('<4h', buf, o + 16)
s = 1.0 / 32767.0
return (px, py, pz), t, (qx * s, qy * s, qz * s, qw * s)
```
### Эффектный поток (FXID)
```python
FX_CMD_SIZE = {1:224,2:148,3:200,4:204,5:112,6:4,7:208,8:248,9:208,10:208}
def parse_fx_payload(raw: bytes):
cmd_count = struct.unpack_from('<I', raw, 0)[0]
ptr = 0x3C
cmds = []
for _ in range(cmd_count):
w = struct.unpack_from('<I', raw, ptr)[0]
op = w & 0xFF
enabled = (w >> 8) & 1
size = FX_CMD_SIZE[op]
cmds.append((op, enabled, ptr, size))
ptr += size
if ptr != len(raw):
raise ValueError('tail bytes after command stream')
return cmds
```
### Texm (header + mips + Page)
```python
def parse_texm(raw: bytes):
magic, w, h, mips, f4, f5, unk6, fmt = struct.unpack_from('<8I', raw, 0)
assert magic == 0x6D786554 # 'Texm'
bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444) else 4)
pix_sum = 0
mw, mh = w, h
for _ in range(mips):
pix_sum += mw * mh
mw = max(1, mw >> 1)
mh = max(1, mh >> 1)
off = 32 + (1024 if fmt == 0 else 0) + bpp * pix_sum
page = None
if off + 8 <= len(raw) and raw[off:off+4] == b'Page':
n = struct.unpack_from('<I', raw, off + 4)[0]
page = [struct.unpack_from('<4h', raw, off + 8 + i * 8) for i in range(n)]
return (w, h, mips, fmt, f4, f5, unk6, page)
```
---
# Часть 6. Остаточные семантические вопросы
Пункты ниже **не блокируют 1:1-парсинг/рендер/интерполяцию** (все бинарные структуры уже определены), но их человеко‑читаемая трактовка может быть уточнена дополнительно.
## 6.1. Batch table — смысл `unk4/unk6/unk14`
Физическое расположение полей известно, но доменное имя/назначение не зафиксировано:
- `unk4` (`+0x04`)
- `unk6` (`+0x06`)
- `unk14` (`+0x0E`)
## 6.2. Node flags и имена групп
- Биты в `Res1.hdr0` используются в ряде рантайм‑веток, но их «геймдизайн‑имена» неизвестны.
- Для group‑индекса `0..4` не найдено текстовых label'ов в ресурсах; для совместимости нужно сохранять числовой индекс как есть.
## 6.3. Slot tail `unk30..unk40`
Хвост слота (`+0x30..+0x43`, `5×uint32`) стабильно присутствует в формате, но движок не делает явной семантической декомпозиции этих пяти слов в path'ах загрузки/рендера/коллизии.
## 6.4. Effect command payload semantics
Container/stream формально полностью восстановлен (header, opcode, размеры, инстанцирование). Остаётся необязательная задача: дать «человеко‑читаемые» имена каждому полю внутри payload конкретных opcode.
## 6.5. Поля `TexmHeader.flags4/flags5/unk6`
Бинарный layout и декодер известны, но значения этих трёх полей в контенте используются контекстно; для 1:1 достаточно хранить/восстанавливать их без модификации.
## 6.6. Что пока не хватает для полноценного обратного экспорта (`OBJ -> MSH/NRes`)
Ниже перечислено то, что нужно закрыть для **lossless round-trip** и 1:1‑поведения при импорте внешней геометрии обратно в формат игры.
### A) Неполная «авторская» семантика бинарных таблиц
1. `Res2` header (`первые 0x8C`): не зафиксированы все поля и правила их вычисления при генерации нового файла (а не copy-through из оригинала).
2. `Res7` tri-descriptor: для 16‑байтной записи декодирован базовый каркас, но остаётся неформализованной часть служебных бит/полей, нужных для стабильной генерации adjacency/служебной топологии.
3. `Res13` поля `unk4/unk6/unk14`: для парсинга достаточно, но для генерации «канонических» значений из голого `OBJ` правила не определены.
4. `Res2` slot tail (`unk30..unk40`): семантика не разложена, поэтому при экспорте новых ассетов нет детерминированной формулы заполнения.
### B) Анимационный path ещё не закрыт как writer
1. Нужен полный writer для `Res8/Res19`:
- точная спецификация байтового формата на запись;
- правила генерации mapping (`Res19`) по узлам/кадрам;
- жёсткая фиксация округления как в x87 path (включая edge-case на границах кадра).
2. Правила биндинга узлов/строк (`Res10`) и `slotFlags` к runtime‑сущностям пока описаны частично и требуют формализации именно для импорта новых данных.
### C) Материалы, текстуры, эффекты для «полного ассета»
1. Для `Texm` не завершён writer, покрывающий все используемые режимы (включая palette path, mip-chain, `Page`, и правила заполнения служебных полей).
2. Для `FXID` известен контейнер/длины команд, но не завершена field-level семантика payload всех opcode для генерации новых эффектов, эквивалентных оригинальному пайплайну.
3. Экспорт только `OBJ` покрывает геометрию; для игрового ассета нужен sidecar-слой (материалы/текстуры/эффекты/анимация), иначе импорт неизбежно неполный.
### D) Что это означает на практике
1. `OBJ -> MSH` сейчас реалистичен как **ограниченный static-экспорт** (позиции/индексы/часть batch/slot структуры).
2. `OBJ -> полноценный игровой ресурс` (без потерь, с поведением 1:1) пока недостижим без закрытия пунктов A/B/C.
3. До закрытия пунктов A/B/C рекомендуется использовать режим:
- геометрия экспортируется из `OBJ`;
- неизвестные/служебные поля берутся copy-through из референсного оригинального ассета той же структуры.
-22
View File
@@ -1,22 +0,0 @@
# Форматы 3D-ресурсов движка NGI
Этот документ теперь является обзором и точкой входа в набор отдельных спецификаций.
## Структура спецификаций
1. [MSH core](msh-core.md) — геометрия, узлы, батчи, LOD, slot-матрица.
2. [MSH animation](msh-animation.md) — `Res8`, `Res19`, выбор ключей и интерполяция.
3. [Materials + Texm](materials-texm.md) — материалы, текстуры, палитры, `WEAR`, `LIGHTMAPS`, `Texm`.
4. [FXID](fxid.md) — контейнер эффекта и команды runtime-потока.
5. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру.
6. [Runtime pipeline](runtime-pipeline.md) — межмодульное поведение движка в кадре.
7. [3D implementation notes](msh-notes.md) — контрольные заметки, декодирование и открытые вопросы.
## Связанные спецификации
- [NRes / RsLi](nres.md)
## Принцип декомпозиции
- Форматы и контейнеры документируются отдельно, чтобы их можно было верифицировать и править независимо.
- Runtime-пайплайн вынесен в отдельный документ, потому что пересекает несколько DLL и не является форматом на диске.
-5
View File
@@ -1,5 +0,0 @@
# Network system
Документ описывает сетевую подсистему: протокол обмена, синхронизацию состояния и сетевую архитектуру (client-server/P2P).
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Net.dll`.
-718
View File
@@ -1,718 +0,0 @@
# Форматы игровых ресурсов
## Обзор
Библиотека `Ngi32.dll` реализует два различных формата архивов ресурсов:
1. **NRes** — основной формат архива ресурсов, используемый через API `niOpenResFile` / `niCreateResFile`. Каталог файлов расположен в **конце** файла. Поддерживает создание, редактирование, добавление и удаление записей.
2. **RsLi** — формат библиотеки ресурсов, используемый через API `rsOpenLib` / `rsLoad`. Таблица записей расположена **в начале** файла (сразу после заголовка) и зашифрована XOR-шифром. Поддерживает несколько методов сжатия. Только чтение.
---
## Часть 1. Формат NRes
### 1.1. Общая структура файла
```
┌──────────────────────────┐ Смещение 0
│ Заголовок (16 байт) │
├──────────────────────────┤ Смещение 16
│ │
│ Данные ресурсов │
│ (выровнены по 8 байт) │
│ │
├──────────────────────────┤ Смещение = total_size - entry_count × 64
│ Каталог записей │
│ (entry_count × 64 байт) │
└──────────────────────────┘ Смещение = total_size
```
### 1.2. Заголовок файла (16 байт)
| Смещение | Размер | Тип | Значение | Описание |
| -------- | ------ | ------- | ------------------- | ------------------------------------ |
| 0 | 4 | char[4] | `NRes` (0x4E526573) | Магическая сигнатура (little-endian) |
| 4 | 4 | uint32 | `0x00000100` (256) | Версия формата (1.0) |
| 8 | 4 | int32 | — | Количество записей в каталоге |
| 12 | 4 | int32 | — | Полный размер файла в байтах |
**Валидация при открытии:** магическая сигнатура и версия должны совпадать точно. Поле `total_size` (смещение 12) **проверяется на равенство** с фактическим размером файла (`GetFileSize`). Если значения не совпадают — файл отклоняется.
### 1.3. Положение каталога в файле
Каталог располагается в самом конце файла. Его смещение вычисляется по формуле:
```
directory_offset = total_size - entry_count × 64
```
Данные ресурсов занимают пространство между заголовком (16 байт) и каталогом.
### 1.4. Запись каталога (64 байта)
Каждая запись каталога занимает ровно **64 байта** (0x40):
| Смещение | Размер | Тип | Описание |
| -------- | ------ | -------- | ------------------------------------------------- |
| 0 | 4 | uint32 | Тип / идентификатор ресурса |
| 4 | 4 | uint32 | Атрибут 1 (например, формат, дата, категория) |
| 8 | 4 | uint32 | Атрибут 2 (например, подтип, метка времени) |
| 12 | 4 | uint32 | Размер данных ресурса в байтах |
| 16 | 4 | uint32 | Атрибут 3 (дополнительный параметр) |
| 20 | 36 | char[36] | Имя файла (null-terminated, макс. 35 символов) |
| 56 | 4 | uint32 | Смещение данных от начала файла |
| 60 | 4 | uint32 | Индекс сортировки (для двоичного поиска по имени) |
#### Поле «Имя файла» (смещение 20, 36 байт)
- Максимальная длина имени: **35 символов** + 1 байт null-терминатор.
- При записи поле сначала обнуляется (`memset(0, 36 байт)`), затем копируется имя (`strncpy`, макс. 35 символов).
- Поиск по имени выполняется **без учёта регистра** (`_strcmpi`).
#### Поле «Индекс сортировки» (смещение 60)
Используется для **двоичного поиска по имени**. Содержит индекс оригинальной записи, отсортированной в алфавитном порядке (регистронезависимо). Индекс строится при сохранении файла функцией `sub_10013260` с помощью **пузырьковой сортировки** по именам.
**Алгоритм поиска** (`sub_10011E60`): классический двоичный поиск по отсортированному массиву индексов. Возвращает оригинальный индекс записи или `-1` при отсутствии.
#### Поле «Смещение данных» (смещение 56)
Абсолютное смещение от начала файла. Данные читаются из mapped view: `pointer = mapped_base + data_offset`.
### 1.5. Выравнивание данных
При добавлении ресурса его данные записываются последовательно, после чего выполняется **выравнивание по 8-байтной границе**:
```c
padding = ((data_size + 7) & ~7) - data_size;
// Если padding > 0, записываются нулевые байты
```
Таким образом, каждый блок данных начинается с адреса, кратного 8.
При изменении размера данных ресурса выполняется сдвиг всех последующих данных и обновление смещений всех затронутых записей каталога.
### 1.6. Создание файла (API `niCreateResFile`)
При создании нового файла:
1. Если файл уже существует и содержит корректный NRes-архив, существующий каталог считывается с конца файла, а файл усекается до начала каталога.
2. Если файл пуст или не является NRes-архивом, создаётся новый с пустым каталогом. Поля `entry_count = 0`, `total_size = 16`.
При закрытии файла (`sub_100122D0`):
1. Заголовок переписывается в начало файла (16 байт).
2. Вычисляется `total_size = data_end_offset + entry_count × 64`.
3. Индексы сортировки пересчитываются.
4. Каталог записей записывается в конец файла.
### 1.7. Режимы сортировки каталога
Функция `sub_10012560` поддерживает 12 режимов сортировки (0–11):
| Режим | Порядок сортировки |
| ----- | --------------------------------- |
| 0 | Без сортировки (сброс) |
| 1 | По атрибуту 1 (смещение 4) |
| 2 | По атрибуту 2 (смещение 8) |
| 3 | По (атрибут 1, атрибут 2) |
| 4 | По типу ресурса (смещение 0) |
| 5 | По (тип, атрибут 1) |
| 6 | По (тип, атрибут 1) — идентичен 5 |
| 7 | По (тип, атрибут 1, атрибут 2) |
| 8 | По имени (регистронезависимо) |
| 9 | По (тип, имя) |
| 10 | По (атрибут 1, имя) |
| 11 | По (атрибут 2, имя) |
### 1.8. Операция `niOpenResFileEx` — флаги открытия
Второй параметр — битовые флаги:
| Бит | Маска | Описание |
| --- | ----- | ----------------------------------------------------------------------------------- |
| 0 | 0x01 | Sequential scan hint (`FILE_FLAG_SEQUENTIAL_SCAN` вместо `FILE_FLAG_RANDOM_ACCESS`) |
| 1 | 0x02 | Открыть для записи (read-write). Без флага — только чтение |
| 2 | 0x04 | Пометить файл как «кэшируемый» (не выгружать при refcount=0) |
| 3 | 0x08 | Raw-режим: не проверять заголовок NRes, трактовать весь файл как единый ресурс |
### 1.9. Виртуальное касание страниц
Функция `sub_100197D0` выполняет «касание» страниц памяти для принудительной загрузки из memory-mapped файла. Она обходит адресное пространство с шагом 4096 байт (размер страницы), начиная с 0x10000 (64 КБ):
```
for (result = 0x10000; result < size; result += 4096);
```
Вызывается при чтении данных ресурса с флагом `a3 != 0` для предзагрузки данных в оперативную память.
---
## Часть 2. Формат RsLi
### 2.1. Общая структура файла
```
┌───────────────────────────────┐ Смещение 0
│ Заголовок файла (32 байта) │
├───────────────────────────────┤ Смещение 32
│ Таблица записей (зашифрована)│
│ (entry_count × 32 байт) │
├───────────────────────────────┤ Смещение 32 + entry_count × 32
│ │
│ Данные ресурсов │
│ │
├───────────────────────────────┤
│ [Опциональный трейлер — 6 б] │
└───────────────────────────────┘
```
### 2.2. Заголовок файла (32 байта)
| Смещение | Размер | Тип | Значение | Описание |
| -------- | ------ | ------- | ----------------- | --------------------------------------------- |
| 0 | 2 | char[2] | `NL` (0x4C4E) | Магическая сигнатура |
| 2 | 1 | uint8 | `0x00` | Зарезервировано (должно быть 0) |
| 3 | 1 | uint8 | `0x01` | Версия формата |
| 4 | 2 | int16 | — | Количество записей (sign-extended при чтении) |
| 6 | 8 | — | — | Зарезервировано / не используется |
| 14 | 2 | uint16 | `0xABBA` или иное | Флаг предсортировки (см. ниже) |
| 16 | 4 | — | — | Зарезервировано |
| 20 | 4 | uint32 | — | **Начальное состояние XOR-шифра** (seed) |
| 24 | 8 | — | — | Зарезервировано |
#### Флаг предсортировки (смещение 14)
- Если `*(uint16*)(header + 14) == 0xABBA` — движок **не строит** таблицу индексов в памяти. Значения `entry[i].sort_to_original` используются **как есть** (и для двоичного поиска, и как XOR‑ключ для данных).
- Если значение **отлично от 0xABBA** — после загрузки выполняется **пузырьковая сортировка** имён и строится перестановка `sort_to_original[]`, которая затем **записывается в `entry[i].sort_to_original`**, перетирая значения из файла. Именно эта перестановка далее используется и для поиска, и как XOR‑ключ (младшие 16 бит).
### 2.3. XOR-шифр таблицы записей
Таблица записей начинается со смещения 32 и зашифрована поточным XOR-шифром. Ключ инициализируется из DWORD по смещению 20 заголовка.
#### Начальное состояние
```
seed = *(uint32*)(header + 20)
lo = seed & 0xFF // Младший байт
hi = (seed >> 8) & 0xFF // Второй байт
```
#### Алгоритм дешифровки (побайтовый)
Для каждого зашифрованного байта `encrypted[i]`, начиная с `i = 0`:
```
step 1: lo = hi ^ ((lo << 1) & 0xFF) // Сдвиг lo влево на 1, XOR с hi
step 2: decrypted[i] = lo ^ encrypted[i] // Расшифровка байта
step 3: hi = lo ^ ((hi >> 1) & 0xFF) // Сдвиг hi вправо на 1, XOR с lo
```
**Пример реализации:**
```python
def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
lo = seed & 0xFF
hi = (seed >> 8) & 0xFF
result = bytearray(len(encrypted_data))
for i in range(len(encrypted_data)):
lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
result[i] = lo ^ encrypted_data[i]
hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
return bytes(result)
```
Этот же алгоритм используется для шифрования данных ресурсов с методом XOR (флаги 0x20, 0x60, 0xA0), но с другим начальным ключом из записи.
### 2.4. Запись таблицы (32 байта, на диске, до дешифровки)
После дешифровки каждая запись имеет следующую структуру:
| Смещение | Размер | Тип | Описание |
| -------- | ------ | -------- | -------------------------------------------------------------- |
| 0 | 12 | char[12] | Имя ресурса (ASCII, обычно uppercase; строка читается до `\0`) |
| 12 | 4 | — | Зарезервировано (движком игнорируется) |
| 16 | 2 | int16 | **Флаги** (метод сжатия и атрибуты) |
| 18 | 2 | int16 | **`sort_to_original[i]` / XOR‑ключ** (см. ниже) |
| 20 | 4 | uint32 | **Размер распакованных данных** (`unpacked_size`) |
| 24 | 4 | uint32 | Смещение данных от начала файла (`data_offset`) |
| 28 | 4 | uint32 | Размер упакованных данных в байтах (`packed_size`) |
#### Имена ресурсов
- Поле `name[12]` копируется побайтно. Внутренне движок всегда имеет `\0` сразу после этих 12 байт (зарезервированные 4 байта в памяти принудительно обнуляются), поэтому имя **может быть длиной до 12 символов** даже без `\0` внутри `name[12]`.
- На практике имена обычно **uppercase ASCII**. `rsFind` приводит запрос к верхнему регистру (`_strupr`) и сравнивает побайтно.
- `rsFind` копирует имя запроса `strncpy(..., 16)` и принудительно ставит `\0` в `Destination[15]`, поэтому запрос длиннее 15 символов будет усечён.
#### Поле `sort_to_original[i]` (смещение 18)
Это **не “свойство записи”**, а элемент таблицы индексов, по которой `rsFind` делает двоичный поиск:
- Таблица реализована “внутри записей”: значение берётся как `entry[i].sort_to_original` (где `i` — позиция двоичного поиска), а реальная запись для сравнения берётся как `entry[ sort_to_original[i] ]`.
- Тем же значением (младшие 16 бит) инициализируется XOR‑шифр данных для методов, где он используется (0x20/0x60/0xA0). Поэтому при упаковке/шифровании данных ключ должен совпадать с итоговым `sort_to_original[i]` (см. флаг 0xABBA в разделе 2.2).
Поиск выполняется **двоичным поиском** по этой таблице, с фолбэком на **линейный поиск** если двоичный не нашёл (поведение `rsFind`).
### 2.5. Поле флагов (смещение 16 записи)
Биты поля флагов кодируют метод сжатия и дополнительные атрибуты:
```
Биты [8:5] (маска 0x1E0): Метод сжатия/шифрования
Бит [6] (маска 0x040): Флаг realloc (буфер декомпрессии может быть больше)
```
#### Методы сжатия (биты 8–5, маска 0x1E0)
| Значение | Hex | Описание |
| -------- | ----- | --------------------------------------- |
| 0x000 | 0x00 | Без сжатия (копирование) |
| 0x020 | 0x20 | Только XOR-шифр |
| 0x040 | 0x40 | LZSS (простой вариант) |
| 0x060 | 0x60 | XOR-шифр + LZSS (простой вариант) |
| 0x080 | 0x80 | LZSS с адаптивным кодированием Хаффмана |
| 0x0A0 | 0xA0 | XOR-шифр + LZSS с Хаффманом |
| 0x100 | 0x100 | Deflate (аналог zlib/RFC 1951) |
Примечание: `rsGetPackMethod()` возвращает `flags & 0x1C0` (без бита 0x20). Поэтому:
- для 0x20 вернётся 0x00,
- для 0x60 вернётся 0x40,
- для 0xA0 вернётся 0x80.
#### Бит 0x40 (выделение +0x12 и последующее `realloc`)
Бит 0x40 проверяется отдельно (`flags & 0x40`). Если он установлен, выходной буфер выделяется с запасом `+0x12` (18 байт), а после распаковки вызывается `realloc` для усечения до точного `unpacked_size`.
Важно: этот же бит входит в код методов 0x40/0x60, поэтому для них поведение “+0x12 и shrink” включено автоматически.
### 2.6. Размеры данных
В каждой записи на диске хранятся оба значения:
- `unpacked_size` (смещение 20) — размер распакованных данных.
- `packed_size` (смещение 28) — размер упакованных данных (байт во входном потоке для выбранного метода).
Для метода 0x00 (без сжатия) обычно `packed_size == unpacked_size`.
`rsGetInfo` возвращает именно `unpacked_size` (то, сколько байт выдаст `rsLoad`).
Практический нюанс для метода `0x100` (Deflate): в реальных игровых данных встречается запись, где `packed_size` указывает на диапазон до `EOF + 1`. Поток успешно декодируется и без последнего байта; это похоже на lookahead-поведение декодера.
### 2.7. Опциональный трейлер медиа (6 байт)
При открытии с флагом `a2 & 2`:
| Смещение от конца | Размер | Тип | Описание |
| ----------------- | ------ | ------- | ----------------------- |
| 6 | 2 | char[2] | Сигнатура `AO` (0x4F41) |
| 4 | 4 | uint32 | Смещение медиа-оверлея |
Если трейлер присутствует, все смещения данных в записях корректируются: `effective_offset = entry_offset + media_overlay_offset`.
---
## Часть 3. Алгоритмы сжатия (формат RsLi)
### 3.1. XOR-шифр данных (метод 0x20)
Алгоритм идентичен XOR‑шифру таблицы записей (раздел 2.3), но начальный ключ берётся из `entry[i].sort_to_original` (смещение 18 записи, младшие 16 бит).
Важно про размер входа:
- В ветке **0x20** движок XOR‑ит ровно `unpacked_size` байт (и ожидает, что поток данных имеет ту же длину; на практике `packed_size == unpacked_size`).
- В ветках **0x60/0xA0** XOR применяется к **упакованному** потоку длиной `packed_size` перед декомпрессией.
#### Инициализация
```
key16 = (uint16)entry.sort_to_original // int16 на диске по смещению 18
lo = key16 & 0xFF
hi = (key16 >> 8) & 0xFF
```
#### Дешифровка (псевдокод)
```
for i in range(N): # N = unpacked_size (для 0x20) или packed_size (для 0x60/0xA0)
lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
out[i] = in[i] ^ lo
hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
```
### 3.2. LZSS — простой вариант (метод 0x40)
Классический алгоритм LZSS (Lempel-Ziv-Storer-Szymanski) с кольцевым буфером.
#### Параметры
| Параметр | Значение |
| ----------------------------- | ------------------ |
| Размер кольцевого буфера | 4096 байт (0x1000) |
| Начальная позиция записи | 4078 (0xFEE) |
| Начальное заполнение | 0x20 (пробел) |
| Минимальная длина совпадения | 3 |
| Максимальная длина совпадения | 18 (4 бита + 3) |
#### Алгоритм декомпрессии
```
Инициализация:
ring_buffer[0..4095] = 0x20 (заполнить пробелами)
ring_pos = 4078
flags_byte = 0
flags_bits_remaining = 0
Цикл (пока не заполнен выходной буфер И не исчерпан входной):
1. Если flags_bits_remaining == 0:
- Прочитать 1 байт из входного потока → flags_byte
- flags_bits_remaining = 8
Декодировать как:
- Старший бит устанавливается в 0x7F (маркер)
- Оставшиеся 7 бит — флаги текущей группы
Реально в коде: control_word = (flags_byte) | (0x7F << 8)
Каждый бит проверяется сдвигом вправо.
2. Проверить младший бит control_word:
Если бит = 1 (литерал):
- Прочитать 1 байт из входного потока → byte
- ring_buffer[ring_pos] = byte
- ring_pos = (ring_pos + 1) & 0xFFF
- Записать byte в выходной буфер
Если бит = 0 (ссылка):
- Прочитать 2 байта: low_byte, high_byte
- offset = low_byte | ((high_byte & 0xF0) << 4) // 12 бит
- length = (high_byte & 0x0F) + 3 // 4 бита + 3
- Скопировать length байт из ring_buffer[offset...]:
для j от 0 до length-1:
byte = ring_buffer[(offset + j) & 0xFFF]
ring_buffer[ring_pos] = byte
ring_pos = (ring_pos + 1) & 0xFFF
записать byte в выходной буфер
3. Сдвинуть control_word вправо на 1 бит
4. flags_bits_remaining -= 1
```
#### Подробная раскладка пары ссылки (2 байта)
```
Байт 0 (low): OOOOOOOO (биты [7:0] смещения)
Байт 1 (high): OOOOLLLL O = биты [11:8] смещения, L = длина − 3
offset = low | ((high & 0xF0) << 4) // Диапазон: 04095
length = (high & 0x0F) + 3 // Диапазон: 3–18
```
### 3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80)
Расширенный вариант LZSS, где литералы и длины совпадений кодируются с помощью адаптивного дерева Хаффмана.
#### Параметры
| Параметр | Значение |
| -------------------------------- | ------------------------------ |
| Размер кольцевого буфера | 4096 байт |
| Начальная позиция записи | **4036** (0xFC4) |
| Начальное заполнение | 0x20 (пробел) |
| Количество листовых узлов дерева | 314 |
| Символы литералов | 0–255 (байты) |
| Символы длин | 256–313 (длина = символ − 253) |
| Начальная длина | 3 (при символе 256) |
| Максимальная длина | 60 (при символе 313) |
#### Дерево Хаффмана
Дерево строится как **адаптивное** (dynamic, self-adjusting):
- **627 узлов**: 314 листовых + 313 внутренних.
- Все листья изначально имеют **вес 1**.
- Корень дерева — узел с индексом 0 (в массиве `parent`).
- После декодирования каждого символа дерево **обновляется** (функция `sub_1001B0AE`): вес узла инкрементируется, и при нарушении порядка узлы **переставляются** для поддержания свойства.
- При достижении суммарного веса **0x8000 (32768)** — все веса **делятся на 2** (с округлением вверх) и дерево полностью перестраивается.
#### Кодирование позиции
Позиция в кольцевом буфере кодируется с помощью **d-кода** (таблица дистанций):
- 8 бит позиции ищутся в таблице `d_code[256]`, определяя базовое значение и количество дополнительных битов.
- Из потока считываются дополнительные биты, которые объединяются с базовым значением.
- Финальная позиция: `pos = (ring_pos 1 decoded_position) & 0xFFF`
**Таблицы инициализации** (d-коды):
```
Таблица базовых значений — byte_100371D0[6]:
{ 0x01, 0x03, 0x08, 0x0C, 0x18, 0x10 }
Таблица дополнительных битов — byte_100371D6[6]:
{ 0x20, 0x30, 0x40, 0x30, 0x30, 0x10 }
```
#### Алгоритм декомпрессии (высокоуровневый)
```
Инициализация:
ring_buffer[0..4095] = 0x20
ring_pos = 4036
Инициализировать дерево Хаффмана (314 листьев, все веса = 1)
Инициализировать таблицы d-кодов
Цикл:
1. Декодировать символ из потока по дереву Хаффмана:
- Начать с корня
- Читать биты, спускаться по дереву (0 = левый, 1 = правый)
- Пока не достигнут лист → символ = лист − 627
2. Обновить дерево Хаффмана для декодированного символа
3. Если символ < 256 (литерал):
- ring_buffer[ring_pos] = символ
- ring_pos = (ring_pos + 1) & 0xFFF
- Записать символ в выходной буфер
4. Если символ >= 256 (ссылка):
- length = символ 253
- Декодировать позицию через d-код:
a) Прочитать 8 бит из потока
b) Найти d-код и дополнительные биты по таблице
c) Прочитать дополнительные биты
d) position = (ring_pos 1 full_position) & 0xFFF
- Скопировать length байт из ring_buffer[position...]
5. Если выходной буфер заполнен → завершить
```
### 3.4. XOR + LZSS (методы 0x60 и 0xA0)
Комбинированный метод: сначала XOR-дешифровка, затем LZSS-декомпрессия.
#### Алгоритм
1. Выделить временный буфер размером `compressed_size` (поле из записи, смещение 28).
2. Дешифровать сжатые данные XOR-шифром (раздел 3.1) с ключом из записи во временный буфер.
3. Применить LZSS-декомпрессию (простую или с Хаффманом, в зависимости от конкретного метода) из временного буфера в выходной.
4. Освободить временный буфер.
- **0x60** — XOR + простой LZSS (раздел 3.2)
- **0xA0** — XOR + LZSS с Хаффманом (раздел 3.3)
#### Начальное состояние XOR для данных
При комбинированном методе seed берётся из поля по смещению 20 записи (4-байтный). Однако ключ обрабатывается как 16-битный: `lo = seed & 0xFF`, `hi = (seed >> 8) & 0xFF`.
### 3.5. Deflate (метод 0x100)
Полноценная реализация алгоритма **Deflate** (RFC 1951) с блочной структурой.
#### Общая структура
Данные состоят из последовательности блоков. Каждый блок начинается с:
- **1 бит**`is_final`: признак последнего блока
- **2 бита**`block_type`: тип блока
#### Типы блоков
| block_type | Описание | Функция |
| ---------- | --------------------------- | ---------------- |
| 0 | Без сжатия (stored) | `sub_1001A750` |
| 1 | Фиксированные коды Хаффмана | `sub_1001A8C0` |
| 2 | Динамические коды Хаффмана | `sub_1001AA30` |
| 3 | Зарезервировано (ошибка) | Возвращает код 2 |
#### Блок типа 0 (stored)
1. Отбросить оставшиеся биты до границы байта (выравнивание).
2. Прочитать 16 бит — `LEN` (длина блока).
3. Прочитать 16 бит — `NLEN` (дополнение длины, `NLEN == ~LEN & 0xFFFF`).
4. Проверить: `LEN == (uint16)(~NLEN)`. При несовпадении — ошибка.
5. Скопировать `LEN` байт из входного потока в выходной.
Декомпрессор использует внутренний буфер размером **32768 байт** (0x8000). При заполнении — промежуточная запись результата.
#### Блок типа 1 (фиксированные коды)
Стандартные коды Deflate:
- Литералы/длины: 288 кодов
- 0–143: 8-битные коды
- 144–255: 9-битные коды
- 256–279: 7-битные коды
- 280–287: 8-битные коды
- Дистанции: 30 кодов, все 5-битные
Используются предопределённые таблицы длин и дистанций (`unk_100370AC`, `unk_1003712C` и соответствующие экстра-биты).
#### Блок типа 2 (динамические коды)
1. Прочитать 5 бит → `HLIT` (количество литералов/длин − 257). Диапазон: 257286.
2. Прочитать 5 бит → `HDIST` (количество дистанций − 1). Диапазон: 1–30.
3. Прочитать 4 бита → `HCLEN` (количество кодов длин − 4). Диапазон: 4–19.
4. Прочитать `HCLEN` × 3 бит — длины кодов для алфавита длин.
5. Построить дерево Хаффмана для алфавита длин (19 символов).
6. С помощью этого дерева декодировать длины кодов для литералов/длин и дистанций.
7. Построить два дерева Хаффмана: для литералов/длин и для дистанций.
8. Декодировать данные.
**Порядок кодов длин** (стандартный Deflate):
```
{ 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 }
```
Хранится в `dword_10037060`.
#### Валидации
- `HLIT + 257 <= 286` (max 0x11E)
- `HDIST + 1 <= 30` (max 0x1E)
- При нарушении — возвращается ошибка 1.
### 3.6. Метод 0x00 (без сжатия)
Данные копируются «как есть» напрямую из файла. Вызывается через указатель на функцию `dword_1003A1B8` (фактически `memcpy` или аналог).
---
## Часть 4. Внутренние структуры в памяти
### 4.1. Внутренняя структура NRes-архива (opened, 0x68 байт = 104)
```c
struct NResArchive { // Размер: 0x68 (104 байта)
void* vtable; // +0: Указатель на таблицу виртуальных методов
int32_t entry_count; // +4: Количество записей
void* mapped_base; // +8: Базовый адрес mapped view
void* directory_ptr; // +12: Указатель на каталог записей в памяти
char* filename; // +16: Путь к файлу (_strdup)
int32_t ref_count; // +20: Счётчик ссылок
uint32_t last_release_time; // +24: timeGetTime() при последнем Release
// +28..+91: Для raw-режима — встроенная запись (единственный File entry)
NResArchive* next; // +92: Следующий архив в связном списке
uint8_t is_writable; // +100: Файл открыт для записи
uint8_t is_cacheable; // +101: Не выгружать при refcount = 0
};
```
### 4.2. Внутренняя структура RsLi-архива (56 + 64 × N байт)
```c
struct RsLibHeader { // 56 байт (14 DWORD)
uint32_t magic; // +0: 'RsLi' (0x694C7352)
int32_t entry_count; // +4: Количество записей
uint32_t media_offset; // +8: Смещение медиа-оверлея
uint32_t reserved_0C; // +12: 0
HANDLE file_handle_2; // +16: -1 (дополнительный хэндл)
uint32_t reserved_14; // +20: 0
uint32_t reserved_18; // +24: —
uint32_t reserved_1C; // +28: 0
HANDLE mapping_handle_2; // +32: -1
uint32_t reserved_24; // +36: 0
uint32_t flag_28; // +40: (flags >> 7) & 1
HANDLE file_handle; // +44: Хэндл файла
HANDLE mapping_handle; // +48: Хэндл файлового маппинга
void* mapped_view; // +52: Указатель на mapped view
};
// Далее следуют entry_count записей по 64 байта каждая
```
#### Внутренняя запись RsLi (64 байта)
```c
struct RsLibEntry { // 64 байта (16 DWORD)
char name[16]; // +0: Имя (12 из файла + 4 нуля)
int32_t flags; // +16: Флаги (sign-extended из int16)
int32_t sort_index; // +20: sort_to_original[i] (таблица индексов / XOR‑ключ)
uint32_t uncompressed_size; // +24: Размер несжатых данных (из поля 20 записи)
void* data_ptr; // +28: Указатель на данные в mapped view
uint32_t compressed_size; // +32: Размер сжатых данных (из поля 28 записи)
uint32_t reserved_24; // +36: 0
uint32_t reserved_28; // +40: 0
uint32_t reserved_2C; // +44: 0
void* loaded_data; // +48: Указатель на декомпрессированные данные
// +52..+63: дополнительные поля
};
```
---
## Часть 5. Экспортируемые API-функции
### 5.1. NRes API
| Функция | Описание |
| ------------------------------ | ------------------------------------------------------------------------- |
| `niOpenResFile(path)` | Открыть NRes-архив (только чтение), эквивалент `niOpenResFileEx(path, 0)` |
| `niOpenResFileEx(path, flags)` | Открыть NRes-архив с флагами |
| `niOpenResInMem(ptr, size)` | Открыть NRes-архив из памяти |
| `niCreateResFile(path)` | Создать/открыть NRes-архив для записи |
### 5.2. RsLi API
| Функция | Описание |
| ------------------------------- | -------------------------------------------------------- |
| `rsOpenLib(path, flags)` | Открыть RsLi-библиотеку |
| `rsCloseLib(lib)` | Закрыть библиотеку |
| `rsLibNum(lib)` | Получить количество записей |
| `rsFind(lib, name)` | Найти запись по имени (→ индекс или −1) |
| `rsLoad(lib, index)` | Загрузить и декомпрессировать ресурс |
| `rsLoadFast(lib, index, flags)` | Быстрая загрузка (без декомпрессии если возможно) |
| `rsLoadPacked(lib, index)` | Загрузить в «упакованном» виде (отложенная декомпрессия) |
| `rsLoadByName(lib, name)` | `rsFind` + `rsLoad` |
| `rsGetInfo(lib, index, out)` | Получить имя и размер ресурса |
| `rsGetPackMethod(lib, index)` | Получить метод сжатия (`flags & 0x1C0`) |
| `ngiUnpack(packed)` | Декомпрессировать ранее загруженный упакованный ресурс |
| `ngiAlloc(size)` | Выделить память (с обработкой ошибок) |
| `ngiFree(ptr)` | Освободить память |
| `ngiGetMemSize(ptr)` | Получить размер выделенного блока |
---
## Часть 6. Контрольные заметки для реализации
### 6.1. Кодировки и регистр
- **NRes**: имена хранятся **как есть** (case-insensitive при поиске через `_strcmpi`).
- **RsLi**: имена хранятся в **верхнем регистре**. Перед поиском запрос приводится к верхнему регистру (`_strupr`). Сравнение — через `strcmp` (case-sensitive для уже uppercase строк).
### 6.2. Порядок байт
Все значения хранятся в **little-endian** порядке (платформа x86/Win32).
### 6.3. Выравнивание
- **NRes**: данные каждого ресурса выровнены по границе **8 байт** (0-padding между файлами).
- **RsLi**: выравнивание данных не описано в коде (данные идут подряд).
### 6.4. Размер записей на диске
- **NRes**: каталог — **64 байта** на запись, расположен в конце файла.
- **RsLi**: таблица — **32 байта** на запись (зашифрованная), расположена в начале файла (сразу после 32-байтного заголовка).
### 6.5. Кэширование и memory mapping
Оба формата используют Windows Memory-Mapped Files (`CreateFileMapping` + `MapViewOfFile`). NRes-архивы организованы в глобальный **связный список** (`dword_1003A66C`) со счётчиком ссылок и таймером неактивности (10 секунд = 0x2710 мс). При refcount == 0 и истечении таймера архив автоматически выгружается (если не установлен флаг `is_cacheable`).
### 6.6. Размер seed XOR
- **Заголовок RsLi**: seed — **4 байта** (DWORD) по смещению 20, но используются только младшие 2 байта (`lo = byte[0]`, `hi = byte[1]`).
- **Запись RsLi**: sort_to_original[i] — **2 байта** (int16) по смещению 18 записи.
- **Данные при комбинированном XOR+LZSS**: seed — **4 байта** (DWORD) из поля по смещению 20 записи, но опять используются только 2 байта.
### 6.7. Эмпирическая проверка на данных игры
- Найдено архивов по сигнатуре: **122** (`NRes`: 120, `RsLi`: 2).
- Выполнен полный roundtrip `unpack -> pack -> byte-compare`: **122/122** архивов совпали побайтно.
- Для `RsLi` в проверенном наборе встретились методы: `0x040` и `0x100`.
Подтверждённые нюансы:
- Для LZSS (метод `0x040`) рабочая раскладка нибблов в ссылке: `OOOO LLLL`, а не `LLLL OOOO`.
- Для Deflate (метод `0x100`) возможен случай `packed_size == фактический_конец + 1` на последней записи файла.
-123
View File
@@ -1,123 +0,0 @@
# Runtime pipeline
Документ фиксирует runtime-поведение движка: кто кого вызывает в кадре, как проходят рендер, коллизия и подключение эффектов.
---
## 1.15. Алгоритм рендера модели (реконструкция)
```
Вход: model, instanceTransform, cameraFrustum
1. Определить current_lod ∈ {0, 1, 2} (по дистанции до камеры / настройкам).
2. Для каждого node (nodeIndex = 0 .. nodeCount1):
a. Вычислить nodeTransform = instanceTransform × nodeLocalTransform
b. slotIndex = nodeTable[nodeIndex].slotMatrix[current_lod][group=0]
если slotIndex == 0xFFFF → пропустить узел
c. slot = slotTable[slotIndex]
d. // Frustum culling:
transformedAABB = transform(slot.aabb, nodeTransform)
если transformedAABB вне cameraFrustum → пропустить
// Альтернативно по сфере:
transformedCenter = nodeTransform × slot.sphereCenter
scaledRadius = slot.sphereRadius × max(scaleX, scaleY, scaleZ)
если сфера вне frustum → пропустить
e. Для i = 0 .. slot.batchCount 1:
batch = batchTable[slot.batchStart + i]
// Фильтрация по batchFlags (если нужна)
// Установить материал:
setMaterial(batch.materialIndex)
// Установить transform:
setWorldMatrix(nodeTransform)
// Нарисовать:
DrawIndexedPrimitive(
baseVertex = batch.baseVertex,
indexStart = batch.indexStart,
indexCount = batch.indexCount,
primitiveType = TRIANGLE_LIST
)
```
---
## 1.16. Алгоритм обхода треугольников (коллизия / пикинг)
```
Вход: model, nodeIndex, lod, group, filterMask, callback
1. slotIndex = nodeTable[nodeIndex].slotMatrix[lod][group]
если slotIndex == 0xFFFF → выход
2. slot = slotTable[slotIndex]
triDescIndex = slot.triStart
3. Для каждого batch в диапазоне [slot.batchStart .. slot.batchStart + slot.batchCount 1]:
batch = batchTable[batchIndex]
triCount = batch.indexCount / 3 // округление: (indexCount + 2) / 3
Для t = 0 .. triCount 1:
triDesc = triDescTable[triDescIndex]
// Фильтрация:
если (triDesc.triFlags & filterMask) → пропустить
// Получить индексы вершин:
idx0 = indexBuffer[batch.indexStart + t*3 + 0] + batch.baseVertex
idx1 = indexBuffer[batch.indexStart + t*3 + 1] + batch.baseVertex
idx2 = indexBuffer[batch.indexStart + t*3 + 2] + batch.baseVertex
// Получить позиции:
p0 = positions[idx0]
p1 = positions[idx1]
p2 = positions[idx2]
callback(triDesc, idx0, idx1, idx2, p0, p1, p2)
triDescIndex += 1
```
---
---
## 3.1. Архитектурный обзор
Подсистема эффектов реализована в `Effect.dll` и интегрирована в рендер через `Terrain.dll`.
### Экспорты Effect.dll
| Функция | Описание |
|----------------------|--------------------------------------------------------|
| `CreateFxManager` | Создать менеджер эффектов (3 параметра: int, int, int) |
| `InitializeSettings` | Инициализировать настройки эффектов |
`CreateFxManager` возвращает объект‑менеджер, который регистрируется в движке и управляет всеми эффектами.
### Телеметрия из Terrain.dll
Terrain.dll содержит отладочную статистику рендера:
```
"Rendered meshes : %d"
"Rendered primitives : %d"
"Rendered faces : %d"
"Rendered particles/batches : %d/%d"
```
Из этого следует:
- Частицы рендерятся **батчами** (группами).
- Статистика частиц отделена от статистики мешей.
- Частицы интегрированы в общий 3D‑рендер‑пайплайн.
-5
View File
@@ -1,5 +0,0 @@
# Sound system
Документ описывает аудиоподсистему: форматы звуковых ресурсов, воспроизведение эффектов и голосов, а также интеграцию со звуковым API.
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга звуковых модулей движка.
-32
View File
@@ -1,32 +0,0 @@
# Terrain + map loading
Документ описывает подсистему ландшафта и привязку terrain-данных к миру.
---
## 4.1. Обзор
`Terrain.dll` отвечает за рендер ландшафта (terrain), включая:
- Рендер мешей ландшафта (`"Rendered meshes"`, `"Rendered primitives"`, `"Rendered faces"`).
- Рендер частиц (`"Rendered particles/batches"`).
- Создание текстур (`"CTexture::CTexture()"` — конструктор текстуры).
- Микротекстуры (`"Unable to find microtexture mapping"`).
## 4.2. Текстуры ландшафта
В Terrain.dll присутствует конструктор текстуры `CTexture::CTexture()` со следующими проверками:
- Валидация размера текстуры (`"Unsupported texture size"`).
- Создание D3D‑текстуры (`"Unable to create texture"`).
Ландшафт использует **микротекстуры** (microtexture mapping chunks) — маленькие повторяющиеся текстуры, тайлящиеся по поверхности.
## 4.3. Защита от пустых примитивов
Terrain.dll содержит проверки:
- `"Rendering empty primitive!"` — перед первым вызовом отрисовки.
- `"Rendering empty primitive2!"` — перед вторым вызовом отрисовки.
Это подтверждает многопроходный рендер (как минимум 2 прохода для ландшафта).
-5
View File
@@ -1,5 +0,0 @@
# UI system
Документ описывает интерфейсную подсистему: ресурсы UI, шрифты, minimap, layout и обработку пользовательского ввода в интерфейсе.
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга UI-компонентов движка.
+371
View File
@@ -0,0 +1,371 @@
# I. Путеводитель и методика
Первый том задаёт язык и правила всей документации. Он объясняет, как читать
технические главы, какие термины используются для игрового runtime, как
разделяются уровни уверенности и какие требования предъявляются к реализации,
которая должна работать с оригинальными данными без потери информации.
Документация рассчитана на разработчика, который уже умеет читать C/C++,
байтовые форматы, PE-модули и графические pipeline, но не обязательно знаком с
Iron3D. Поэтому этот том не описывает один конкретный crate, package или
физическое деление будущего кода. Он фиксирует контракты: что должно быть
прочитано, сохранено, рассчитано и показано.
## Назначение книги
Книга ведёт от общей архитектуры Iron3D к точным форматам данных и алгоритмам
исполнения. Практическая цель -- реализация, способная открыть оригинальный
каталог *Parkan: Iron Strategy*, загрузить миссию, создать мир, провести
игровой шаг и сформировать кадр.
Форматы в главах описываются как байтовые контракты. Если указано поле
`+0x10`, это означает расположение в потоке или структуре данных, а не
разрешение читать файл прямым `reinterpret_cast`. Для постоянных layouts
используются offsets, проверки размеров, bounded cursor и явное сохранение
неизвестных байтов. Для versioned и variable-length записей приоритет имеет
последовательный parser с контролем границ.
Игровое поведение описывается не только размером структур. Совместимая
реализация должна учитывать порядок событий, время, fallback-правила,
идентификаторы объектов, численные ограничения, состояние материалов,
границы кадра и правила завершения операций.
## Маршруты чтения
**Читатель, новый для игровой разработки**, начинает с базовых понятий этого
тома, затем переходит к архитектуре, игровому циклу и вводу в рендер. После
этого имеет смысл читать главы о миссиях, мире и ресурсных форматах.
**Разработчик совместимого движка** читает тома II-VII линейно. Технические
главы имеют одинаковую логику: назначение подсистемы, данные на диске,
представление в памяти, алгоритм работы, проверки и требования к новой
реализации.
**Аналитик оригинальной программы** использует этот том вместе с разделами о
доказательной базе, ABI, результатах корпусных проверок и границах знания.
Факты, согласованные выводы и открытые вопросы должны оставаться разделёнными:
это позволяет расширять реализацию без подмены проверенных контрактов
удобными догадками.
## Состав документации
1. **Путеводитель и методика** -- язык предметной области, правила чтения и
процедура проверки.
2. [**Запуск, архитектура и игровой цикл**](02-architecture.md) -- от
`iron_3d.exe` до расчёта и вывода кадра.
3. [**Ресурсная система и форматы**](03-resources.md) -- архивы, кэши, реестры
и служебные данные.
4. [**Мир, миссии и игровой runtime**](04-world.md) -- TMA, ландшафт, ареалы и
создание объектов.
5. [**Геометрия, материалы и рендер**](05-render.md) -- от вершины модели до
изображения на экране.
6. [**Поведение, управление, звук и сеть**](06-behavior.md) -- интерактивные
подсистемы.
7. [**Руководство по полной реализации**](07-implementation.md) -- предлагаемая
архитектура и порядок работ.
8. [**Справочник и доказательная база**](08-evidence.md) -- ABI,
конфигурация, статистика и открытые вопросы.
Дополнительные краткие определения собраны в
[глоссарии](../appendices/glossary.md). Технические области, где контракт ещё
не закрыт полностью, перечислены в
[границах знания](../appendices/knowledge-boundaries.md).
## Условные обозначения
`+0x10` означает смещение поля относительно начала структуры или записи.
`RVA 0x13B60` -- адрес относительно базы PE-модуля. `u16`, `u32`, `i16` и
`float32` обозначают типы фиксированной ширины. `LE` означает little-endian.
`payload` -- полезные данные записи после метаданных контейнера. `EOF` -- точное
завершение файла или ограниченного блока.
Если в тексте указан hash, RVA или ordinal, значение относится к явно
обозначенному binary profile. Адреса разных сборок не объединяются по имени
функции. При публикации функции нужны минимум модуль, SHA-256 сборки и RVA.
Размеры структур выражаются в байтах. Счётчики и offsets считаются частью
формата, даже когда их можно восстановить из длины файла. Padding, reserved
поля, неизвестные хвосты и gaps не нормализуются без доказанного правила.
## Совместимость
Слово "совместимость" в этой книге имеет несколько уровней.
**Reader** умеет открыть файл, проверить границы, извлечь известные поля и
сохранить неизвестные bytes так, чтобы данные можно было записать обратно.
**Viewer** умеет показать ресурс: модель, texture, material, эффект или карту.
Viewer может быть полезен для анализа, но он не доказывает поведение runtime.
**Runtime** умеет создать мир, зарегистрировать объекты, исполнять события,
обновлять время, применять контроллеры, выбирать видимое состояние и передавать
его рендеру.
**Полноценный движок** дополнительно воспроизводит порядок операций, численные
правила, fallback-поведение, resource lifetime, reference ownership, pause,
manual input, сетевые идентификаторы, boundaries кадра и состояние
интерактивных подсистем.
Поэтому файл может быть "прочитан правильно", но всё ещё не быть реализованным
на уровне движка. Например, reader MSH может восстановить вершины и индексы,
viewer может нарисовать mesh, а runtime обязан ещё сохранить material slots,
animation state, bounds, LOD, visibility, collision и связи с объектом мира.
## Движок как программа длительного действия
Обычная прикладная программа получает запрос, вычисляет результат и заканчивает
работу. Игра живёт в цикле: прочитать ввод, обновить состояние мира,
сформировать звук и изображение, показать кадр и повторить. Движок -- набор
подсистем и соглашений, которые делают этот цикл устойчивым.
**Simulation** отвечает на вопрос "что произошло в мире": куда переместился
объект, кого он видит, сколько у него здоровья, сработал ли эффект, изменился
ли маршрут или приказ. **Rendering** отвечает на другой вопрос: "как текущее
состояние показать". В корректной архитектуре рендер не решает игровые правила,
а читает подготовленное состояние.
**Tick** -- один шаг расчёта. **Frame** -- одно изображение. Они могут
выполняться с разной частотой: игра способна рассчитать несколько шагов между
двумя показами или временно не рисовать, не останавливая логику. Поэтому время,
накопление input, порядок callbacks и момент удаления объектов считаются частью
контракта.
## Мир, сцена и объект
**Мир** -- долгоживущее состояние миссии: ландшафт, объекты, время, погода,
принадлежность к кланам и глобальные сервисы. **Сцена** -- представление той
части мира, которую можно обработать для текущей камеры. **Игровой объект** --
сущность с идентификатором, положением, набором свойств и поведением.
В Iron3D объектами управляет World3D. Объекты регистрируются в общей очереди,
получают события, участвуют в расчёте и могут быть удалены отложенно, чтобы не
разрушить обход коллекции посреди шага. Это важнее, чем конкретный контейнер в
новой реализации: совместимость определяется моментом наблюдаемого добавления,
обновления и удаления.
Мир не равен renderer scene graph. Один объект может иметь runtime state,
controller, сетевой mirror, визуальную модель, collision bounds и script state.
Часть этих данных нужна для gameplay, часть -- для вывода, часть -- для
сохранения и воспроизведения.
## Ресурс, модель и материал
**Ресурс** -- именованный блок данных, который можно найти и загрузить. Архивы
`NRes` и `RsLi` содержат таблицы таких блоков. Имя, индекс, размер, offset,
compression method и fallback-правило являются частью контракта загрузки.
**Модель** описывает форму объекта. Она состоит из вершин, индексов, узлов,
групп треугольников, слотов материалов и auxiliary streams. **Vertex** хранит
положение и обычно дополнительные атрибуты: нормаль для освещения и
UV-координату для выборки texture. **Triangle** -- три вершины, образующие
примитив. **Index buffer** хранит номера вершин и позволяет переиспользовать их
между треугольниками. **Batch** -- непрерывный диапазон индексов, который
рисуется одним материалом и одним набором состояний.
**Материал** описывает способ отображения поверхности: texture references,
цвет, прозрачность, режимы смешивания и анимацию параметров. **Texture** --
изображение в памяти графической системы. **Mip-уровни** -- уменьшенные копии
изображения для дальних объектов. **Lightmap** -- дополнительная texture с
заранее рассчитанным освещением.
Runtime должен связывать эти уровни по цепочке: миссия выбирает объект, объект
ссылается на prototype, prototype приводит к модели, модель -- к WEAR,
материалам, textures и lightmaps. Ошибка на любом участке этой цепочки может
не проявиться в parser-е, но проявится в игровом кадре.
## Пространственные понятия
**Transform** переводит точку из локальных координат модели в координаты мира,
камеры и экрана. **Иерархия узлов** позволяет одному элементу наследовать
движение другого. **LOD** выбирает менее подробную геометрию вдали. **Culling**
отбрасывает то, что не видно. **Bounds** -- упрощённая оболочка объекта,
обычно сфера или AABB, используемая для быстрых тестов.
**Collision** отвечает на геометрические пересечения. **Navigation** ищет
допустимый маршрут. В Iron3D эти задачи разделены: Control обслуживает
физическую модель и столкновения, а ArealMap хранит пространственные области и
связи между ними.
Важно не смешивать визуальные и игровые упрощения. Render bounds могут быть
достаточны для отсечения, но не обязаны совпадать с collision shape. Навигация
может использовать areal graph, который не является ни mesh-ем модели, ни
геометрией ландшафта в renderer-е.
## Графический конвейер
Процессор выбирает видимые объекты, готовит матрицы, материалы и списки
примитивов. Графический backend передаёт вершины, индексы, textures и state
драйверу. Видеокарта преобразует вершины в координаты экрана, разбивает
треугольники на фрагменты, проверяет глубину, смешивает цвет и записывает
результат в буфер кадра. После завершения буфер становится видимым
пользователю.
Для совместимости важны не только данные draw call. Контракт включает frame
boundaries, viewport, camera state, порядок world traversal, material resolve,
shadow/transparent/FX subpasses, завершение renderer-а, восстановление state и
callbacks после рендера. Если часть имён vtable slots ещё не доказана, новая
реализация должна фиксировать крупный порядок операций и оставлять
детализацию проверяемой.
## Практический словарь реализации
**Handle** -- компактная ссылка на управляемый объект. **Cache** -- сохранённый
результат загрузки или декодирования. **Reference count** -- число владельцев
ресурса. **Fallback** -- предписанный запасной вариант при отсутствии данных.
**Invariant** -- условие, которое всегда должно быть истинным для корректного
файла или runtime-состояния. **Determinism** -- повторяемость результата при
одинаковых входных данных и порядке событий.
**Strict mode** -- режим parser-а, который принимает только корректный файл:
верные magic, версии, размеры, ranges, индексы и точный EOF. **Lossless mode**
-- режим чтения/записи, который сохраняет неизвестные поля, padding, gaps и raw
payload без нормализации. **Quirk** -- именованное отклонение, разрешённое
только после проверки на реальных данных или исполняемом коде.
Эти слова используются как технические термины. Если глава называет значение
fallback-ом, invariant-ом или quirk-ом, это должно иметь проверяемое
последствие в reader-е, writer-е или runtime.
## Как читать C/C++-схемы структур
Структуры в главах описывают байтовый layout, а не переносимый C++ object
model. Если поля на диске идут без padding, reader должен читать их по offsets
либо использовать явно проверенный packed layout. Прямое отображение native
struct допустимо только при доказанном размере, выравнивании и endian-правиле.
`sizeof` обязательно проверяется `static_assert` или эквивалентным compile-time
test. Это особенно важно для records, где 32-битное поле начинается после
нечётного числа 16-битных или 8-битных полей: стандартное выравнивание
современного compiler-а может вставить скрытые bytes и изменить offsets.
Для variable-length форматов предпочтителен bounded cursor:
1. Прочитать header и проверить минимальный размер.
2. Проверить, что offsets и sizes лежат внутри текущего блока.
3. Прочитать таблицы до объявленного count, не до "пока получается".
4. Проверить ссылки между таблицами.
5. Дойти до точного EOF или сохранить явно разрешённый trailing payload.
Writer пересчитывает только производные значения: размеры, offsets, число
записей, сортировочные таблицы и padding, если правило доказано. Unknown fields
и reserved ranges сохраняются побайтно.
## Иерархия доказательств
Документация использует четыре уровня уверенности.
**Прямое наблюдение** -- поле, значение или последовательность видны в
инструкции программы, таблице PE, экспорте, строке, обработчике файла или в
самом ресурсе. Это самый сильный уровень.
**Корпусное подтверждение** -- правило проверено на всех подходящих файлах
одного или нескольких явно названных наборов: демоверсии, Части 1 и Части 2.
Например, базовый корпус содержит 435 моделей MSH, 518 textures Texm и 923
эффекта FXID, прошедших структурные проверки без ошибок; полные части расширяют
эту матрицу вариантов.
**Согласованный вывод** -- назначение восстановлено по нескольким независимым
признакам: вызывающим функциям, vtable slots, строкам ошибок, диапазонам
значений и связям между форматами. Такой вывод пригоден для реализации, но его
численные детали следует проверять тестами.
**Открытый вопрос** -- данные можно читать и сохранять, однако предметный смысл
поля или редкой ветки не доказан. Такие bytes нельзя обнулять,
переупорядочивать или превращать в authoring API.
Уровень уверенности должен быть виден из формулировки. "Поле равно" означает
проверенный layout или значение. "Вероятно отвечает за" означает согласованный
вывод. "Неизвестно" означает сохранять без изменения и не строить вокруг этого
публичный контракт.
## Проверенные материалы
Локальный набор проверки включает демоверсию, полные каталоги Частей 1 и 2,
исполняемые файлы, 15 DLL каждой сборки и игровые ресурсы. DLL из
первоначального архива и DLL демоверсии совпали по SHA-256: `15/15`, поэтому
выводы по этому коду и demo-ресурсам образуют один доказательный профиль.
Исполняемый файл демоверсии `iron_3d.exe` имеет размер 36 864 байта, PE32/x86,
entry RVA `0x141E`, image base `0x400000` и SHA-256
`b0a8b0db1c3a8698c4d4604d89c655496bd91ac1f8859a455e8a45838aebfbd6`.
Исполняемые файлы Частей 1 и 2 также имеют размер 36 864 байта и побайтно
совпадают между собой, но относятся к другому binary profile: entry RVA
`0x147E`, SHA-256
`f476af85c034a4b4f34f49d0806e4dff397b5da0ee26d382a7674231144979f7`.
Полные каталоги Частей 1 и 2 суммарно включают 60 TMA, 1 101 unit DAT, 254
NRes-файла и 14 975 NRes entries. Все контейнеры и TMA прошли bounded parser до
точного EOF; полный достижимый граф обеих частей разрешился без ошибок.
## Процедура проверки
Проверка строится как воспроизводимая цепочка:
1. Снять PE-метаданные, хэши, импорты, экспорты, ordinals, RTTI и строки.
2. Построить граф вызовов между модулями и отметить фабрики подсистем.
3. Разобрать функции запуска, загрузчики файлов, главный цикл и критические
vtable-вызовы.
4. Проверить форматы независимыми reader-скриптами с контролем границ и точного
завершения файла.
5. Построить цепочку миссия -> объект -> прототип -> модель -> материал ->
texture.
6. Сравнить счётчики, диапазоны, ссылки и размеры на всём доступном корпусе.
Ключевой результат сквозной проверки демо-миссий: все 201 объектов шести
миссий разрешились в 501 запрос прототипов, затем в 501 модель, 501 таблицу
WEAR, 3 879 слотов материалов и 5 085 ссылок на textures или lightmaps. Ошибок
в фактически исполняемом пути нет.
## Что не считается доказательством
Удобное имя поля не доказывает его назначение. Совпадение layout с текущей
реализацией не доказывает поведение оригинального runtime. Успешный viewer не
доказывает writer. Успешный reader одного файла не доказывает формат всего
корпуса. Совпадение ABI не доказывает побайтную идентичность всех сборок.
Если локальные данные и предположение расходятся, приоритет имеют исполняемый
код, реальные ресурсы и взаимные invariants между форматами. Неизвестное поле
лучше оставить без имени, чем дать ему ложное предметное значение.
## Требования к воспроизводимости
Каждая новая реализация должна иметь strict parser mode, lossless roundtrip
mode и набор corpus tests. Неизвестные поля сохраняются побайтно. Любое
присвоенное полю имя должно сопровождаться наблюдаемым поведением или тестом.
Численные правила -- округление, порядок умножения, RNG и время -- считаются
частью формата исполнения, даже если файл читается правильно.
Минимальный отчёт проверки должен фиксировать:
1. build profile и hashes модулей;
2. путь или ключ ресурса;
3. размер входного файла и hash входных bytes;
4. версию parser-а или commit реализации;
5. список включённых quirks;
6. число прочитанных записей и точку EOF;
7. ошибки, предупреждения и unknown ranges;
8. результат roundtrip, если writer участвует в проверке.
Для runtime-проверок дополнительно нужны mission key, configuration, device
profile, начальное состояние, input/time script и trace значимых callbacks.
## Разделение профилей
Binary profile описывает исполняемый код: PE-метаданные, exports/imports,
ordinals, hashes, RVA и layout функций. Corpus profile описывает набор файлов:
каталог, миссии, ресурсы, размеры, counts, variants и статистику parser-а.
Эти профили нельзя смешивать без явной пометки. Один и тот же формат может
иметь общий смысл в разных сборках, но отличаться редкими ветками, адресами
функций или набором встреченных вариантов. Один и тот же address может иметь
смысл только внутри конкретного module hash.
При расширении документации новое утверждение должно отвечать на три вопроса:
1. Где это видно напрямую?
2. На каком корпусе это проверено?
3. Что должна сделать реализация, если правило нарушено?
Если на один из вопросов нет ответа, утверждение остаётся согласованным выводом
или открытым вопросом, а не закрытым контрактом.
+472
View File
@@ -0,0 +1,472 @@
# II. Запуск, архитектура и игровой цикл
Этот том описывает путь от запуска `iron_3d.exe` до устойчивого кадра:
загрузку `iron3d.dll`, создание shell/game objects, поднятие платформенных
сервисов, запуск World3D, расчёт simulation step, безопасное удаление объектов,
рендер и завершение программы.
Главная особенность Iron3D -- это не один монолитный engine object, а связка
небольшого Win32 bootstrap и набора DLL, которые обмениваются фабриками,
singleton-интерфейсами и C++ vtable. Совместимая реализация может изменить
физическое деление на библиотеки, но не может произвольно менять порядок
инициализации, object identity, правила владения, fallback ресурсов и порядок
событий.
```text
iron_3d.exe
-> iron3d.dll
-> services.dll
-> World3D.dll
-> Terrain.dll
-> Ngi32.dll
-> AniMesh.dll / ArealMap.dll / Effect.dll
-> ai.dll / Behavior.dll / Wizard.dll
-> Control.dll / MisLoad.dll / Net.dll / Joystick.dll
```
## Карта модулей
Во внешней архитектуре обнаружено пятнадцать DLL. Экспортов сравнительно мало:
они обычно создают объект, возвращают singleton или дают доступ к уже поднятой
подсистеме. Основная работа выполняется через C++-интерфейсы, поэтому порядок
виртуальных слотов является частью ABI, особенно для compatibility shim эпохи
MSVC6.
```text
iron_3d.exe
|
v
iron3d.dll -- композиция игры, shell и главный цикл
|
+-- services.dll -- доступ к display, GUI, ресурсам, звуку, таймеру и сети
+-- World3D.dll -- объекты, очередь, время, камера и кадр
+-- Terrain.dll -- ландшафт, свет, атмосфера и визуальный слой мира
+-- ai.dll / Behavior.dll / Wizard.dll
+-- Control.dll / Effect.dll / MisLoad.dll
+-- Net.dll / Joystick.dll
+-- Ngi32.dll -- ресурсы, графика, звук, математика и CPU dispatch
```
Циклы импортов между DLL ожидаемы. Terrain создаёт визуальные объекты и
обращается к World3D, а World3D получает world-interface из Terrain. Это не
значит, что обе библиотеки совместно владеют всем состоянием. Реальные границы
задаются интерфейсами, refcount, очередью объектов и порядком shutdown.
Практичная новая структура может быть внутренним набором модулей `platform`,
`resources`, `world`, `mission`, `terrain`, `render`, `animation`, `effects`,
`behavior`, `physics`, `audio` и `network`. Важно сохранить не DLL-границы, а
контракты: имена ресурсов, порядок поиска, fallback-ветки, object ID, момент
создания mirror objects, численное поведение и последовательность событий.
## Роли модулей
`iron3d.dll` создаёт shell и game objects, читает `iron_3d.ini`, поднимает
display, sound, CD-audio, network и настройки World3D, загружает миссионные и
UI-конфигурации, содержит message pump и вызывает расчёт/рендер игры.
`services.dll` работает как service locator. Через него запрашиваются display,
GUI, network manager, resource manager, sound server и timer. Этот слой отделяет
высокоуровневую игру от деталей создания устройств.
`World3D.dll` -- центральный runtime: очередь объектов, идентификаторы,
события, отложенное удаление, game time, pause, manual input, камера,
material/texture/lightmap managers, сетевые mirrors, расчёт и 3D-проход.
`Terrain.dll` отвечает не только за землю. В его область входят ландшафт,
здания, визуальный слой мира, камера, shade/state layer, primitive buffers,
сортировочные слои, источники света, тени, microtextures, атмосфера, дождь,
молнии, солнце и flares.
`Ngi32.dll` содержит низкоуровневые сервисы: DirectDraw/Direct3D-era renderer,
DirectSound, readers `NRes`/`RsLi`, память, часы, математику, пересечения,
определение CPU и таблицу быстрых процедур `g_FastProc`.
Предметные DLL закрывают отдельные области. `AniMesh.dll` загружает модели и
агентов. `ArealMap.dll` строит spatial graph и маршруты. `Behavior.dll`
реализует поведение юнитов. `ai.dll` содержит стратегический AI и миссионные
сценарии. `Wizard.dll` корректирует локальное движение. `Control.dll`
обслуживает физическую модель и столкновения. `Effect.dll` создаёт runtime-FX.
`MisLoad.dll` читает миссионные данные. `Net.dll` инкапсулирует DirectPlay.
`Joystick.dll` работает через DirectInput.
## Поток данных
Миссия не создаёт готовый кадр напрямую. Данные проходят через несколько
уровней: описание объекта, прототипы, ресурсы, runtime-object, контроллеры,
simulation state, render items и только затем платформенный renderer.
```text
mission data
-> object identity and properties
-> prototype registry
-> model/material/texture/effect resources
-> World3D object + domain controllers
-> simulation state
-> visible render items
-> Ngi32 render interface
-> DirectX-era device
```
Этот поток объясняет, почему нельзя объединять физический архив, metadata entry,
декодированный payload и готовый runtime-кэш. У каждого уровня свой срок жизни,
собственный refcount и собственные ошибки. Детали ресурсного конвейера описаны
в [Томе III](03-resources.md), а сборка мира из миссии -- в [Томе IV](04-world.md).
## Bootstrap
`iron_3d.exe` -- небольшой PE32/x86 bootstrap размером 36 864 байта. Основная
игровая логика находится в `iron3d.dll`. Исполняемый файл создаёт Win32-процесс,
подготавливает окружение, загружает библиотеку и получает восемь публичных
точек входа:
```text
createShell deleteShell
createGame deleteGame
createSubsystems deleteSubsystems
getIGame getIShell
```
Эти функции образуют внешнюю границу игры. `createShell` создаёт оболочку
интерфейса и меню, `createGame` -- объект игровой логики, `createSubsystems` --
аппаратные и runtime-сервисы. Getter-функции возвращают уже созданные объекты.
Запуск удобно читать как конечный автомат:
```text
PROCESS_CREATED
-> LIBRARY_READY
-> ENTRYPOINTS_READY
-> SHELL_CREATED
-> GAME_CREATED
-> SUBSYSTEMS_READY
-> MAIN_LOOP
-> SUBSYSTEMS_CLOSED
-> GAME_DELETED
-> SHELL_DELETED
```
Каждый переход имеет обратное действие. Если display, sound или другой
обязательный сервис не создан, главный цикл не начинается, но уже созданные
объекты освобождаются в обратном порядке. Новая оболочка запуска должна
работать из каталога оригинальной установки, сохранять смысл относительных
путей, создавать окно до графической подсистемы и закрывать частично поднятые
сервисы без предположения, что init дошёл до конца.
Bootstrap обеих полных частей побайтно одинаков, хотя файл второй части может
иметь другое имя:
```text
size 36 864
entry RVA 0x147E
SHA-256 f476af85c034a4b4f34f49d0806e4dff397b5da0ee26d382a7674231144979f7
```
Следовательно, различия полных частей начинаются после передачи управления DLL
и игровым данным. Адреса executable демоверсии относятся к другой binary
profile и не должны переноситься на полные версии без проверки hash.
## Инициализация подсистем
Iron3D разделяет создание высокоуровневых объектов и создание подсистем.
`createShell` конструирует оболочку пользовательского интерфейса, `createGame`
создаёт объект игры, а `createSubsystems` связывает их с display, sound,
network и World3D.
Высокоуровневая последовательность выглядит так:
```text
прочитать iron_3d.ini
-> получить display service
-> создать окно и графическое устройство
-> проверить доступность 3D-драйвера
-> выбрать CURRENT_D3DCARD
-> получить sound service и настроить громкость
-> создать network instance и передать application GUID
-> создать World3D game settings
```
Ошибка отсутствующего 3D-устройства обрабатывается отдельно от ошибок ресурсов:
это разные стадии запуска. Конфигурация влияет не только на разрешение. В
runtime попадают графическая карта, громкость эффектов, CD-audio, режим
CD-sound, сетевое приложение и World3D settings. Application GUID сетевой
подсистемы:
```text
{3C1D1F01-A870-11D1-8400-000021B14415}
```
Один и тот же GUID передаётся сетевому объекту и service layer. Если он
разойдётся, экземпляры игры станут логически разными приложениями, даже при
исправном транспорте.
## `stdInitGame`
После платформенных сервисов World3D создаёт внутренний runtime:
1. Создаёт глобальную очередь объектов.
2. Сохраняет window handle и режим игры.
3. При нужном режиме ограничивает курсор областью окна.
4. Получает или создаёт 3D sound object.
5. Загружает реестр адресов компонентов из `Comp.ini`.
6. Получает или создаёт 3D renderer.
7. Читает профиль возможностей renderer.
8. Загружает component type 6.
9. Для multiplayer создаёт NetWatcher.
10. Получает world-interface из Terrain.
11. Устанавливает исходные параметры света и тумана.
Порядок важен. World objects не должны появляться до queue, ресурсы рендера --
до renderer, сетевые mirror objects -- до NetWatcher. В новой реализации у
каждого этапа должен быть явный признак успешного создания, чтобы shutdown мог
безопасно разобрать неполный init.
## Завершение
Shutdown идёт в обратном направлении: прекращаются игровые расчёты и сетевые
наблюдатели, разбираются отложенные операции, освобождаются world objects и
менеджеры, затем renderer и sound, затем game settings и platform services.
Ограничение курсора снимается, глобальные ссылки очищаются.
Полезный протокол завершения:
1. Запретить новые события и новые объекты.
2. Дождаться выхода из calculation/render traversal.
3. Разобрать очередь deferred operations.
4. Отсоединить объекты от очереди, контроллеров и менеджеров.
5. Освободить managers и singletons после их consumers.
6. Закрыть устройства и платформенные сервисы.
Такой порядок защищает от dangling-ссылок между World3D, Terrain, renderer,
sound и сетевым слоем.
## Главный цикл
Главный цикл -- не одна функция `update_and_render`, а расписание, связывающее
Win32 messages, input, игровые события, таймеры, сеть и renderer. Системная
очередь сообщает об активации окна, вводе, изменении состояния процесса и
выходе. Очередь World3D рассчитывает игровые объекты. У этих очередей разные
правила времени и владения, поэтому их нельзя смешивать в один контейнер.
Подтверждённые точки вызова в одном из профилей:
```text
stdCalculateGame RVA 0x5FA94, 0x604C1, 0x6086B
ClearManualEventsList RVA 0x6052F
stdRenderGame RVA 0x60B2F
UpdateManualEventsList в обработчике сообщений около RVA 0xA3759
```
Смысловой skeleton:
```c
while (running) {
stdCalculateGame();
clear_keyboard_snapshot();
update_shell_and_mode();
ClearManualEventsList();
process_window_messages();
update_timers_ui_gameplay_network();
if (mode_requires_extra_step) stdCalculateGame();
if (render_enabled) {
stdSetCurrentCamera(camera);
stdRenderGame(camera);
} else {
sleep_briefly();
}
update_post_render_state();
}
```
Ввод из window messages накапливается между расчётными шагами. Если читать
клавиатуру только внутри рендера, события будут теряться при пропущенных кадрах
или отключённом выводе.
## `stdCalculateGame`
Calculation pass сначала очищает или подготавливает список manual events,
увеличивает внутренний depth/counter и опрашивает input device. Если устройство
временно потеряно, выполняется повторное получение доступа и чтение повторяется.
Затем при незамороженной игре выставляется признак `in_calculation` и вызывается
основной traversal очереди объектов.
```text
prepare input/events
-> enter calculation
-> dispatch queue events
-> objects update behavior and transforms
-> leave calculation
-> apply deferred operations
-> occasional cache maintenance
```
После traversal разбирается deferred-delete list. Объект может запросить
собственное удаление во время события, но память освобождается только после
завершения обхода. Периодически также очищаются давно неиспользуемые ресурсы и
объекты по порогам часов порядка 20 и 60 секунд.
Совместимый runtime должен иметь явный traversal depth или флаг
`in_calculation`. Нельзя полагаться на то, что контейнер выдержит удаление
текущего элемента из обработчика события.
## Жизненный цикл кадра
Рендер читает состояние, подготовленное расчётом. Кадр начинается до renderer-а:
message pump уже накопил ввод, World3D уже обновил объекты, отложенные операции
и анимации, после чего выбирается камера и обновляется listener звука.
```text
system messages and input
-> simulation calculation
-> deferred object operations
-> animation and transforms
-> camera and sound listener
-> visibility and render queues
-> materials and draw passes
-> renderer completion
-> end-of-render callbacks and UI
```
В `World3D::stdRenderGame` виден крупный каркас: установка camera/viewport,
renderer frame boundaries, traversal мира, завершение world/shade path,
renderer completion, снятие `in_render`, восстановление viewport и рассылка
end-of-render callbacks. Эти callbacks позволяют объектам безопасно обновить
временные ресурсы после того, как draw-команды больше их не используют.
Один calculation step не обязан соответствовать одному изображению. Главный
цикл допускает дополнительный вызов `stdCalculateGame` и режим, в котором
расчёт продолжается без вывода кадра. Поэтому нужно хранить отдельно:
1. монотонные платформенные часы;
2. игровое время с pause и масштабированием;
3. длительность текущего calculation step;
4. локальное время анимации и FX;
5. реальные часы обслуживания кэшей.
Игровую логику нельзя выводить из render delta: изменение частоты кадров тогда
изменит движение, камеру и сценарные таймеры. Подробности render item и рисков
кадровой совместимости вынесены в справочник [Render frame](../reference/render-frame.md).
## World3D
World3D связывает игровые объекты, события, время, ввод, камеру, сетевые
отражения и визуальное представление. Он не содержит всю предметную логику:
движение делегируется Behavior/Wizard, физика -- Control, мир -- Terrain. Его
задача -- общая идентичность, порядок вызовов и безопасный жизненный цикл.
`CreateQueue` создаёт singleton-объект размером 20 байт, а `GetQueue`
возвращает его. Очередь служит центральным маршрутизатором событий и операций
над объектами.
Публичный слой предоставляет отдельные функции для локальных и сетевых
объектов:
```text
CreateObject
AddObjectToGame
AddNewObjectToGame
CreateMirrorObject
AddMirrorObjectToGame
AddNewMirrorToGame
```
Разделение "создать" и "добавить в игру" означает два этапа: сначала выделить и
настроить instance, затем зарегистрировать его в общей системе. Это позволяет
loader-у заполнить свойства до появления объекта в расчётной очереди.
## Идентичность объектов
Object ID кодирует не только порядковый номер. Проверки диапазонов показывают
разбиение на номер игрока, класс и индекс. Mirror object представляет объект,
владельцем которого является другой участник. Локальный runtime хранит его
видимое состояние, но источник авторитетных изменений находится удалённо.
```c
struct ObjectId {
uint32_t raw;
uint16_t owner_player;
uint16_t class_and_index;
};
```
Точное битовое разбиение нужно брать из сетевых функций. На уровне API уже
сейчас полезно разделить логические свойства: `is_local`, `is_mirror`, `owner`,
`class` и `index`.
Минимальный runtime-object должен хранить identifier, type, owner, transform,
active state, ordered property bag, ссылки на controllers, участие в расчёте и
рендере, сетевой статус и флаг отложенного удаления. Специализированные DLL
могут быть представлены компонентами, но порядок их вызовов задаёт World3D.
## Отложенное удаление
`DeleteGameObject` проверяет, идёт ли calculation pass. Если обход активен и
удаление не принудительное, объект помещается в deferred list. `KillGameObject`
отправляет запрос через очередь, а не освобождает память напрямую.
```c
void request_delete(Object* o) {
if (world.in_calculation) {
world.deferred_delete.push_back(o);
o->pending_delete = true;
} else {
world.detach_and_release(o);
}
}
```
Это защищает итераторы, связи и текущий стек вызовов. Любая новая подсистема,
способная удалить объект из обработчика события, обязана пользоваться тем же
механизмом.
Регистрация в очереди и владение памятью -- разные понятия. Удаление из мира не
всегда означает немедленное освобождение instance: часть объектов и managers
использует intrusive reference count, а renderer, sound и resource managers
могут возвращать уже существующий singleton с увеличенным счётчиком. Поэтому
global manager закрывается после всех объектов, которые на него ссылаются.
## Детерминизм
Даже при одинаковых формулах результат зависит от порядка. Стабильный runtime
сохраняет последовательность queue traversal, момент формирования input
snapshot, порядок сетевых сообщений, обработку deferred operations и порядок
обращений к RNG. Оптимизация и многопоточность допустимы только при
детерминированном объединении результатов.
Для переносимой реализации полезно разделить scheduler phases и immutable
render snapshot. Это архитектурная рекомендация для новой реализации, а не
утверждение о точном layout исходных C++ classes.
## Стабильность между сборками
Внешняя архитектура полных Частей 1 и 2 сохраняет те же пятнадцать DLL, 313
exports, имена, ordinals и import sets. Побайтно идентичны:
```text
ai.dll, Behavior.dll, Joystick.dll, MisLoad.dll, Net.dll,
Ngi32.dll, Terrain.dll, Wizard.dll, World3D.dll
```
Пересобраны:
```text
AniMesh.dll, ArealMap.dll, Control.dll, Effect.dll,
iron3d.dll, services.dll
```
Это разделяет переносимость выводов. World3D lifecycle, Terrain, NRes/RsLi
readers, mission loader, AI/Behavior/Wizard, DirectPlay wrapper и joystick
adapter подтверждаются одной машинной реализацией. Model/agent runtime,
collision, effects, shell/composition и service layer требуют отдельного
сравнения поведения Частей 1 и 2.
Для `World3D.dll` Частей 1 и 2 применим общий hash:
```text
World3D.dll SHA-256
17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e
```
RVA внутри `iron3d.dll` нельзя считать общими без проверки конкретного файла:
эта DLL пересобрана между частями, а демоверсия имеет отдельный binary profile.
Смысловая последовательность цикла переносится как контракт scheduler-а, но
адреса остаются build-specific.
+561
View File
@@ -0,0 +1,561 @@
# III. Ресурсная система и форматы
Ресурсная система Iron3D переводит имена из миссий и прототипов в объекты,
которыми пользуются подсистемы мира, рендера, анимации, звука, эффектов и
управления. В этом пути участвуют несколько разных сущностей: файл на диске,
открытый архив, запись каталога, подготовленный payload и готовый runtime-объект.
Их нельзя смешивать, потому что у каждого уровня свой срок жизни, свои правила
кэширования и свой набор проверок.
Основной контейнер ресурсов -- [NRes](../reference/nres.md). Он используется как
внешний архив (`objects.rlb`, `Material.lib`, `Textures.lib`) и как внутренний
контейнер модели `*.msh`. Второй библиотечный формат -- [RsLi](../reference/rsli.md):
его каталог находится в начале файла, а payload может храниться raw, через
потоковое преобразование, LZSS, адаптивный Huffman + LZSS или raw Deflate.
Визуальная часть прототипа дальше проходит через [MSH](../reference/msh.md),
[WEAR/MAT0](../reference/materials.md) и [Texm](../reference/texm.md), но этот
том описывает именно ресурсный слой: как найти, проверить, раскрыть и сохранить
данные до передачи их предметным подсистемам.
```text
TMA или unit DAT
-> логический ключ
-> objects.rlb
-> archive.rlb :: model.msh
-> model.wea
-> Material.lib :: MAT0
-> Textures.lib / LightMap.lib :: Texm
```
На демо-корпусе эта цепочка проверена целиком для всех реально размещённых
объектов. При этом полная таблица прототипов может содержать ссылки на контент,
которого нет в урезанной поставке. Диагностика должна различать недостижимую
ссылку в общем реестре и ресурс, реально требуемый выбранной миссией.
## Ресурсный конвейер
Загрузка ресурса состоит из последовательных стадий:
1. Разрешить относительный путь с учётом глобального resource path и текущего
каталога игры.
2. Открыть архив или вернуть уже открытый archive object из кэша.
3. Найти запись каталога по имени, не меняя исходный порядок каталога.
4. Проверить bounds, размер payload и способ хранения.
5. Подготовить bytes: распаковать, применить потоковое преобразование или
вернуть raw-диапазон.
6. Разобрать предметный формат и создать объект подсистемы.
7. Сохранить готовый объект в отдельном кэше, если формат допускает повторное
использование.
Эти стадии дают четыре независимых уровня кэша:
1. Открытые архивы.
2. Каталоги имён, offsets и размеров.
3. Подготовленные блоки данных.
4. Кэши моделей, материалов, текстур, lightmaps, эффектов и служебных объектов.
Повторное открытие того же нормализованного пути возвращает существующий
archive object и увеличивает счётчик владельцев. Готовая texture или model при
этом может жить дольше file handle и иметь собственную политику удаления. Кэш
предметного объекта не должен напрямую закрывать архив: он зависит от данных,
но не владеет файлом как ресурсом операционной системы.
## Имена и пути
Большинство игровых имён сравнивается без учёта регистра в ASCII-диапазоне. Это
не Unicode case folding. Для совместимости достаточно нормализовать `A..Z` в
`a..z`, а для RsLi-поиска -- переводить запрос в uppercase ASCII и укладывать его
в фиксированный ключ.
Фиксированные строки читаются bounded parser-ом: строковая часть заканчивается
на первом NUL, но оставшийся хвост поля сохраняется. Нельзя очищать хвосты,
пересобирать регистр, заменять смешанные разделители или заранее переводить все
пути в абсолютные имена. Старые данные используют исторические имена библиотек,
разный регистр исходных путей и фиксированные поля, где после терминатора могут
оставаться значимые для roundtrip bytes.
## Строгий и совместимый режимы
Строгий reader нужен тестам, редактору и проверке корпуса. Он валидирует
структуру до выдачи любого `EntryView`: magic, версию, счётчики, арифметические
переполнения, bounds, sort permutation, alignment и точное завершение payload.
Если формат требует NUL-терминатор, строгий режим проверяет его именно в пределах
фиксированного поля.
Совместимый reader повторяет только известные особенности оригинала:
- линейный поиск при повреждённой сортировочной таблице;
- RsLi-исключение `deflate_eof_plus_one` для `sprites.lib::INTERF8.TEX`;
- material fallbacks, подтверждённые ресурсной цепочкой;
- отсутствие геометрии у системных и солнечных объектов, где mesh pass не
требуется.
Режим совместимости не должен скрывать произвольные ошибки. Каждое послабление
оформляется как именованное правило и покрывается отдельным тестом. Если quirk
применим только к Deflate-записи, он не распространяется на LZSS, Huffman или
raw-диапазоны.
## NRes
`NRes` хранит произвольные именованные payload и их атрибуты. Каталог расположен
в конце файла, поэтому начало каталога вычисляется из полного размера файла и
числа записей.
```text
[Header: 16 байт]
[Data region: payload с выравниванием]
[Directory: entry_count x 64 байта]
```
Все числа little-endian.
```c
struct NResHeader16 {
char magic[4]; // "NRes"
uint32_t version; // 0x00000100
int32_t entry_count; // >= 0
uint32_t total_size; // равен фактическому размеру файла
};
```
Производные значения:
```text
directory_size = entry_count * 64
directory_offset = total_size - directory_size
```
Reader проверяет, что `directory_offset >= 16`, умножение не переполнено, а
каталог заканчивается точно на `total_size`.
### Запись каталога NRes
```c
#pragma pack(push, 1)
struct NResEntry64 {
uint32_t type_id; // +0x00
uint32_t attr1; // +0x04
uint32_t attr2; // +0x08
uint32_t size; // +0x0C
uint32_t attr3; // +0x10
char name[36]; // +0x14
uint32_t data_offset; // +0x38
uint32_t sort_index; // +0x3C
};
#pragma pack(pop)
```
Имя содержит не более 35 полезных байт и завершающий ноль. Writer запрещает
внутренний NUL и слишком длинное имя, но сохраняет неизвестные атрибуты
`attr1`, `attr2`, `attr3` без нормализации. Их смысл зависит от конкретного
типа ресурса и не может быть выведен из контейнера.
Поле `sort_index` задаёт отображение из позиции в отсортированном списке в
исходный индекс записи. Каталог остаётся в исходном порядке. Поиск идёт по
отсортированному отображению, но возвращает исходную запись. При сохранении
writer строит массив исходных индексов, сортирует его по ASCII-case-insensitive
именам и записывает результат в `sort_index`. Если отображение нельзя использовать
или оно не является перестановкой в строгом режиме, совместимый путь переходит к
последовательному сравнению имён.
### Размещение данных NRes
Каждый active payload должен лежать после 16-байтового заголовка и полностью до
начала каталога. Канонические игровые файлы выравнивают начало следующего
payload до границы 8 байт нулевым заполнением.
Порядок canonical save:
1. Записать временный заголовок.
2. Записать payload всех записей в текущем порядке.
3. После каждого блока добавить нули до кратности 8.
4. Построить таблицу поиска имён.
5. Дописать каталог.
6. Записать окончательный `total_size`.
Строгий reader выполняет проверки до выдачи записи:
- `magic == "NRes"` и `version == 0x100`;
- `entry_count >= 0`, а `entry_count * 64` вычисляется без переполнения;
- `total_size` равен фактической длине файла;
- `directory_offset = total_size - entry_count * 64` не меньше 16;
- для каждой записи `data_offset >= 16` и `data_offset + size <= directory_offset`;
- поле имени содержит NUL в пределах 36 байт;
- каждый `sort_index < entry_count`;
- в строгом режиме все `sort_index` образуют перестановку `0..N-1`.
Нулевое заполнение до границы 8 байт -- подтверждённое поведение игровых
архивов и canonical writer-а. Reader не должен считать ненулевой gap частью
соседнего payload, но lossless-редактор сохраняет исходные bytes, если файл
открыт не в режиме канонической пересборки.
### Неплотная data region
Проверка 120 NRes-файлов / 6 804 entries Части 1 и 134 файлов / 8 171 entries
Части 2 не выявила нарушений magic, version, total size, bounds, sort
permutation, ASCII-order, 8-byte alignment или перекрытий активных payload.
Однако `Textures.lib` Части 2 содержит большой ненулевой диапазон в data region,
который не адресуется ни одной записью каталога. Первый активный payload
начинается значительно позже начала файла, а каталог и все активные entries
остаются корректными.
Следовательно, parser не должен требовать плотного покрытия data region. Нужно
различать три вида диапазонов:
- `active payload` -- bytes, на которые указывает запись каталога;
- `gap/padding` -- bytes между активными диапазонами;
- `unindexed preserved region` -- произвольные bytes, не принадлежащие ни одной
записи.
Canonical compact writer может исключить unindexed region только при явной
операции repack. Lossless editor сохраняет её побайтно вместе с исходным
порядком entries и gaps.
## RsLi
`RsLi` -- библиотечный архив с каталогом в начале файла. Записи могут храниться
в исходном виде или проходить один из поддержанных путей подготовки.
```text
[Header: 32 байта]
[Entry table: entry_count x 32 байта]
[Payloads]
[необязательный trailer]
```
Заголовок начинается с двух байт `NL`. Версия равна `1`, число записей хранится
как знаковое 16-битное значение. Поле по смещению `0x0E` может содержать
`0xABBA`: это означает, что отображение сортировки уже подготовлено.
Подтверждённые поля header:
```text
+0x00 char[2] "NL"
+0x02 u8 reserved, в корпусе 0
+0x03 u8 version, в корпусе 1
+0x04 i16 entry_count
+0x0E u16 presorted_flag, значение 0xABBA
+0x14 u32 xor_seed
```
Остальные bytes заголовка сохраняются без нормализации.
### Запись каталога RsLi
После подготовки таблицы каждая запись имеет layout 32 байта:
```c
struct RsLiEntry32 {
char name[12];
uint8_t service[4];
int16_t flags;
int16_t sort_to_original;
uint32_t unpacked_size;
uint32_t data_offset_raw;
uint32_t packed_size;
};
```
Имя обычно хранится в uppercase ASCII. Четыре служебных байта после имени
сохраняются без изменения. `sort_to_original` играет ту же роль, что и
`sort_index` в NRes: связывает отсортированную позицию с исходной записью.
Таблица на диске проходит обратимое побайтовое преобразование. Начальное
состояние берётся из младших 16 бит `xor_seed`. Если обозначить два байта
состояния как `lo` и `hi`, для каждого входного байта выполняется:
```text
lo = hi XOR ((lo << 1) mod 256)
out = in XOR lo
hi = lo XOR (hi >> 1)
```
Операция симметрична: один и тот же цикл используется для подготовки и
восстановления. Состояние непрерывно проходит по всей таблице; его нельзя
перезапускать на каждой записи.
### Способы хранения RsLi
Способ определяется выражением `flags & 0x1E0`:
```text
0x000 исходный блок
0x020 только потоковое байтовое преобразование
0x040 LZSS
0x060 преобразование, затем LZSS
0x080 адаптивный Huffman, затем LZSS
0x0A0 преобразование, адаптивный Huffman и LZSS
0x100 raw Deflate без оболочки zlib
```
Reader обязан различать все значения, а неизвестную маску отклонять как
неподдерживаемую. После любого пути должно быть получено ровно `unpacked_size`
байт. Методы `0x080` и `0x0A0` подтверждены decoder-кодом и синтетическими
тестами, но живых payload этих веток в проверенных RsLi-файлах не найдено.
Параметры LZSS:
- размер кольцевого окна -- `4096`;
- начальное заполнение -- байт `0x20`;
- начальная позиция -- `0xFEE`;
- управляющие признаки читаются от младшего бита к старшему;
- двухбайтовая ссылка кодирует 12-битную позицию и длину `n + 3`;
- восстановленные bytes сразу записываются обратно в кольцевое окно.
В конце файла может находиться шестибайтовый media overlay trailer: два символа
`AO` и 32-битное значение `overlay`. В таком режиме фактическая позиция блока
равна `data_offset_raw + overlay`. Reader сначала проверяет, что overlay не
выходит за размер отображённого файла, затем проверяет весь диапазон записи.
### Поиск, кэш и проверки RsLi
Запрос имени переводится в uppercase ASCII и укладывается в фиксированный ключ.
При признаке `0xABBA` используется сохранённое отображение сортировки. Если
признака нет, loader строит его после чтения каталога. Некорректный индекс
приводит к последовательному поиску.
Файл открывается через memory mapping. Runtime-запись хранит указатель на
упакованный диапазон, размеры и необязательный указатель на подготовленные
данные. Первый обычный `load` создаёт буфер и сохраняет результат; повторный
возвращает его из кэша. Быстрый путь может вернуть указатель непосредственно в
mapped file только для исходного блока.
Reader проверяет:
- сигнатуру `NL`, служебный байт и версию;
- неотрицательное число записей;
- размещение всей таблицы в файле;
- что сохранённое отображение сортировки является перестановкой;
- что эффективный диапазон каждого блока не выходит за конец файла;
- что способ хранения известен;
- что после подготовки получено ровно `unpacked_size` байт.
В demo-каталоге и полных каталогах обеих частей наблюдаются два RsLi-файла:
```text
gamefont.rlb 2 entries, все 0x040 LZSS
sprites.lib 24 entries, все 0x100 raw Deflate
```
Последняя запись `sprites.lib::INTERF8.TEX` объявляет packed range, который
заканчивается на один байт после физического EOF. Совместимый путь читает на
один байт меньше; строгий путь регистрирует именованный quirk
`deflate_eof_plus_one`. Это исключение не распространяется на другие записи,
методы или произвольные выходы за конец файла.
Writer, который редактирует существующий архив, сохраняет все служебные bytes
заголовка и записей. Выбор оптимального способа упаковки для новых файлов
является отдельной политикой и не должен менять уже существующие entries без
явного запроса.
## Реестр объектов
Имя объекта в миссии является логическим ключом. Связь этого ключа с файлами
модели, материалов и служебных данных хранится в `objects.rlb`, который сам
использует формат NRes. Имя записи каталога -- ключ прототипа. Payload записи
состоит из записей по 64 байта:
```c
struct ObjectRef64 {
char archive_name[32];
char resource_name[32];
};
```
Payload каждой записи `objects.rlb` обязан быть кратен 64 байтам. Это
проверяется до чтения первой ссылки. Оба поля читаются как строки до первого
NUL, но полный 32-байтовый блок сохраняется при редактировании без очистки
хвоста.
Разрешение прототипа:
1. Найти entry реестра по логическому ключу без учёта ASCII-регистра.
2. Прочитать все `ObjectRef64` в исходном порядке.
3. Если ссылка указывает обратно в `objects.rlb`, рекурсивно раскрыть указанный
родительский prototype.
4. Объединить effective references родителя с локальными references дочерней
записи, сохранив порядок и происхождение.
5. Выбрать первую существующую ссылку с расширением `.msh`, открыть указанный
архив и найти модель по имени.
6. Загружать `.bas` как отдельный служебный ресурс сооружения, а не как замену
MSH.
7. Если effective prototype не содержит MSH, считать объект негеометрическим,
если это допускает его назначение.
Resolver обязан детектировать циклы наследования, ограничивать глубину и
кэшировать результат раскрытия. В обеих частях fortification-прототипы используют
явного родителя из `objects.rlb`: родитель предоставляет MSH/WEAR/CPT/NDP/CTL,
а дочерняя запись добавляет собственный BASE. Негеометрический объект не является
ошибкой сам по себе: системные и солнечные сущности могут участвовать в логике
или эффектах без mesh pass.
Контракт реализации:
- сохранять порядок ссылок внутри прототипа;
- не выводить имя модели из имени entry, если имеется явная ссылка;
- проверять существование указанного архива и ресурса независимо;
- отделять статус «негеометрический объект» от статуса «повреждённая ссылка»;
- кэшировать результат разрешения ключа, но инвалидировать его при замене архива;
- в diagnostic mode строить полный граф зависимостей и отмечать узлы, достижимые
из выбранной миссии.
В demo-варианте `objects.rlb` содержит 590 прототипов. У 554 есть прямая ссылка
на MSH; 549 таких ссылок разрешаются в доступных demo-архивах. Ещё 34 прототипа
раскрываются через родительскую запись `objects.rlb` и дополняются локальным
BASE. Семь записей не дают геометрию, а 41 ссылка всего реестра указывает на
контент, которого нет в урезанной поставке. Для 501 запросов прототипов,
порождаемых шестью demo-миссиями, найдены прототип, MSH и WEAR.
## Unit DAT
Запись миссии может ссылаться не на один ключ, а на unit-файл `*.dat`. Такой файл
перечисляет компоненты сложного игрового объекта.
```text
TMA object
-> путь к unit DAT
-> список component keys
-> несколько entries objects.rlb
-> модели, WEAR, control points, effects и другие ресурсы
```
Это объясняет, почему один размещённый unit может состоять из корпуса, башен,
оружия, эффектов и служебных частей. В демоверсии найдено 425 unit-файлов и
5 219 записей; все разобраны без ошибок. Наблюдаемый тип записи равен `1`, а
архив назначения -- `objects.rlb`. В 5 205 из 5 219 фиксированных полей имени
обнаружены ненулевые bytes после строкового терминатора; reader использует
строковую часть, а lossless writer сохраняет весь исходный блок.
Размер каждого unit DAT удовлетворяет формуле:
```text
file_size = 8 + record_count * 112
```
Первые два байта header равны `F1 F0`. Оставшиеся шесть bytes имеют несколько
наблюдаемых вариантов; их семантика пока не названа и они сохраняются как
`header_opaque[6]`.
```c
#pragma pack(push, 1)
struct UnitDatRecord112 {
char archive_name[32]; // +0x00
char resource_name[32]; // +0x20
uint32_t kind; // +0x40, в корпусе всегда 1
int32_t parent_or_link; // +0x44
char description[32]; // +0x48
uint32_t tail0; // +0x68, opaque
uint32_t tail1; // +0x6C, opaque
};
#pragma pack(pop)
```
Во всех проверенных records `archive_name == "objects.rlb"` и `kind == 1`.
Поле `parent_or_link` встречается как `-1`, `0`, `1` и другие небольшие индексы
и связывает компоненты составного unit; точная предметная классификация ссылки
ещё не закрыта. `description` -- человекочитаемое описание компонента. В Части 2
есть поля `description[32]`, полностью заполненные без NUL; это валидная bounded
string длиной 32 байта. Требование обязательного terminator применяется только
к полям, где оно доказано форматом. `tail0` и `tail1` нельзя нормализовать.
Проверено 425 файлов / 5 219 records Части 1 и 676 файлов / 8 145 records
Части 2. Все соответствуют формуле размера, `kind == 1` и
`archive_name == "objects.rlb"`.
## Вспомогательные форматы
MSH, материал и текстура отвечают за видимую форму. Полноценный прототип
дополнительно хранит точки крепления, зависимости, управляющие параметры,
области взаимодействия и ссылки на эффекты. Эти данные распределены между
несколькими небольшими форматами.
Для них действует строгая граница знания: framing, counts и валидность корпуса
могут быть подтверждены parser-ом, тогда как предметный смысл части полей
остаётся неизвестным. Reader предоставляет typed view для доказанных полей и
raw bytes для остальных. Инструмент должен показывать статус поля:
`layout-confirmed`, `consumer-inferred` или `opaque`.
### CTPT
В demo-корпусе найдено 284 CTPT-ресурса и 3 599 точек; все прочитаны без ошибок.
Имена показывают назначение слоя: `TurretCenter`, `TurretDirect`,
`CameraCenter`, `TargetDirect`, `Root`, `Sfx_1`, `Sign_Entrance1`, `Width`,
`Height`, `Dir`.
CTPT хранит локальные marker-точки модели. После применения transform такая точка
становится позицией или направлением в мире. Оружие может использовать её для
дула или оси башни, камера -- для привязки обзора, эффект -- для точки появления.
Конкретное назначение определяется именем и consumer-ом, а не одним общим флагом.
Первое 32-битное поле чаще равно `0`; встречаются `0x80000000` и редкий
вариант. До установления точной семантики оно хранится как `flags_raw`.
### NDPR
Проверено 494 NDPR-ресурса и 1 915 записей. Они ссылаются на `animals.rlb`,
`system.rlb`, `static.rlb`, `turrets.rlb`, `weapon.rlb` или используют пустое
имя архива. В 89 записях присутствует связанный эффект. Пустое имя архива
разрешается относительно текущего контекста. Reader хранит ссылку и остальные
параметры раздельно; writer сохраняет исходный порядок.
### EXPL и reference arrays
Проверено 144 ресурса EXPL: 26 используют версию 1, 54 -- версию 2, 64 --
версию 3. Reader выбирает layout по version field и требует точного завершения
payload. Полная field-level семантика всех версий пока не доказана, поэтому
version-specific opaque sections сохраняются.
Отдельная проверенная группа из 585 ресурсов содержит 2 956 однотипных
ссылочных records. Их границы и counts закрыты, однако единое предметное имя
всего семейства не подтверждено всеми consumers. В API безопаснее использовать
нейтральное `ReferenceArray` и конкретизировать назначение на уровне типа entry.
### SUND и CTLD
Два ресурса SUND содержат суммарно 12 ключей. Их следует загружать как параметры
системного объекта, а не как геометрию.
Для CTLD проверено 531 payload. Размеры и сочетания счётчиков сильно различаются,
поэтому parser должен быть версионно- и счётчик-ориентированным, а неизвестные
секции -- храниться в исходном виде.
### TRF, ANI и SKE
В демоверсии обнаружены 5 файлов TRF, 38 preload-записей, 8 ANI-ресурсов и
6 SKE-ресурсов. Все проходят структурный разбор. Эти семейства участвуют в
подготовке компонентов и анимационных или управляющих данных до создания
runtime-объекта.
Поскольку живой корпус невелик, редактор не должен синтезировать новые варианты
этих форматов по догадке. Безопасный режим -- читать доказанные счётчики и
ссылки, предоставлять raw-view неизвестных секций и обеспечивать побайтовое
сохранение неизменённых данных.
### BASE
Проверено 30 BASE-ресурсов; каждый содержит ровно один polygon record и проходит
структурную проверку. BASE payload и ссылка `.bas` в `objects.rlb` выполняют
связанные, но разные роли:
- наличие ссылки `.bas` позволяет registry resolver-у искать одноимённый
`<stem>.msh` в том же архиве;
- сам BASE payload загружается отдельной подсистемой сооружений и не заменяет
MSH geometry.
Resolver не должен интерпретировать bytes BASE как mesh. Writer сохраняет
polygon record и неизвестные поля 1:1, пока полный gameplay-контракт BASE не
подтверждён.
## Правило сохранения
Lossless editor сохраняет неизвестные поля, хвосты фиксированных строк,
служебные bytes, gaps, padding и unindexed regions. Writer пересчитывает только
явно производные значения: размеры, offsets, число записей, сортировочную
перестановку и padding. Такая дисциплина позволяет редактировать известную
часть ресурса, не разрушая данные, смысл которых пока не установлен.
Canonical repack допустим только как явная операция. Он может исключать
неиндексируемые диапазоны, пересортировывать таблицы и пересобирать padding, но
не должен быть побочным эффектом обычного редактирования. Если пользователь
открыл существующий архив и изменил один известный атрибут, все остальные bytes,
не являющиеся производными от этого изменения, должны пройти roundtrip без
потери.
+648
View File
@@ -0,0 +1,648 @@
# IV. Мир, миссии и игровой runtime
Миссия в Iron3D не является готовым снимком мира. Она задаёт исходные данные:
маршруты, кланы, размещённые объекты, свойства, ссылку на ландшафт и
дополнительные записи. Runtime строит из этого карту, пространственные
структуры, очередь `World3D`, визуальные представления, controllers и связи с
ресурсной системой.
Для совместимой реализации важно не смешивать три слоя:
1. **Disk data** -- `data.tma`, `Land.msh`, `Land.map`, `BuildDat.lst` и
связанные resource archives.
2. **Prepared data** -- разобранные paths, clans, terrain streams, areal graph,
prototype graph, material и texture handles.
3. **Runtime objects** -- World3D instances, domain controllers, spatial
registration, AI/scripts, timers и расчётный tick.
Граница между этими слоями нужна для диагностики и отката. Ошибка в достижимой
цепочке размещённого объекта должна остановить создание миссии до публикации
объекта в очереди событий. Недостижимая запись общего архива может быть
inventory warning и не обязана блокировать текущую карту.
## `data.tma`: данные миссии
`data.tma` -- основное описание расстановки и логической конфигурации миссии.
Он не содержит всю геометрию, материалы или AI-код. Файл перечисляет paths,
clans, objects, свойства и ссылки на внешние прототипы. Подробный справочный
контракт формата вынесен в [TMA](../reference/tma.md), но глава использует его
как часть сквозного runtime pipeline.
TMA читается строго последовательно bounded cursor-ом. Записи имеют переменную
длину, поэтому offsets следующих секций получаются только после разбора
предыдущих. Секции нельзя искать по сигнатурам: порядок управляется счётчиками,
длинами и mode-dependent ветками.
Главный критерий корректности -- `cursor.offset == file_size` после последней
записи. Неописанный хвост, переполнение при вычислении размеров, отрицательный
или чрезмерный count и выход за bounds являются ошибками parser-а, а не
материалом для эвристического восстановления.
### Верхний уровень
Все переменные строки в проверенных TMA используют length-prefixed primitive:
```c
struct LpString {
uint32_t byte_length;
uint8_t bytes[byte_length];
};
```
Завершающий NUL не является обязательной частью framing. Reader продвигается
ровно на `4 + byte_length`. Текст можно декодировать как legacy ANSI/CP1251 для
человекочитаемого представления, но исходные bytes сохраняются для lossless
режима.
Подтверждённый верхний уровень:
```text
u32 format_version // 1
u32 path_count
PathRecord paths[path_count]
u32 clan_section_version // 6
u32 clan_count
ClanRecord clans[clan_count]
u32 object_section_version // 10
u32 object_count
PlacedObject objects[object_count]
LpString land_path
u32 mission_flag
LpString description_raw
u32 extra_section_version // 1
u32 extra_count
ExtraRecord28 extras[extra_count]
```
Имена `clan_section_version`, `object_section_version` и
`extra_section_version` описывают устойчивое положение полей в контракте. Они
не доказывают исходные имена C++-структур. Strict mode проверяет известные
значения, compatible mode сохраняет raw value и сообщает диагностический
контекст.
### Paths
```c
struct PathRecord {
int32_t path_id;
uint32_t point_count;
float points[point_count][3];
};
```
Paths идут сразу после `path_count` без имён и padding. `path_id` не обязан
совпадать с физической позицией записи: script/gameplay reference должен
использовать сохранённый ID, а не индекс массива.
Перед выделением массива проверяются `point_count`, умножение `point_count *
12` и наличие всего диапазона в файле. Координаты хранятся как little-endian
`float32` triples в общей системе координат мира.
### Clans
Clan section задаёт участников миссии, их ресурсные связи, позиционные anchors
и таблицы отношений. Общая prefix-часть:
```text
LpString name
i32 raw_id
f32 anchor_x
f32 anchor_y
u32 mode
mode-dependent body
relation table
```
Для обычных modes `1..3` тело содержит две пары:
```text
LpString resource_path
i32 resource_tag
LpString resource_path
i32 resource_tag
```
После них идёт relation table:
```text
u32 relation_count
repeat relation_count:
LpString other_clan_name
i32 relation_value
```
Первая ресурсная строка обычно указывает на script/formula base, вторая -- на
TRF или пустой ресурс. Tags различаются между кланами и должны сохраняться как
raw-поля, пока их потребительская семантика не закрыта.
Mode `0` имеет отдельный count-driven layout:
```text
LpString first_resource
u32 spatial_group_count
repeat spatial_group_count:
u32 record_count
repeat record_count:
float raw_spatial[5]
LpString second_resource
i32 second_tag
u32 relation_count
relations...
```
Внутренний `record_count` в известных живых образцах равен `1`, но parser читает
объявленное значение. Нельзя разбирать mode `0` как обычные две resource
references: это сдвигает cursor и ломает последующую relation table.
### PlacedObject и свойства
Ключевое поле размещённого объекта -- `resource_name`. Оно имеет два рабочих
варианта:
1. прямой логический ключ прототипа, который ищется в `objects.rlb`;
2. путь к unit DAT, из которого получается список компонентных ключей.
Доказанное framing объектной записи:
```text
u32 raw_kind
u32 class_or_flags
LpString resource_name
u32 raw_after_resource
u32 identity_or_clan_raw
f32 position[3]
f32 orientation[3]
f32 scale[3]
LpString instance_name
u32 raw_after_name
i32 link0
i32 link1
u32 property_schema_version // 1
u32 property_count
Property properties[property_count]
```
`orientation[3]` названа по наблюдаемому использованию как transform-поле, но
точный Euler order должен подтверждаться pose/render parity. `scale` в
большинстве записей равен `(1,1,1)`. `instance_name` может быть пустым у
unit-ссылки или содержать stem размещённого прототипа.
Свойства хранятся как ordered property bag:
```text
Property:
u32 raw_value[4]
LpString name
```
Порядок, повторяемость имени и raw 16-byte value важнее удобного словаря.
Разные consumers интерпретируют четыре слова как integer, float, default или
range data в зависимости от имени свойства. Typed view допустим только для
доказанных property names; базовый parser обязан сохранить исходный порядок.
В раннем проверенном корпусе на каждом из 201 размещённого объекта встречаются
`Invulnerability` и `Life state`. Для 48 unit-ссылок дополнительно наблюдаются
`LogicalID`, `ClanID`, `Type`, `MaxSpeedPercent`, `MaximumOre`, `CurrentOre`,
`ChargeRadius`, `FreeBotNum`, `FreeTechnoNum`, `FreeConstructionTime` и
`FreeResearchTime`. Имя `NOT USED` встречается массово и сохраняется как
обычное поле, несмотря на исторический смысл названия.
### Epilogue и extras
После объектов идут путь к ландшафту, флаг миссии, raw-описание и trailing
section. `description_raw` не всегда является чистым текстом: внутри
объявленной длины встречаются служебные bytes и остатки путей. Поэтому decoded
view является вспомогательным, а не каноническим представлением.
```c
struct ExtraRecord28 {
float position[3];
uint32_t raw[4];
};
```
Последние четыре слова `ExtraRecord28` пока не нормализуются. Reader хранит их
как raw data и не позволяет extra record поглотить начало следующей секции или
файловый хвост.
Покрытие полных каталогов:
```text
Часть 1: 29 TMA, 34 paths, 101 clans, 864 objects, 28 extra records
Часть 2: 31 TMA, 61 paths, 91 clans, 885 objects, 41 extra records
```
Версии стабильны: верхний уровень `1`, clan section `6`, object section `10`,
property schema `1`, trailing section `1`. У всех размещённых объектов
`class_or_flags == 0x80000002`.
## Сквозная загрузка миссии
`data.tma` описывает размещение, но видимый runtime-объект появляется только
после прохождения dependency graph. Простая загрузка файлов с похожим stem
работает на отдельных объектах, но ломается на составных unit DAT, изменённых
именах моделей и наследовании прототипов через `objects.rlb`.
Сквозная цепочка:
```text
TMA object
-> direct prototype key или unit DAT
-> component key
-> objects.rlb entry
-> MSH и WEAR
-> material slots
-> MAT0 phases
-> Texm и lightmap
-> prepared World3D instance
```
Контейнеры и графические форматы описаны отдельно в [NRes](../reference/nres.md),
[MSH](../reference/msh.md), [WEAR и MAT0](../reference/materials.md) и
[Texm](../reference/texm.md). В этой главе они рассматриваются как ребра
создания мира.
### Фазы loader-а
1. **Mission context.** Выбрать каталог миссии, прочитать конфигурацию и
определить карту.
2. **World foundation.** Загрузить `Land.msh`, `Land.map`, `BuildDat.lst` и
создать spatial managers.
3. **Mission description.** Разобрать TMA, paths и clans, но пока не публиковать
объекты.
4. **Prototype resolution.** Для каждой размещённой сущности раскрыть прямой
ключ или unit DAT и построить component list.
5. **Resource preparation.** Открыть требуемые RLB/LIB, проверить MSH, WEAR,
MAT0, textures, lightmaps и effects.
6. **Instance construction.** Создать World3D objects и domain controllers,
заполнить transform, ownership и properties.
7. **Registration.** Только после успешной настройки добавить instances в
queue и spatial structures.
8. **Scenario start.** Подключить AI/scripts, активировать timers и разрешить
первый calculation tick.
Разделение construction и registration предотвращает появление наполовину
созданного объекта в очереди событий. Если ошибка возникает до регистрации,
pending objects освобождаются без рассылки gameplay-событий. После регистрации
откат выполняется через обычный lifecycle очереди.
### Статистика dependency graph
Для ранних шести миссий 201 размещённый объект даёт 48 ссылок на unit-файлы и
153 прямых ключа. Unit-файлы раскрываются в 348 компонентов. Всего получается
501 запрос прототипа; для каждого достижимого запроса найдены запись реестра,
MSH и WEAR.
Полный dependency graph частей 1 и 2:
```text
Часть 1
864 placed objects
463 unit references -> 4 300 components
4 701 prototype/MSH/WEAR requests
36 954 material slots
48 806 texture requests + 139 lightmaps
failures 0
Часть 2
885 placed objects
561 unit references -> 5 521 components
5 845 prototype/MSH/WEAR requests
50 888 material slots
68 603 texture requests + 214 lightmaps
failures 0
```
`failures 0` означает, что для каждой достижимой ветви найдены prototype,
effective MSH/WEAR, MAT0, Texm и lightmap. Это не означает, что во всём
глобальном каталоге нет недостижимых или служебных записей.
Метрики нужно помечать областью. Чистая object chain шести ранних миссий даёт
3 873 material slots и 5 049 texture requests. Mission total включает по одной
environment WEAR-таблице на миссию и становится 3 879 material slots и 5 067
texture references.
### Диагностика ошибок
Ошибка привязывается к конкретному ребру графа:
- миссия ссылается на отсутствующий unit-файл;
- unit DAT раскрывается в component key, которого нет в реестре;
- prototype найден, но его MSH отсутствует в ожидаемом archive;
- WEAR указывает на неизвестный MAT0;
- MAT0 phase ссылается на отсутствующий Texm или lightmap;
- prepared object не прошёл валидацию transform/properties.
Сообщение вида `resource not found` недостаточно для восстановления каталога.
Диагностика должна содержать исходный placed object, раскрытый ключ, archive,
entry и тип связи.
## `Land.msh`: ландшафт как специализированная модель
`Land.msh` является [NRes](../reference/nres.md)-архивом, но его содержимое
отличается от обычной объектной MSH. Он хранит геометрию поверхности, таблицы
участков и ускорители пространственных запросов. Видимые buffers являются лишь
частью данных: CPU-подсистемам остаются нужны adjacency, surface classes и
cell accelerator streams.
Во всех проверенных картах порядок типов одинаков:
```text
1, 2, 3, 4, 5, 18, 14, 11, 21
```
Типы `1`, `3`, `4` и `5` совместимы по базовому представлению с узлами,
позициями, нормалями и UV обычной модели. Типы `11` и `21` специфичны для
terrain; `14` и `18` являются дополнительными потоками.
### Streams и размеры элементов
```text
type 1 38 байт node/slot mapping
type 3 12 байт float3 positions
type 4 4 байта packed normals
type 5 4 байта packed UV
type 11 4 байта cell accelerator data
type 14 4 байта auxiliary stream
type 18 4 байта auxiliary stream
type 21 28 байт terrain face
```
Для этих streams `attr1` соответствует числу элементов, а `attr3` -- stride.
Тип `2` начинается заголовком размером `0x8C`, после которого идут slot records
по 68 байт. Число slots вычисляется как `(size - 0x8C) / 68`; reader проверяет
делимость, bounds и отсутствие хвоста.
### `TerrainFace28`
Запись type `21` связывает triangles, соседей и surface metadata:
```text
+0x00 .. +0x07 flags и служебные поля
+0x08 u16 vertex0
+0x0A u16 vertex1
+0x0C u16 vertex2
+0x0E u16 neighbor0
+0x10 u16 neighbor1
+0x12 u16 neighbor2
+0x14 .. +0x1B material/class/edge fields
```
Каждый vertex index обязан быть меньше числа позиций type `3`. Neighbor равен
`0xFFFF` либо указывает на другой элемент type `21`. Последние восемь bytes
сохраняются без нормализации до полного закрытия предметной семантики.
### Маски поверхности
Runtime использует полную 32-битную маску face и два compact-представления.
Основное 16-битное поле собирается из отдельных битов полной маски; второе
шестибитное поле хранит material classes. Это не усечение младших битов.
Для совместимого writer-а нужны явные функции `full_to_compact()` и
`compact_to_full()`. Неизвестные биты полной маски сохраняются отдельно, иначе
обратное преобразование потеряет информацию.
Основное соответствие:
```text
full 00000001 -> compact 0001
full 00000008 -> compact 0002
full 00000010 -> compact 0004
full 00000020 -> compact 0008
full 00001000 -> compact 0010
full 00004000 -> compact 0020
full 00000002 -> compact 0040
full 00000400 -> compact 0080
full 00000800 -> compact 0100
full 00020000 -> compact 0200
full 00002000 -> compact 0400
full 00000200 -> compact 0800
full 00000004 -> compact 1000
full 00000040 -> compact 2000
full 00200000 -> compact 8000
```
Для шестибитного material-поля используются full-биты `0x100`, `0x8000`,
`0x10000`, `0x40000`, `0x80000` и `0x80`; они переходят соответственно в
compact-биты `1`, `2`, `4`, `8`, `0x10`, `0x20`.
### Проверенное покрытие
```text
AutoMAP 3 051 вершина, 3 174 faces
PROL 11 125 вершин, 9 234 faces
Tut_1 8 827 вершин, 8 290 faces
Tut_2 9 456 вершин, 8 996 faces
Tut_3 9 833 вершины, 8 560 faces
Tut_4 9 022 вершины, 8 612 faces
```
Расширенное покрытие:
```text
Часть 1: 33 карты, 299 450 vertices, 275 882 faces
Часть 2: 32 карты, 188 024 vertices, 184 454 faces
```
Во всех 65 картах порядок типов равен `[1,2,3,4,5,18,14,11,21]`. Strides,
count-driven размеры, vertex indices, neighbor indices и payload bounds
валидны. Различия карт являются различиями данных, а не новым вариантом
loader-а.
## `Land.map` и ArealMap
`Land.map` хранит логическое разбиение пространства на связанные области. Это
NRes-архив с одной записью type `12`. Payload содержит переменное число
ареалов, links и grid быстрого поиска.
Ареал -- участок мира с геометрической границей и метаданными. Граф соседств
позволяет искать маршрут между крупными областями вместо обхода каждой
terrain-вершины. Grid отвечает на быстрый вопрос: какие области потенциально
находятся рядом с координатой.
### Prefix ареала
```c
struct ArealPrefix56 {
float anchor_x;
float anchor_y;
float anchor_z;
float reserved_12;
float area_metric;
float normal_x;
float normal_y;
float normal_z;
uint32_t logic_flag;
uint32_t reserved_36;
uint32_t class_id;
uint32_t reserved_44;
uint32_t vertex_count;
uint32_t poly_count;
};
```
После prefix идут `float3 vertices[vertex_count]`. Нормаль в проверенных
записях имеет длину, практически равную единице. Поля `reserved_12`,
`reserved_36` и `reserved_44` в живом корпусе равны нулю, но writer сохраняет
их без нормализации.
### Links и polygon blocks
За вершинами хранится массив:
```c
struct EdgeLink8 {
int32_t area_ref;
int32_t edge_ref;
};
```
Пара `(-1, -1)` означает отсутствие соседа. Иначе `area_ref` указывает на
другую область, а `edge_ref` -- на соответствующее ребро. Число пар равно
`vertex_count + 3 * poly_count`.
После links для каждого polygon читается `u32 n`, затем block размером
`4 * (3*n + 1)` bytes. Во всех 65 проверенных картах `poly_count == 0`.
Framing ветки восстановлен по loader path, но предметное поведение polygon
blocks не получает статус corpus-verified.
### Grid быстрого поиска
После всех ареалов записаны `cellsX` и `cellsY`. Далее для каждой ячейки идут
`u16 hitCount` и `hitCount` номеров областей. Runtime уплотняет это в одно
32-битное значение: старшие 10 бит содержат число попаданий, младшие 22 --
начальный индекс в общем пуле.
Grid не является точной геометрической проверкой. Он возвращает короткий список
candidates, после чего выполняется проверка принадлежности области. При
загрузке каждый area ID обязан быть меньше общего числа ареалов.
Покрытие:
```text
Ранние шесть карт: 3 811 areals, grid 128 x 128
Часть 1: 33 карты, 34 662 areals, 197 698 areal vertices
Часть 2: 32 карты, 18 984 areals, 114 968 areal vertices
```
Во всех картах grid равен `128 x 128`. Максимальное число candidates в ячейке
-- 20 для Части 1 и 14 для Части 2. Все area/edge references находятся в
диапазоне, normals имеют единичную длину в пределах float32-погрешности, parser
заканчивается точно на конце payload.
## Пространственные задачи runtime
Движок решает три похожих, но независимых вопроса:
- **видимость** -- нужно ли рисовать объект для текущей камеры;
- **столкновение** -- пересекается ли движение с поверхностью или другим телом;
- **навигация** -- через какие области допустимо провести маршрут.
Terrain, Control и ArealMap используют общие координаты мира, но разные
структуры данных. Нельзя заменять навигационный граф видимыми triangles или
вычислять collision только по границе areal. Render frame описан отдельно в
[Render frame](../reference/render-frame.md); здесь важна подготовка world data,
которую renderer получает уже после загрузки миссии.
### Поиск области
Координата переводится в ячейку grid из `Land.map`. Ячейка даёт список
candidate areas, затем выполняется точная геометрическая проверка. Такой запрос
не перебирает все области карты и не зависит от количества terrain faces.
Если координата попадает в несколько candidates, выбор должен учитывать
геометрию boundary и class/logic flags, а не только первый ID из grid cell.
Если область не найдена, caller получает явный miss и решает, допустим ли
fallback к ближайшей области.
### Маршрут
После определения начальной и целевой областей маршрут строится по графу
соседств. Результат высокого уровня -- последовательность areal IDs. Из неё
формируется локальный corridor, внутри которого movement controller выбирает
конкретное движение по поверхности.
Такое разделение оставляет навигацию устойчивой к деталям terrain mesh:
изменение density triangles не должно менять high-level route, пока areal graph
и links остаются теми же.
### Категории зон объектов
`BuildDat.lst` связывает 12 имён категорий с 32-битными масками:
```text
Bunker_Small 80010000
Bunker_Medium 80020000
Bunker_Large 80040000
Generator 80000002
Mine 80000004
Storage 80000008
Plant 80000010
Hangar 80000040
MainTeleport 80000200
Institute 80000400
Tower_Medium 80100000
Tower_Large 80200000
```
Файл читается секционно. Неизвестное имя, дублирование или нарушенная структура
не должны тихо превращаться в нулевую маску. Нулевая маска является
диагностируемым состоянием, а не универсальным default.
## Создание мира
Инициализация карты должна быть staged pipeline, а не набором независимых
autoload-ов:
1. открыть `Land.msh` и построить geometry/spatial данные terrain;
2. открыть `Land.map` и создать areals, links и cell grid;
3. загрузить категории `BuildDat.lst`;
4. создать world managers для поверхности, областей, света и атмосферы;
5. разобрать TMA, paths и clans;
6. раскрыть object resources через unit DAT и `objects.rlb`;
7. подготовить MSH, WEAR, MAT0, Texm, lightmap и FXID dependencies;
8. создать World3D objects и domain controllers в pending state;
9. проверить cross references между components, controllers и spatial data;
10. зарегистрировать visual, physical и behavior components;
11. подключить AI/scripts и разрешить первый calculation tick.
Минимальный псевдокод объектной части:
```c
for (const PlacedObject& placed : mission.objects) {
vector<string> keys = expand_resource_name(placed.resource_name);
for (const string& key : keys) {
Prototype p = registry.resolve(key);
PreparedVisual v = prepare_visual(p);
Object* o = construct_component(p, v, placed.properties);
o->set_world_transform(placed.transform);
pending_registration.push_back(o);
}
}
validate_cross_references(pending_registration);
register_all(pending_registration);
```
`prepare_visual` использует явные ссылки прототипа и правила fallback ресурсной
системы. Она не должна угадывать модель по имени placed object, если prototype
уже задаёт другой effective MSH/WEAR.
## Инварианты реализации
- Reader всех count-driven структур проверяет overflow до выделения памяти.
- Parser TMA, `Land.msh` и `Land.map` завершает работу точно на конце своего
payload.
- Неизвестные поля, reserved bytes, raw strings и property values сохраняются
lossless.
- Object properties остаются ordered property bag; сортировка имён запрещена.
- Clan relations и area links проверяются на диапазон, но физический порядок
записей сохраняется.
- Terrain vertex indices, face neighbors и areal references валидируются до
публикации spatial managers.
- Достижимый missing resource останавливает mission load до регистрации
объектов; недостижимая запись общего каталога остаётся диагностикой.
- Calculation tick включается только после успешной сборки terrain, areal graph,
managers, object queue и scenario bindings.
+863
View File
@@ -0,0 +1,863 @@
# V. Геометрия, материалы и рендер
Этот том описывает путь от загруженного игрового состояния до pixels в back
buffer. Renderer не решает игровые правила: он получает transforms, geometry,
материалы, свет, эффекты, камеру и список видимых объектов, затем превращает
их в упорядоченный набор draw calls и fixed-function states.
Графический pipeline FParkan держится на нескольких слоях данных:
```text
MSH node/slot/batch
-> Batch20.material_index
-> строка WEAR
-> имя MAT0
-> активная phase
-> textureName и lightmap slot
-> Texm payload
-> LegacyRenderState
-> draw item кадра
```
Важное практическое правило: форматы ресурсов, runtime-состояние renderer-а и
современный backend являются разными уровнями. Файл можно прочитать правильно и
всё равно получить неверный кадр из-за другой сортировки, другого mip-skip,
другой ветки material fallback или другого округления animation time.
## Контур рендера
Изображение является последней стадией длинного цикла. До renderer-а уже
накоплен ввод, рассчитан simulation step, применены отложенные операции,
обновлены animation states, выбрана camera и выставлен listener для 3D sound.
```text
system messages and input
-> simulation calculation
-> deferred object operations
-> animation and transforms
-> camera and sound listener
-> visibility and render queues
-> materials and draw passes
-> renderer completion
-> end-of-render callbacks and UI
```
CPU делает отбор объектов, сэмплирует animation, собирает matrices, выбирает
LOD/slot, группирует batches и готовит состояния. Графический pipeline
преобразует вершины из model space в screen space, rasterizes triangles,
проверяет depth, применяет texture stages, lighting, alpha test/blend и пишет
pixels.
Координатный путь вершины:
```text
local/model space
-> world space
-> view/camera space
-> clip space
-> normalized device coordinates
-> viewport pixels
```
Порядок умножения матриц и соглашение о layout должны быть едины во всём
движке. Ошибка транспонирования часто выглядит как сломанная анимация, хотя
ключи модели прочитаны верно.
## Граница Ngi32
`Ngi32.dll` является платформенной границей Iron3D-era renderer-а. Она создаёт
графический и звуковой interfaces, перечисляет устройства, хранит capability
profile, предоставляет память, часы и быстрые математические процедуры.
Высокоуровневые DLL должны обращаться к interface Ngi32, а не напрямую к
конкретному DirectDraw/Direct3D device.
`iron_3d.ini` задаёт выбранный `CURRENT_D3DCARD`. Display layer перечисляет
drivers и video modes, проверяет поддержку 3D, переводит native capabilities во
внутренний профиль и создаёт render object. `niCreate3DRender` принимает
выбранный driver/mode, window handle и flags владения, динамически получает
функции DirectDraw/Direct3D семейства 5-7 и публикует refcounted renderer.
`niGet3DRender` возвращает уже созданный объект и увеличивает число владельцев.
```text
enumerate adapters and video modes
-> choose CURRENT_D3DCARD
-> translate native capabilities
-> create DirectDraw surfaces and 3D interface
-> construct engine renderer
-> publish global refcounted pointer
```
Старый API работает как state machine. Перед draw подсистема terrain/shade
выбирает matrices, texture stages, filtering, depth test/write, culling, alpha
test, blending и vertex format. Современный backend может собрать это в
immutable pipeline key и реализовать через shaders, но compatibility layer
должен видеть исходную fixed-function модель.
```c
struct LegacyRenderState {
Mat4 world, view, projection;
TextureStage stages[2];
BlendMode blend;
DepthMode depth;
CullMode cull;
bool alpha_test;
uint8_t alpha_ref;
VertexFormat vertex_format;
};
```
Эта структура является переносимой моделью наблюдаемого контракта, а не
утверждением о точном layout оригинального объекта renderer-а.
Отдельная часть ABI -- таблица `g_FastProc`. При запуске выбираются scalar,
MMX, Katmai/SSE, 3DNow или PPro-реализации процедур, а `niGetProcAddress(index)`
возвращает pointer из изменяемой таблицы. Номер slot является частью ABI:
signature менять нельзя. Различия scalar/SIMD округления способны менять
animation sampling, culling, particles и даже gameplay-adjacent decisions.
## MSH как граф модели
`*.msh` является nested NRes, а не одной монолитной структурой. Geometry,
nodes, slots, batches, animation и служебные streams лежат в отдельных entries
и связываются по `type_id`. Физический порядок entries сохраняется для
roundtrip, но reader не должен выводить из него смысловую связь.
Карта основных entries:
```text
type 1 узлы и выбор slot, обычно stride 38
type 2 header 0x8C + slots по 68 байт
type 3 positions float3, stride 12
type 4 packed normals, stride 4
type 5 packed UV0, stride 4
type 6 index buffer, u16
type 7 triangle descriptors, stride 16
type 8 animation keys, stride 24
type 9 служебный поток модели
type 10 строки и имена узлов
type 13 draw batches, stride 20
type 15 дополнительный поток, stride 8
type 17 вспомогательные данные
type 18 редкий поток, stride 4
type 19 animation frame map, u16
type 20 редкая вспомогательная таблица
```
Базовый набор types стабилен для проверенных моделей Частей 1 и 2. Расширенный
вариант добавляет types 18 и 20. Редкий вариант `MTCHECK.MSH` имеет
альтернативный атрибут type 1; его payload нужно поддерживать copy-through до
закрытия layout.
### Узлы и slots
Type 1 обычно состоит из записей по 38 байт:
```c
struct Node38 {
uint16_t hdr0;
uint16_t parent_or_link;
uint16_t anim_map_start;
uint16_t fallback_key;
uint16_t slot_index[15];
};
```
`slot_index` образует матрицу `3 LOD x 5 groups`. Выбор выполняется как
`slot_index[lod * 5 + group]`; `0xFFFF` означает отсутствие geometry для этой
комбинации. Поле `parent_or_link` участвует в иерархии или связи узлов, но
название остаётся описательным.
Type 2 начинается с header `0x8C`, затем содержит slots по 68 байт:
```c
struct Slot68 {
uint16_t tri_start;
uint16_t tri_count;
uint16_t batch_start;
uint16_t batch_count;
float aabb_min[3];
float aabb_max[3];
float sphere_center[3];
float sphere_radius;
uint32_t opaque[5];
};
```
Slot связывает диапазон triangle descriptors, диапазон draw batches, AABB и
sphere bounds. AABB удобен для более точных осевых тестов, sphere -- для
быстрого отбрасывания. Последние пять слов сохраняются без интерпретации.
Обязательные проверки:
- `type 2` имеет размер не меньше `0x8C`;
- остаток после header кратен 68;
- каждый `slot_index` либо `0xFFFF`, либо меньше числа slots;
- `tri_start + tri_count` не выходит за type 7;
- `batch_start + batch_count` не выходит за type 13.
### Vertex streams, triangles и batches
Основные vertex streams:
```text
type 3: position = три float32
type 4: normal = четыре int8
type 5: UV0 = два int16
type 6: index = uint16
```
Normal XYZ декодируется как signed component / `127.0` с clamp в `[-1, 1]`.
Четвёртый byte normal stream не отбрасывается при roundtrip. UV декодируется
как `packed / 1024.0`. Index buffer адресует вершины относительно `base_vertex`
batch-а, поэтому проверка допустимости всегда использует
`base_vertex + index < vertex_count`.
Type 7 хранит descriptors triangles:
```c
struct TriDesc16 {
uint16_t tri_flags;
uint16_t link0;
uint16_t link1;
uint16_t link2;
int16_t nx;
int16_t ny;
int16_t nz;
uint16_t sel_packed;
};
```
Descriptors используются коллизией, выбором и связями triangles. `sel_packed`
содержит три двухбитовых selector-а; значение `3` преобразуется в отсутствие
ссылки (`0xFFFF`). Полная семантика links и flags не закрывается одним layout.
Type 13 задаёт draw ranges:
```c
#pragma pack(push, 1)
struct Batch20 {
uint16_t batch_flags; // +0x00
uint16_t material_index; // +0x02
uint16_t opaque4; // +0x04
uint16_t opaque6; // +0x06
uint16_t index_count; // +0x08
uint32_t index_start; // +0x0A
uint16_t opaque14; // +0x0E
uint32_t base_vertex; // +0x10
};
#pragma pack(pop)
static_assert(sizeof(Batch20) == 20);
```
`material_index` выбирает строку WEAR. `index_start`, `index_count` и
`base_vertex` описывают один indexed draw. Неизвестные поля могут влиять на
редкие проходы или state grouping, поэтому writer сохраняет их 1:1.
Типовой обход модели:
```c
for (Node& node : model.nodes) {
Matrix node_world = parent_world * local_transform(node);
uint16_t sid = node.slot_index[lod * 5 + group];
if (sid == 0xFFFF) continue;
Slot& slot = model.slots[sid];
if (camera.culls(transform(slot.bounds, node_world))) continue;
for (uint32_t i = 0; i < slot.batch_count; ++i) {
Batch& b = model.batches[slot.batch_start + i];
bind_wear_material(b.material_index);
draw_indexed(b.base_vertex, b.index_start, b.index_count);
}
}
```
В реальном кадре между culling и draw добавляются material resolve, lightmap,
render queues и сортировка, но связи данных остаются такими.
## Иерархия и анимация
Анимация MSH меняет локальный transform узлов. Geometry streams не изменяются:
для каждого узла на кадр строится matrix из position и quaternion. Дочерний
узел наследует transform родителя, поэтому изменение корпуса переносит башню,
точки крепления и все связанные slots.
Связка состоит из:
- type 8: пул animation keys;
- type 19: карта кадров;
- `anim_map_start` и `fallback_key` в `Node38`;
- parent links, задающих порядок умножения matrices.
Ключ type 8 занимает 24 байта:
```c
struct AnimKey24 {
float position[3];
float time;
int16_t qx;
int16_t qy;
int16_t qz;
int16_t qw;
};
```
Quaternion components декодируются как signed value / `32767.0`. На диске
порядок полей XYZ-W, но runtime math использует логическое `[w, x, y, z]`.
Безусловная современная нормализация после чтения не добавляется без parity
проверки: она может изменить крайние кадры.
Type 19 является массивом `uint16_t`; его `attr2` задаёт общее число кадров
timeline. Для конкретного узла `anim_map_start` указывает на блок длиной
`frame_count` либо равен `0xFFFF`.
Выбор ключа:
1. вычислить frame index из времени;
2. если frame вне диапазона, взять `fallback_key`;
3. если `anim_map_start == 0xFFFF`, взять `fallback_key`;
4. иначе прочитать `map_words[anim_map_start + frame]`;
5. если значение не меньше `fallback_key`, снова использовать fallback;
6. иначе использовать mapped key и следующий key для interpolation.
Fallback возвращается без interpolation. Это защищает статические узлы и конец
track-а.
Для времени между двумя keys:
```text
alpha = (t - k0.time) / (k1.time - k0.time)
position = lerp(k0.position, k1.position, alpha)
rotation = shortest-path quaternion blend
```
Перед quaternion blend проверяется dot product. Если стороны находятся в
противоположных полусферах, знак второй стороны меняется, чтобы пройти по
короткому пути. При точном совпадении времени возвращается соответствующий key
без вычисления alpha.
Объект может переходить между двумя animation states. Тогда для каждого узла
сэмплируются позы A и B, затем position смешивается линейно, а quaternion --
через shortest-path blend. Если одна сторона невалидна, используется другая.
```c
Pose sample_node(Node n, float t);
Pose blend_pose(Pose a, Pose b, float weight);
Mat4 local = quaternion_matrix(pose.rotation);
local.set_translation(pose.position);
world[n] = world[parent(n)] * local;
```
Для parity особенно важны x87-compatible округление при выборе frame index и
порядок операций. Одинаковая формула на SSE может выбрать соседний кадр возле
границы.
Проверки animation data:
- размер type 8 кратен 24;
- размер type 19 кратен 2;
- каждый `fallback_key` меньше числа keys;
- блок карты узла полностью помещается в type 19;
- времена keys внутри track возрастают;
- parent links не образуют cycle;
- quaternion components читаются как signed 16-bit.
## WEAR и MAT0
MSH batch хранит только числовой `material_index`. WEAR переводит позиционный
slot в имя материала. MAT0 по этому имени описывает phases, parameters,
texture names и animation blocks. Такое разделение позволяет одной geometry
использовать разные appearances.
```text
Batch20.material_index
-> строка WEAR
-> имя MAT0
-> активная phase
-> textureName и render parameters
```
### WEAR
WEAR имеет type ID `0x52414557` и обычно хранится как `*.wea` рядом с моделью.
Формат текстовый:
```text
<wearCount>
<legacyId> <materialName>
... wearCount строк
[пустая строка]
[LIGHTMAPS
<lightmapCount>
<legacyId> <lightmapName>
... lightmapCount строк]
```
`legacyId` читается и сохраняется, но material выбирается по позиции строки и
имени. Пустая строка перед `LIGHTMAPS` является частью совместимого framing:
parser paths по-разному обрабатывают переход, и отсутствие разделителя ломает
совместимость. Material handle кодируется как `(table_index << 16) |
wear_index`; manager поддерживает ограниченное число wear tables.
Fallback material resolve строго разделён:
1. имя из WEAR;
2. `DEFAULT`;
3. entry 0;
4. для lightmap отсутствие означает slot `-1`, а не замену обычной texture.
Пустое имя texture внутри phase означает намеренно untextured surface.
Lightmap ищется в отдельном cache и не подменяется diffuse texture.
### MAT0
MAT0 имеет type ID `0x3054414D` и обычно находится в `Material.lib`. `attr1`
содержит runtime flags, `attr2` -- версию payload. Versioned metadata читается
cursor-ом: старые версии получают runtime defaults, но reader не пытается
насильно читать поля новой версии.
```c
#pragma pack(push, 1)
struct Mat0PrefixV4Plus {
uint16_t phase_count; // +0x00
uint16_t animation_block_count; // +0x02, меньше 20
uint8_t metadata_a; // +0x04, attr2 >= 2
uint8_t metadata_b; // +0x05, attr2 >= 2
uint32_t metadata_c_raw; // +0x06, attr2 >= 3
uint32_t metadata_d_raw; // +0x0A, attr2 >= 4
};
struct Phase34 {
uint8_t parameters[18];
char texture_name[16];
};
#pragma pack(pop)
static_assert(sizeof(Phase34) == 34);
```
Если `attr2 < 2`, metadata A/B получают default `255`; при `attr2 < 3`
значение C соответствует `1.0f`; при `attr2 < 4` D равно 0. C/D сохраняются
как raw 32-bit values до полного подтверждения интерпретации. Phase parameters
сохраняются как 18 raw bytes даже там, где часть bytes уже имеет понятный
смысл.
Каждая phase разворачивается в runtime-запись примерно 76 байт: коэффициенты
цвета, освещения и прозрачности, texture slot и служебные поля. Material time
выбирает одну или две phases; только часть полей интерполируется, остальные
копируются из активной записи.
Animation block MAT0 имеет плотный framing без 4-byte tail alignment:
```text
u32 header_raw
u16 key_count
repeat key_count:
u16 k0
u16 k1
u16 k2
```
Младшие три бита `header_raw` задают числовой mode, остальные образуют mask
interpolation. Наблюдаются modes 0, 1, 2 и 3, связанные с семействами loop,
ping-pong, one-shot/clamp и random-offset, но точные boundary cases остаются
предметом runtime parity. Поле `k2` сохраняется всегда.
Проверки MAT0:
- `animation_block_count < 20`;
- все versioned metadata помещаются в payload;
- секция phases имеет ровно `phase_count * 34` байта;
- `texture_name` ограничено 16 байтами;
- каждый animation block и его keys помещаются в payload;
- parser заканчивает чтение на точном конце записи.
Material manager кэширует разобранный MAT0 и texture handles. Current phase
лучше вычислять на экземпляр материала, если random offset или локальное время
различаются между объектами; immutable phase data остаются общими.
## Texm: текстуры, mip-уровни и атласы
`Texm` -- основной формат изображений. Он хранится в `Textures.lib`,
`LightMap.lib` и других NRes-архивах. Payload содержит header, необязательную
palette, mip chain и иногда `Page` chunk для atlas rectangles.
```c
struct TexmHeader32 {
uint32_t magic; // 'Texm'
uint32_t width;
uint32_t height;
uint32_t mip_count;
uint32_t flags4;
uint32_t flags5;
uint32_t unknown6;
uint32_t format;
};
```
Подтверждённые formats:
```text
0 Indexed8 + palette 256 x 4 байта
565 R5 G6 B5
556 R5 G5 B6
4444 A4 R4 G4 B4
88 L8 A8
888 RGB8 в четырёхбайтовом element
8888 A8 R8 G8 B8
```
Formats 556 и 88 являются loader-confirmed, но не corpus-verified для
доступных игровых payload. CPU decoder расширяет короткие каналы до 8 bit через
повторение значимых bit, а не простым shift. Для 888 служебный четвёртый byte
сохраняется при roundtrip.
Layout:
```text
TexmHeader32
[palette 1024 байта, только для format 0]
level 0 pixels
level 1 pixels
...
level mip_count-1 pixels
[optional Page chunk]
```
Размер уровня `i` вычисляется из `max(1, width >> i)` и
`max(1, height >> i)`. Bytes per pixel: 1 для indexed; 2 для 565, 556, 4444 и
88; 4 для 888 и 8888. Parser суммирует размеры с проверкой overflow до чтения.
`Page` chunk:
```c
struct PageHeader8 {
uint32_t magic; // 'Page'
uint32_t rect_count;
};
struct PageRect8 {
int16_t x;
int16_t width;
int16_t y;
int16_t height;
};
```
Chunk обязан иметь размер `8 + rect_count * 8`; произвольный tail не
допускается. Rectangles задаются в pixel space базового mip. Если loader
пропускает верхние mip-уровни, rectangles масштабируются вместе с новым base
level.
Mip-skip является поведением loader-а, а не offline-изменением файла. После
skip меняются runtime width, height, mip count и pointer на первый загружаемый
уровень. Современный renderer должен повторить выбор base level или
эквивалентно эмулировать его upload policy; использование полной texture при
тех же UV меняет резкость и atlas coordinates.
Indexed texture требует связанную palette. Часть palettes выбирается по suffix
имени: буква `A..Z` и вариант пустой или `0..9`, всего 286 возможных slots.
Невалидный suffix диагностируется явно.
Обычные textures и lightmaps находятся в разных managers. Обычный cache
отслеживает refcount и время неиспользования, а eviction выполняется
отложенно. Lightmap lifetime связан с world/mission и не должен попадать под
ту же политику удаления.
Строгий Texm parser проверяет положительные dimensions, положительный
`mip_count`, известный format, точный размер palette/mip chain, корректный
`Page` и отсутствие лишних bytes. `flags4`, `flags5` и `unknown6` сохраняются
1:1; участие `flags5` в mip-skip подтверждено, но полная семантика всех bits не
закрыта.
## Свет, тени, атмосфера и сортировка
Свет является отдельной world-подсистемой. Terrain layer создаёт
`LightManager`, `Shader` и primitive managers. Это не один глобальный
коэффициент яркости: world управляет point lights, lightmaps, shadows,
atmospheric objects и sort phases. Материал сообщает свойства поверхности, а
CShade превращает их в states renderer-а.
Подтверждённые точки: `CreateLightManager`, `CreateShader`,
`CreateAtmosphere`, `CreatePrimitives`, `CreatePrimitives2`,
`CShade::StartMeshRender`, `CShade::EndMeshRender` и
`CShade::ConfigureTextureAndAlphaBlendModes`.
CShade получает active MAT0 phase, capability profile устройства и pass
context. Он выбирает texture mode, alpha blending, depth/cull behavior и способ
освещения. Наличие fallback вроде `TEXTUREMODE_MODULATE not supported`
означает, что material нельзя напрямую преобразовать в современный PBR.
Сначала строится legacy state, затем он сопоставляется shader permutation.
CLightManager выдаёт numeric IDs источникам и проверяет допустимое количество.
Ветка `EmulatePointLights()` позволяет воспроизводить point lights даже при
ограничениях hardware lighting. Неизвестный type light должен давать отдельную
ошибку.
Lightmap не является обычной diffuse texture. WEAR содержит отдельный блок
`LIGHTMAPS`, manager открывает `LightMap.lib`, а shade path подаёт lightmap
отдельным slot или texture stage. Замена lightmap предварительным умножением в
diffuse texture ломает LOD, atlas coordinates и динамическую модуляцию.
Тени проходят отдельным render pass. Terrain содержит пути для теней зданий и
роботов, ограничения максимального числа, detail level и smoothing. Доказаны
shadow manager/pass, настройки detail/smoothing/count и зависимость от
Terrain/CShade; полная формула projection geometry для каждого caster требует
dynamic trace. Unknown settings из `shade.cfg` читаются и сохраняются по
именам, а не заменяются произвольными modern defaults.
Atmosphere manager создаёт world objects для фоновых и погодных явлений.
Отдельно подтверждены lightning, sun render, flare, `env_lightning`, rain
background sound и обязательные ссылки на lightning effect. Эти объекты
обновляются по игровому времени, но часть параметров зависит от camera: flare
требует screen position и occlusion test, rain -- области рядом с observer,
sound -- listener. Их нельзя один раз запечь в terrain.
RNG для lightning, atmosphere phases и FX должен иметь стабильный порядок.
Даже правильный средний интервал не даёт повторяемый кадр, если random values
запрашиваются в другой последовательности.
Согласованная модель sort phases:
```text
opaque terrain and models
-> lightmapped/state-grouped passes
-> shadows and projected primitives
-> alpha-tested surfaces
-> transparent objects/effects back-to-front
-> atmosphere, flares and overlays
```
Точный взаимный порядок отдельных FX, shadow и atmosphere subpasses требует
capture. Новый renderer должен хранить явный `RenderPhase` и стабильный
secondary sort key, а не сортировать всё только по material ID.
## FXID: система эффектов
FXID -- не готовая картинка, а описание небольшого runtime command stream.
Header задаёт lifetime, time mode, random shifts и transform. Затем идут
команды разных types. При создании manager превращает disk-команды в runtime
objects; во время кадра они обновляются и выпускают sounds, particles,
materials или projected primitives.
Type ID равен `0x44495846`. Header занимает 60 байт:
```c
struct FxHeader60 {
uint32_t command_count;
uint32_t time_mode;
float duration_seconds;
float phase_jitter;
uint32_t flags;
uint32_t settings_id;
float random_shift[3];
float pivot[3];
float scale[3];
};
```
Поток команд начинается строго с offset `0x3C`. `duration_seconds`
преобразуется runtime-ом во внутреннюю шкалу времени. `phase_jitter` и
`random_shift` используются только при соответствующих flags. Pivot задаёт
локальную точку опоры, scale -- базовый масштаб экземпляра. Unknown flags и
settings ID сохраняются.
Каждая команда начинается с `uint32_t command_word`:
```text
opcode = command_word & 0xFF
enabled = (command_word >> 8) & 1
```
Bits 9-31 являются частью данных и сохраняются. Между командами нет
выравнивания. Размер команды, включая word:
```text
opcode 1 224 байта
opcode 2 148 байт
opcode 3 200 байт
opcode 4 204 байта
opcode 5 112 байт
opcode 6 4 байта
opcode 7 208 байт
opcode 8 248 байт
opcode 9 208 байт
opcode 10 208 байт
```
Parser использует opcode только для выбора фиксированного размера. Неизвестный
opcode отклоняется: попытка угадать длину потеряет синхронизацию всего stream.
Opcodes 2, 3, 4, 5, 7, 8, 9 и 10 содержат pair fixed strings:
```c
struct FxResourceRef64 {
char archive[32];
char name[32];
};
```
Имена сравниваются case-insensitive по ASCII, а tail после первого nul byte
сохраняется. Resolve выполняется при создании command object или лениво при
первом запуске, но ошибка должна включать имя эффекта, номер команды, archive
и resource name.
Базовый normalized age:
```text
tn = (now - start_time) / (end_time - start_time)
```
`time_mode` выбирает источник коэффициента: constant, forward/reverse age,
cyclic phase, external world state и варианты с ограничением относительно
предыдущего значения. Точные формулы редких modes являются parity-задачей.
Flags могут умножать alpha на lifetime, применять triangular remap, случайно
сдвигать phase/space, инвертировать active-state, фильтровать по времени суток
или включать manager gates.
Lifecycle:
```text
create instance
-> copy header and external transform
-> calculate end time and random offsets
-> create command objects in disk order
-> resolve required resources
-> Start
on each calculation/render frame
-> evaluate time coefficient and gates
-> update commands in stable order
-> emit active primitives or sounds
-> collect render batches
-> handle Stop / Restart / end-of-life
```
Update и emit разделяются. Simulation может продолжаться в кадре без render, а
emit не должен повторно менять игровое состояние. Для authoring безопасно
типизировать header и resource references, а body редких commands сохранять raw
до подтверждения field-level semantics.
## Полный кадр
Крупный вход в world render проходит через `World3D::stdRenderGame`. Доказан
следующий порядок boundary операций:
1. передать camera в Terrain через `stdSetCurrentCamera2` и сохранить её как
текущую;
2. получить camera/view/viewport interfaces через virtual queries;
3. обновить положение и ориентацию 3D sound listener;
4. настроить renderer viewport и matrices;
5. вызвать два renderer boundary slots перед traversal;
6. установить глобальный флаг `in_render`;
7. вызвать главный virtual метод camera/world traversal;
8. выполнить дополнительную post queue при включённом режиме;
9. завершить world/shade pass;
10. вызвать renderer completion slot;
11. снять `in_render`, восстановить viewport и разослать end-of-render.
Семантические имена нескольких slots перед и после traversal не подтверждены,
поэтому в compatibility code их лучше временно называть
`frame_boundary_0`, `frame_boundary_1`, `frame_boundary_2`.
Обход видимого мира:
```text
проверить active/visible state
-> выбрать LOD по расстоянию и настройкам
-> получить node matrices из animation state
-> выбрать slot для каждого node/group
-> преобразовать bounds в world space
-> выполнить culling
-> добавить batches в подходящую render queue
```
Material/texture resolve желательно выполнять после visibility и slot
selection, чтобы невидимые объекты не меняли порядок обращений к caches и не
создавали лишние side effects. Невидимость объекта и отсутствие slot являются
разными причинами пропуска и диагностируются отдельно.
Подготовленный draw item содержит:
```text
node world matrix
batch flags and index range
WEAR material handle
MAT0 active phase and coefficients
texture handle
optional lightmap handle
render phase and sorting key
legacy pipeline state
```
Draw item должен ссылаться на immutable данные кадра. Изменение phase или
texture cache посреди прохода не должно менять уже собранную очередь.
Согласованная декомпозиция внутренних render phases:
1. подготовка frame state, camera и viewport;
2. непрозрачный terrain;
3. непрозрачные object batches;
4. lightmap и дополнительные material passes;
5. projected primitives и тени;
6. alpha-tested geometry;
7. transparent objects и FX в сортировочных слоях;
8. atmosphere, sun, flare и weather;
9. renderer completion boundary;
10. end-of-render callbacks;
11. shell/UI и post-render state.
Точный взаимный порядок пунктов 4-8 и связь completion slot с физическим
DirectDraw flip/present требуют dynamic capture. Сортировка внутри каждой фазы
должна быть стабильной: для opaque первичен pipeline/material key, для
transparent -- distance layer и depth order, затем stable insertion ID.
Геометрический draw использует streams type 3/4/5, optional streams, index
buffer type 6, `base_vertex`, `index_start` и `index_count`. Матрица узла
устанавливается как world transform, затем CShade привязывает texture stages и
fixed-function state.
```c
set_world_matrix(item.node_world);
bind_vertex_streams(model.streams);
bind_index_buffer(model.indices);
apply_legacy_state(item.pipeline);
bind_texture(0, item.texture);
bind_texture(1, item.lightmap);
draw_indexed(item.batch.base_vertex,
item.batch.index_start,
item.batch.index_count);
```
После последнего world pass renderer закрывает сцену и выводит back buffer.
World3D снимает `in_render`, восстанавливает временный viewport state и вызывает
`on_end_render` у active objects. Только после этого допустимо освобождать
temporary vertex buffers или заменять render representation. UI/shell
обслуживается верхним уровнем после возврата из world-render path; для
диагностики полезно уметь сохранять world-only command list и финальный
framebuffer отдельно.
## Проверки паритета
Главные риски совпадения кадра:
- x87 extended precision и правила округления;
- различия scalar/SIMD slots `g_FastProc`;
- порядок objects, batches и transparent primitives;
- depth write/test, cull, alpha test и blend transitions;
- mip-skip, palette и `Page` coordinates;
- material fallback и выбор phase;
- последовательность RNG для FX и atmosphere;
- capability fallback конкретного устройства;
- quantization времени и дополнительный simulation step;
- eager/lazy resource resolve и cache side effects.
Минимальный deterministic frame capture должен включать camera state, viewport,
visible object IDs, выбранные LOD/group/slot, draw-item list, material и texture
handles, pipeline keys, matrices, render phase, sort key, причины culling и
hashes промежуточных buffers. Без такой трассировки нельзя уверенно отделить
ошибку формата MSH от ошибки state machine renderer-а или сортировки.
Связанные справочные страницы с таблицами форматов: [MSH](../reference/msh.md),
[materials](../reference/materials.md), [Texm](../reference/texm.md) и
[render frame](../reference/render-frame.md).
+769
View File
@@ -0,0 +1,769 @@
# VI. Поведение, управление, звук и сеть
Шестой том описывает подсистемы, которые превращают загруженный мир в
реагирующую игру: AI, Behavior, Wizard, Control, ввод, камеру, звук и сеть.
Эти области нельзя восстанавливать только по структуре файлов. Для них важны
порядок кадра, ownership объектов, timing событий и доказуемые границы между
решением, движением, presentation и транспортом.
Ключевой принцип: reader compatibility не равна gameplay compatibility.
Корректно разобранный ресурс ещё не доказывает, что runtime выбирает ту же
цель, строит тот же маршрут, применяет ту же collision correction, создаёт тот
же sound event или отправляет тот же network payload. Поэтому все утверждения
ниже разделяют подтверждённую структуру, восстановленный архитектурный
контракт и открытые участки, требующие динамической трассировки.
```text
AI / mission script
-> стратегическая цель, условия, команды миссии
Behavior
-> состояние объекта, target, global/local path
Wizard
-> локальная коррекция траектории
Control
-> physical step, collision proxy, итоговый transform
World3D
-> очередь событий, ownership, deferred deletion
Render / Sound / Net
-> представление, listener, mirrors и сообщения
```
Связанные главы: [мир и миссии](04-world.md), [геометрия и рендер](05-render.md)
и справочный [render frame](../reference/render-frame.md).
## AI, Behavior и Wizard
Iron3D разделяет стратегическое принятие решений, поведение конкретного объекта
и локальную коррекцию движения. Это разделение должно сохраниться в новой
реализации: стратегический AI не меняет transform напрямую, а collision manager
не выбирает игровую цель.
```text
ai.dll / SuperAI
-> цель клана, миссии и группы
Behavior.dll
-> состояние юнита, target, global path, local corridor
Wizard.dll
-> ближайшая допустимая траектория
Control.dll
-> физическое движение и столкновения
```
### Behavior
`CreateBehaviour` создаёт controller для отдельного игрового объекта.
`CreateDistributor` восстановлен по consumers как посредник распределения
команд или ресурсов; это высокоуверенный архитектурный вывод, а не доказанное
имя внутреннего класса. Behavior получает `IArealMap` через AI/клановый
контекст, ведёт radar/target state, строит global path, превращает его в local
corridor и передаёт движение Wizard.
Ошибочные состояния проверяются явно:
1. отсутствует system map;
2. отсутствует terrain interface;
3. active behavior не имеет `IArealMap`;
4. объект попал в non-reachable area;
5. объект пытается выйти из non-walkable area;
6. path generator вошёл в infinite cycle.
Эти случаи являются fatal или diagnostic conditions. Совместимая реализация не
должна тихо исправлять их teleport-ом, потому что такое исправление скрывает
ошибку areal graph, terrain query или state machine.
### Параметры Behavior.ini
Подтверждены настройки:
```text
PathFind_BuildingHitDist
PathFind_BuildingNearestDist
PathFind_NearBuildSpeedPercent
PathFind_CorridorRadius
PathFind_NearDoorCoeff
PathFind_fStepOffBuilding
PathFind_MaxAccel
PathFind_MaxRotation
PathFind_fStepDist
PathFind_MinPointInTrajectory
Network_ResourceTransferMaxDelay
```
Они задают геометрию corridor, дистанции реакции на здания, снижение скорости
возле препятствий, пределы ускорения и поворота, дискретизацию trajectory и
сетевой timeout передачи ресурсов. Значения читаются как runtime-конфигурация,
а не компилируются в код. Parser должен поддерживать комментарии `//`, пробелы
вокруг `=` и CRLF.
Файл также содержит logging/debug switches: `Behavior.log`, уровни ошибок,
show vectors и z-buffer debug. Эти переключатели полезны не только для
совместимости, но и как модель современных trace flags.
### Wizard
Wizard получает желаемое направление и corridor, анализирует ближайшие
ограничения и выдаёт скорректированную локальную траекторию. Behavior может
очищать её через `ClearWizardPath` при смене цели, повреждении global path или
переходе объекта в неактивное состояние.
Нужно различать четыре уровня движения:
- **global path** -- последовательность areals;
- **local path** -- точки или сегменты внутри corridor;
- **wizard path** -- краткосрочное движение с учётом ближайших препятствий;
- **physical step** -- фактически разрешённое Control перемещение.
Хранение всего маршрута одним массивом лишает систему возможности локально
обойти препятствие без полного повторного поиска. Граница Behavior/Wizard
существует именно для того, чтобы краткосрочная геометрическая коррекция не
ломала стратегический path state.
### SuperAI и миссионные сценарии
`CreateSuperAI` создаёт центральный controller клана; `GetSuperAI` возвращает
его. AI загружает файлы из `MISSIONS\SCRIPTS\`, проверяет версию и пишет ошибки
в `ai.log`. Несовпадение версии является отдельной ошибкой, а не неизвестной
командой.
Сценарный корпус содержит binary `.scr`, formula exports `.fml`, таблицу
переменных `varset.var` и `.trf`-данные. `.scr` хранит именованные секции и
события, например `Init`, `Mission`, `Problems0`, `Fort_Task_Complete` и
`Hero_Teleported`, вместе с числовыми ссылками на compiled instructions.
`.fml` является текстовым экспортом formula set. `varset.var` декларативно
описывает типы, defaults, ranges и строки через макросоподобные формы
`VAR(...)` и `STRING(...)`.
Безопасная runtime-модель:
```text
load script bundle
-> validate version and symbol tables
-> create global/formula variables
-> bind named events to instruction offsets
-> instantiate SuperAI per clan
-> dispatch MISSION_START and object events
-> update timers/conditions each simulation tick
-> enqueue game commands through World3D/Behavior
```
Сценарий не должен владеть игровым объектом напрямую. Он хранит logical/object
IDs и отправляет команды через игровые interfaces, чтобы удаление объекта или
сетевой mirror не оставили dangling pointer.
Полная grammar compiled instructions и точное значение всех opcodes остаются
открытым направлением. До появления decompiler-а `.scr` binary body сохраняется
lossless, а доказанные symbol/event tables документируются отдельно.
### TRF и preload-данные
TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial
variants имеют сигнатуру [NRes](../reference/nres.md) и содержат большие
таблицы имён игровых прототипов: оружия, башен, сооружений и других объектов.
Также найдены preload-записи, ANI и SKE resources.
По содержимому, порядку загрузки и consumers TRF с высокой вероятностью
предоставляет AI/сценарному слою заранее подготовленную таблицу типов и
связанных данных. Framing и имена подтверждены corpus-ом, но полная семантика
каждой TRF-записи ещё не закрыта. Имена должны разрешаться через тот же
resource registry, что и миссионные объекты.
### Стабильность AI-слоя
`ai.dll`, `Behavior.dll` и `Wizard.dll` побайтно идентичны в Частях 1 и 2. Это
подтверждает, что разделение SuperAI -> Behavior -> Wizard и бинарная
реализация этих трёх уровней не менялись.
Сценарный корпус:
```text
Часть 1: 58 SCR, 58 FML, 29 TRF
Часть 2: 59 SCR, 59 FML, 44 TRF
```
Все TRF являются структурно валидными NRes. Неизменность DLL усиливает вывод о
стабильной VM, но не закрывает instruction grammar `.scr`: для неё нужен
dispatcher/jump-table decompiler. Дополнительные сценарные данные расширяют
differential corpus, но не заменяют анализ VM.
## Control, физика и коллизии
Control превращает желаемое движение в физически допустимое изменение
состояния. World3D владеет жизненным циклом объекта; Terrain предоставляет
поверхность и world queries; Behavior/Wizard задают намерение; Control создаёт
physical controller и collision representation.
Публичная поверхность:
```text
InitializeSettings
LoadControlSystem
LoadPhysicalModel
CreateCollManager
CreateCollObject
```
Модуль импортирует World3D queue/object functions, `Terrain::GetWorld`, часы,
тригонометрию и `g_FastProc`. Это подтверждает его положение между gameplay
object и геометрией мира.
### Control system и physical model
`LoadControlSystem` загружает настройки controller-а: ограничения скорости,
ускорения, поворота и режимы управления. `LoadPhysicalModel` загружает форму и
параметры, используемые для столкновений. Visible MSH не обязан совпадать с
collision representation: для физики часто нужна более простая и устойчивая
форма.
Практичная runtime-модель:
```c
struct PhysicalState {
Transform transform;
Vec3 linear_velocity;
Vec3 angular_velocity;
float requested_speed;
float requested_turn;
uint32_t flags;
};
struct CollisionProxy {
ObjectId owner;
ShapeSet shapes;
Bounds broad_phase_bounds;
uint32_t category_mask;
};
```
Названия полей здесь описывают контракт совместимой реализации, а не точный
layout исходного C++-объекта.
### Collision pipeline
Один расчётный шаг удобно разделить так:
1. controller получает желаемые `speed`/`turn` от Behavior или manual input;
2. вычисляет кандидатный transform на основе `dt`;
3. обновляет broad-phase bounds collision object;
4. collision manager находит потенциальные пары и terrain candidates;
5. narrow phase вычисляет контакт или допустимый остаток перемещения;
6. physical state корректируется;
7. World3D получает итоговый transform;
8. событие `GMSG_COLLISION_DETECTED` отправляется в согласованной фазе.
Позиция collision event после narrow phase является рекомендуемой фазой
реализации и согласуется с назначением сообщения, но точный call-site
относительно всех correction steps требует динамической трассировки Control.
Удаление объекта из обработчика остаётся отложенным по правилам World3D.
Collision manager не должен хранить прямую незащищённую ссылку на объект,
который уже pending-delete.
### CTLD и physical resources
Реестр прототипов ссылается на `*.ctl`, `*.cpt` и связанные control resources.
В Части 1 структурно проверен 531 CTLD payload без ошибок. Размеры и пять
внутренних счётчиков образуют множество вариантов: наиболее частый размер
392 байта с pattern `(0,0,0,1,0)`, но встречаются блоки от примерно 212 до
1868 байт и более сложные комбинации.
CTLD является составным count-driven форматом, а не фиксированной struct.
Parser должен:
- прочитать prefix и все счётчики с проверкой переполнения;
- вычислить границы секций по их counts;
- сохранять неизвестные records в typed raw containers;
- требовать точного завершения payload;
- не использовать размер одного популярного варианта как универсальный layout.
Полная предметная семантика всех секций ещё не доказана, но существующие файлы
можно безопасно читать, индексировать и сохранять.
### Terrain queries и movement handoff
Control получает world-interface Terrain и использует поверхность, faces и
ускорители для высоты, нормали и пересечений. Навигационный маршрут сообщает,
куда двигаться, но итоговый transform определяется по физической поверхности.
При переходе через склон controller должен согласовать горизонтальный шаг,
высоту и ориентацию с terrain normal.
Порядок операций должен быть детерминированным: пары collision objects
сортируются по стабильному ID, contacts обрабатываются в фиксированной
последовательности, а интеграция использует одну политику `dt` и округления.
Иначе одинаковая миссия постепенно расходится даже без сети.
### Различия Control в Части 2
`Control.dll` пересобрана при неизменных размере, imports и пяти именах/ordinals
exports; RVA всех пяти exports изменились. Форматы и cross-module boundary
сохранились, но точное physical/collision behavior нельзя считать побайтно тем
же.
CTLD-корпус расширен с 531 до 623 payload. Новых framing errors не найдено;
большинство общих CTLD изменено вместе с переработанными моделями. Это
подтверждает count-driven parser, но не закрывает предметную семантику shape
records и contact solver.
Differential test обеих частей должен воспроизводить движение без препятствий,
slope following, pair collision, timing collision event и удаление объекта в
callback. Сравниваются transforms и contact events по tick, а не только факт
успешной загрузки.
## Ввод, камера и управление
World3D нормализует клавиатуру, мышь и joystick в общие scan codes и manual
commands. Win32 message handler вызывает `UpdateManualEventsList`; перед
обработкой новой порции сообщений основной цикл вызывает
`ClearManualEventsList`. Снимок клавиатуры очищается отдельно через
`stdClearKeyboard`.
Публичная поверхность включает `WinMsg2ScanCode`, converters для
keyboard/mouse/joystick/predicate, `ScanCode2Str`, `ManualCommand2Str`,
`stdIsKeyPressed`, lock/unlock keyboard и чтение mouse shift. Это позволяет
хранить конфигурацию управления независимо от физического устройства.
### Event, state и axis
Ввод имеет минимум три семантики:
- **edge event** -- нажатие или отпускание в текущей порции сообщений;
- **held state** -- клавиша остаётся нажатой между кадрами;
- **analog value** -- смещение мыши или положение joystick axis.
Manual command дополняет источник коэффициентом, режимом wrap, dead
zone/threshold и временной характеристикой. Строки camera bindings показывают
команды `MCMD_STATE`, `MCMD_ANGLE_X`, `MCMD_ANGLE_Y`, режимы `MAN_WRAP` и
`MAN_NOTWRAP`, а также параметры ускорения в миллисекундах.
Simulation читает подготовленный input snapshot. Renderer не должен
самостоятельно опрашивать OS, иначе одно и то же нажатие будет зависеть от
частоты кадров.
### Joystick через DirectInput
`Joystick.dll` экспортирует:
```text
QueryJoy
CreateJoy
ReleaseJoy
SetJoyRange
PeekJoyMessage
GetJoyCaps
```
`QueryJoy` обнаруживает устройство, `CreateJoy` получает интерфейс DirectInput,
`SetJoyRange` нормализует оси в диапазон движка, `PeekJoyMessage` выдаёт
очередное унифицированное событие.
При потере устройства чтение может вернуть ошибку acquired state. Интерфейс
следует повторно получить, очистить устаревшее состояние и продолжить.
Hot-unplug не должен оставлять последнюю ось навсегда отклонённой.
`GetInstalledJoyNames` и `SetActiveJoy` в World3D связывают device list с
game-facing выбором.
### Два camera interface
World3D предоставляет `stdSetCurrentCamera`/`stdGetCurrentCamera`: это камера
как часть игрового состояния. Terrain имеет
`stdSetCurrentCamera2`/`stdGetCurrentCamera2`: concrete camera, которую world
renderer использует для matrices, viewport и visibility.
`LoadCamera` экспортирован обоими модулями. По call graph World3D-вариант
играет роль component bridge, а Terrain-вариант связан с concrete
camera/world implementation. Это архитектурный вывод: точные class names и
layout не восстановлены.
Минимальные данные камеры:
```text
world position and orientation
view matrix
projection parameters / field of view
near and far planes
viewport rectangle
camera mode and target object
manual angles/state
```
Такая граница позволяет game code работать с абстрактной камерой, не зная
внутреннего renderer representation.
### Camera commands и порядок кадра
Подтверждены команды `CMD_CAMERA_LEFT`, `CMD_CAMERA_RIGHT`, `CMD_CAMERA_UP`,
`CMD_CAMERA_DOWN`, `CMD_CAMERA_CENTER`, `CMD_CAMERA_INFRARED`, а также
spotlight и внешние/миссионные camera modes. Горизонтальный угол использует
wrap, вертикальный -- ограниченный диапазон. Center плавно возвращает обе оси к
заданному значению.
Порядок кадра:
1. собрать manual events;
2. обновить camera controller во время calculation;
3. вычислить итоговый transform и ограничения;
4. перед render установить current camera;
5. передать её Terrain и sound listener;
6. после кадра сохранить mode-specific state.
Camera smoothing должно использовать игровое время или специально
подтверждённые часы. Привязка к render delta делает управление разным при 30 и
144 FPS.
## Звуковая подсистема
Ngi32 создаёт низкоуровневый DirectSound backend. `services.dll` публикует
`ISoundServer`. Game, Terrain и FX работают уже через эти интерфейсы:
воспроизводят 2D/3D sources, меняют volume и связывают listener с camera.
Публичные функции Ngi32:
```text
niCreate3DSound
niGet3DSound
niGet3DSoundCaps
niMuteSound
```
Backend динамически вызывает `DirectSoundEnumerateA` и `DirectSoundCreate`;
параметр `DisableDSound` может полностью отключить этот путь.
### Устройство и capabilities
Конфигурация учитывает `3D Sound`, качество, reverse sound, частоту buffer,
режим постоянного воспроизведения и автоматический выбор лучшего устройства.
Эти значения преобразуются во внутренний capability/profile object до создания
sources.
Код содержит отдельный no-device state и строку `3D Sound was not initialized`.
Отсутствие 3D sound обрабатывается отдельно от ошибок simulation/resources.
Новый runtime не должен позволять отсутствию звука разрушать simulation и
обязан возвращать звуковым командам явный no-device result.
Общий sound object разделяется между подсистемами и использует счётчик
владельцев. Закрывать DirectSound следует после остановки всех sources и
atmosphere/FX managers.
### Sound resources и SWAV
Основная библиотека называется `sounds.lib`; `mission.cfg` также создаёт
именованные sound resources и variations. Legacy API `rsLoadWave` загружает
waveform из archive. Импорт `MSACM32` подтверждает путь преобразования сжатых
wave-данных в формат playback buffer.
Resource identity состоит из library и name. Один sound asset может иметь
несколько runtime sources с различными position, volume, pitch/flags и временем
запуска. Поэтому кэшировать следует decoded sample/buffer, а source object
создавать на событие.
FX opcode 2 хранит `archive[32] + name[32]` и обычно создаёт sound command.
Atmosphere использует отдельные loop/variation sources, например rain
background. Миссионный слой содержит voice events для завершения или провала
задания.
Проверенный SWAV-корпус:
```text
Часть 1: 399 — 306 MS ADPCM, 93 PCM
Часть 2: 540 — 446 MS ADPCM, 93 PCM, 1 empty entry
```
Все непустые записи имеют RIFF/WAVE framing и частоту 22 050 Hz. В Части 2
entry `ALIEN_ME.WAV` имеет размер 0. Это присутствующий archive key без
decodable waveform.
Sound loader должен различать:
- `entry_missing`;
- `entry_empty`;
- `wave_invalid`;
- `decoded_sample`.
Нулевой payload не передаётся RIFF parser-у и не должен приводить к чтению
header за границей.
### 3D listener и sources
Перед world traversal `stdRenderGame` обновляет listener из camera transform.
Listener содержит position, orientation и, при наличии, velocity. Source
содержит world position и параметры затухания. Spatialization выполняется
backend-ом либо совместимой программной моделью.
```text
camera transform
-> listener position/front/up
object or effect transform
-> source position
sample + source parameters
-> DirectSound 3D buffer
```
Прямо подтверждено обновление listener в начале `stdRenderGame`, до world
traversal. Sound events могут создаваться и в calculation/FX path, поэтому
нельзя утверждать, что listener предшествует созданию каждого source. Важно,
что spatial backend получает camera state текущего отображаемого кадра до
завершения его обработки. Перенос listener update после world render создаст
как минимум однокадровое рассогласование presentation.
### Громкость, mute и CD-аудио
`iron3d.dll` применяет отдельные настройки эффектов и CD sound. Параметр
`FORCE_CD_SOUND` меняет политику выбора музыкального источника. `niMuteSound`
должен временно остановить вывод без разрушения sample cache и logical playback
state.
В новой реализации полезно разделить buses: master, effects, ambient, voice и
music/CD. Это проектное решение совместимого backend-а, а не доказанный layout
оригинального mixer-а. Оно позволяет применять старые коэффициенты, не
переписывая individual source volume.
### Граница service layer
`Ngi32.dll` с DirectSound/backend code не изменилась между Частями 1 и 2, но
`services.dll` пересобрана и уменьшилась на 4 096 байт. Поэтому low-level
decoder/device path подтверждается одной машинной реализацией, а service
lifecycle, GUI/audio wiring и defaults требуют раздельной трассировки обеих
частей.
## Сетевая подсистема
Net инкапсулирует DirectPlay4A и lobby/service-provider API. World3D строит над
транспортом player identity, mirror objects и игровые сообщения. Эти уровни
следует разделять: DirectPlay отвечает за доставку bytes между players,
World3D -- за смысл сообщения и владение объектом.
Application GUID:
```text
{3C1D1F01-A870-11D1-8400-000021B14415}
```
Он передаётся network instance и service layer. Экземпляры с другим GUID не
принадлежат одному логическому приложению.
### Lifecycle соединения
Публичные функции Net покрывают полный цикл:
```text
CreateNetworkInstance
-> select/use service provider
-> setup connection
-> enumerate or create session
-> join/create session
-> create local player
-> send/receive messages and player data
-> destroy player
-> close session
-> close connection
```
Поддерживаются providers эпохи DirectPlay: TCP/IP, IPX и modem/lobby варианты,
если они установлены в системе. Функции явно проверяют, что DirectPlay enabled
до enumeration, session и player operations. Неверный порядок вызовов должен
возвращать понятную ошибку, а не разыменовывать пустой interface.
### Sessions, players и адреса
Net предоставляет enumeration service providers и sessions, выбор host/join,
player name/password/data, latency, максимальный размер сообщения, размер
очереди, server player info и provider address. Lobby launch обрабатывается
отдельной веткой.
Внутренняя модель должна хранить как минимум:
```c
struct NetPlayer {
TransportPlayerId transport_id;
uint16_t game_player_number;
string name;
RawBytes player_data;
bool is_local;
bool is_host;
};
```
Transport ID нельзя использовать как постоянный `ObjectId`. NetWatcher связывает
временный DirectPlay identifier с номером игрока и World3D entities.
### Игровые сообщения World3D
Подтверждённые имена message surface:
```text
GMSG_CREATE_REMOTE_PLAYER
GMSG_APPEND_RESOURCE
GMSG_CHANGE_OBJECT_OWNER
GMSG_SET_PLAYER_DATA
GMSG_MISSION_DATA_PATH
GMSG_TAKE_OBJECT
GMSG_TEXT_FOR_PLAYER
GMSG_SYNC_STATE
GMSG_CREATE_MIRROR
GMSG_PAUSE_REMOTE_PLAYER
GMSG_CONFIRM_PLAYER_DATA
GMSG_KILL_PLAYER
SYSMSG_SET_TIME
SYSMSG_SET_PLAYER_NUMBER
GMSG_END_MESSAGE_SEQ
GMSG_REMOVE_RESOURCE
```
`GMSG_COLLISION_DETECTED` относится к общей очереди, но не обязательно
передаётся по сети. Message ID, payload size и delivery policy должны быть
частью явной schema. Нельзя сериализовать C++ pointers или native padding.
### Mirror objects и ownership
Удалённо принадлежащий объект представлен local mirror instance. Он участвует в
рендере и spatial queries, но authority над его созданием, ключевыми properties
и удалением находится у owner player. Сообщение смены владельца обновляет эту
границу; оно не должно создавать второй объект с тем же ID.
Типовой путь:
```text
remote create message
-> validate player and ObjectId
-> resolve prototype/resources
-> CreateMirrorObject
-> apply initial state
-> AddMirrorObjectToGame
-> subsequent sync messages update mirror
```
При потере player NetWatcher инициирует предписанное удаление или transfer
ownership через World3D queue. Мгновенное освобождение во время receive callback
запрещено по тем же причинам, что и в calculation pass.
### Сжатие и wire compatibility
`netZipData` и `netUnZipData` образуют встроенный слой упаковки payload. Он
находится выше транспорта: переход с DirectPlay на UDP/ENet не отменяет
необходимость воспроизводить формат упакованного сообщения, если требуется
соединение с оригинальной игрой.
Полный wire schema, framing и алгоритм сжатия пока не доказаны packet
capture-ом. Поэтому нужны два режима:
- **native compatibility** -- отдельный adapter, реализуемый после трассировки
оригинальных packets;
- **modern multiplayer** -- новая versioned protocol schema, использующая ту же
game-message семантику, но не заявляющая совместимость с DirectPlay client.
Эти режимы нельзя незаметно смешивать. До доказательства native wire
compatibility современный transport должен быть versioned и отделён от слоя,
который претендует на совместимость с оригинальным клиентом.
### Стабильность сетевого слоя
`Net.dll` и `World3D.dll` побайтно идентичны в обеих частях. Application GUID,
DirectPlay wrapper, mirror-object API и World3D message surface относятся к
одной машинной реализации.
Это подтверждает отсутствие отдельной сетевой реализации для Части 2, но не
закрывает wire schema: без packet/send-receive capture по-прежнему неизвестны
точное framing, reliability flags, payload layouts и алгоритм `netZipData` для
native interoperability.
Для binary regression достаточно одного профиля неизменённых DLL, но message
captures должны включать контент обеих частей, потому что prototype/resource IDs
и mission data различаются.
## Контракты реализации
Совместимая реализация должна фиксировать не только результат, но и момент его
появления в кадре. Для Behavior, Control, input, sound и network особенно важны
tick boundaries: одна и та же команда, применённая на один tick раньше или
позже, меняет дальнейшую симуляцию.
### Trace-события
Минимальный trace для этого тома:
- input snapshot: edge events, held state, analog values;
- camera state: mode, target, angles, matrices, viewport;
- Behavior: target, areal, global path revision, local corridor;
- Wizard: requested vector, constraints, wizard path;
- Control: candidate transform, contacts, correction, final transform;
- World3D queue: message name, ObjectId, dispatch phase, deferred deletion;
- sound: sample key, source owner, position, event tick, listener state;
- network: player mapping, message ID, payload length, delivery policy.
Для рендера это связывается с [render frame](../reference/render-frame.md):
camera и listener должны попадать в trace до world traversal, иначе нельзя
отделить ошибку presentation от ошибки управления.
### Проверки Behavior и сценариев
- script version mismatch даёт отдельную ошибку;
- event table читается lossless;
- VM body сохраняется без потери неизвестных bytes;
- отсутствующий `IArealMap` не замалчивается;
- non-walkable/non-reachable states дают diagnostic condition;
- одинаковый input log воспроизводит одинаковый sequence Behavior commands;
- resource names из TRF разрешаются через общий registry.
### Проверки Control
- движение без препятствий;
- slope/terrain-following;
- симметричные pair-collision tests с переставленными IDs;
- contact event отправляется один раз в предписанной фазе;
- удаление объекта в collision callback безопасно;
- replay одинакового input log даёт одинаковые transforms;
- collision proxy перестраивается после смены component/model state.
### Проверки input и камеры
- edge event не повторяется как held state;
- mouse/joystick axis сбрасывается по правилам snapshot;
- hot-unplug joystick не оставляет старое отклонение;
- camera horizontal angle wraps, vertical angle clamps;
- center command использует подтверждённое время, а не render FPS;
- Terrain и sound получают одну и ту же camera frame.
### Проверки звука
- backend может отсутствовать без нарушения simulation;
- один decoded sample переиспользуется несколькими sources;
- `entry_missing`, `entry_empty` и `wave_invalid` различаются;
- listener совпадает с camera frame;
- loop source корректно переживает pause/resume;
- mute не сбрасывает position и time;
- missing sound resource содержит полную диагностическую цепочку;
- deterministic test сравнивает список sound events, а не waveform устройства.
### Проверки сети
- нельзя создавать queue с активной сетью и нулевым player ID;
- session/player operations до enable/setup возвращают ошибку;
- сообщения проверяют длину до чтения payload;
- sequence/end markers обрабатываются в стабильном порядке;
- duplicate create mirror не создаёт второй instance;
- ownership change атомарно обновляет routing;
- pause/time messages применяются в одной simulation boundary;
- resource transfer имеет timeout `Network_ResourceTransferMaxDelay`;
- disconnect не оставляет objects с несуществующим owner;
- replay записанного message log даёт одинаковое World3D state.
`resnet.log` и `NetWatch.log` следует поддерживать как отдельные каналы: первый
относится к transport/resource exchange, второй -- к связи players и game
objects.
## Границы знания
Подтверждены внешние interfaces, часть runtime order, значимые строки,
конфигурационные параметры, corpus-level counts и стабильность ряда DLL между
двумя частями. Открытыми остаются:
- instruction grammar `.scr` и semantics всех VM opcodes;
- точная семантика всех TRF-записей;
- полный layout CTLD shape records;
- contact solver и порядок всех correction steps;
- class layout камер, контроллеров, sound service и network watcher;
- DirectPlay wire framing, reliability flags и payload schema;
- алгоритм `netZipData`/`netUnZipData`;
- точные defaults service layer там, где DLL пересобраны.
Эти границы должны оставаться видимыми в документации и тестах. Если новая
реализация вводит удобный современный abstraction layer, он обязан быть
отделён от утверждений о native compatibility и покрыт отдельным trace.
+674
View File
@@ -0,0 +1,674 @@
# VII. Руководство по полной реализации
Этот том описывает инженерный путь к совместимому движку FParkan. Он опирается
на доказанные форматы и runtime-контракты, но не требует повторять физическое
деление оригинала на пятнадцать DLL. Повторить нужно наблюдаемое поведение:
форматы, имена, fallback, object IDs, порядок событий, численную политику,
границы кадра, сохранения и воспроизводимость прохождения.
Предложенные ниже modules, handles, snapshots, queues и scheduler phases являются
целевой архитектурой новой реализации, а не восстановленным внутренним layout
оригинального Iron3D. Главная практическая цель: запускаться из неизменённого
оригинального каталога игры, проходить corpus gates для демоверсии, Части 1 и
Части 2, а затем измеримо двигаться от archive compatibility к полной игровой
совместимости.
## Целевая архитектура
Практичная форма новой реализации -- модульный монолит с узкими интерфейсами и
отдельными platform adapters. Внутренние границы должны соответствовать ролям
Iron3D, а не обязательно его DLL. Это упрощает перенос на современные платформы
и оставляет возможность поддерживать разные compatibility profiles для разных
сборок данных.
```text
application запуск, окно, конфигурация, shutdown
platform filesystem, clocks, input, threads, dynamic libraries
resources NRes, RsLi, paths, archives, cache and diagnostics
assets MSH, WEAR, MAT0, Texm, FXID and auxiliary formats
mission TMA, unit DAT, prototype graph, scenario data
world ObjectId, queue, lifecycle, time, messages, mirrors
terrain Land.msh, Land.map, surface and spatial queries
navigation areals, graph search, corridors
behavior unit state machines, target and path requests
physics control systems, collision proxies and contacts
animation pose sampling, hierarchy and blending
audio sample cache, sources, listener and buses
render legacy-state compatibility and modern backend
network game message schema plus transport adapters
tools validators, extractors, viewers, captures and editors
```
Каждый модуль зависит от нижележащих интерфейсов, а не от concrete managers.
Behavior видит `INavigation` и `IPhysicsCommandSink`, но не включает headers
renderer-а. Render получает immutable snapshot, а не mutable world. Network
receive не меняет мир напрямую: validated messages попадают в очередь следующей
calculation boundary.
### Центральные идентичности
Resource identity хранит и исходное написание, и нормализованный ASCII-key для
поиска:
```c
struct ResourceKey {
NormalizedRelativePath archive;
FixedAsciiName name;
uint32_t type_id;
};
```
Normalization сохраняет исходную строку для diagnostics и roundtrip, а отдельный
ASCII-casefold key используется только для lookup. Эта граница важна для
архивов [NRes](../reference/nres.md), таблиц [RsLi](../reference/rsli.md),
prototype references и fallback-путей материалов.
Object identity разделяет внутреннюю защиту от dangling references и исходную
сетевую/script-семантику:
```c
struct ObjectHandle { uint32_t generation; uint32_t slot; };
struct OriginalObjectId { uint32_t raw; };
```
`ObjectHandle` нужен для безопасного внутреннего владения, deferred deletion и
weak references. `OriginalObjectId` сохраняет наблюдаемую семантику исходной
игры: scripts, mirrors, network messages и savegame references должны видеть
логический ID, а не адрес объекта или номер slot в новом allocator-е.
Frame snapshot отделяет simulation от render. Simulation пишет mutable state;
renderer читает опубликованное состояние или строго ограниченную фазу
`in_render`. Deferred deletion применяется между фазами, а не во время traversal.
Командный контур renderer-а должен сверяться с [описанием кадра](../reference/render-frame.md)
до pixel comparison.
### Владение ресурсами
Ресурс проходит несколько уровней:
```text
ArchiveHandle -> EntryView -> DecodedBlob -> ParsedAsset -> RuntimeResource
```
`EntryView` ссылается на metadata архива, `DecodedBlob` владеет подготовленными
bytes, `ParsedAsset` является CPU-представлением, `RuntimeResource` может
дополнительно владеть GPU/audio objects. Eviction верхнего уровня не закрывает
архив, если он ещё нужен другому entry. Ссылки идут вниз только через явные
handles.
Для shared objects допустимы reference counting или generation handles.
Intrusive refcount нужен только в ABI-shim; внутренний современный код
предпочтительно держит понятное владение и weak handles. Архивы, decoded blobs,
CPU assets и GPU resources имеют отдельные бюджеты и отдельные diagnostics.
### Backend adapters
Render, audio, input и network получают отдельные adapters. Legacy compatibility
state живёт выше Vulkan, D3D11 или Metal backend; DirectPlay compatibility живёт
отдельно от modern transport. Так можно заменить платформу, не меняя форматы,
игровую семантику и regression corpus.
Backend adapter не должен быть местом, где исправляются данные. Если
[MSH](../reference/msh.md), [MAT0](../reference/materials.md) или
[Texm](../reference/texm.md) требуют fallback, это фиксируется в asset/runtime
слое и попадает в trace. Backend получает уже выбранные resources, states и
draw items.
### Scheduler phases
```text
collect_platform_events
build_input_snapshot
advance_game_clock
calculate_world_queue
apply_deferred_operations
update_navigation_physics_animation_fx
publish_render_snapshot
render_world
render_ui
end_frame_callbacks
maintenance_and_eviction
```
Фазы имеют стабильный порядок и запрещённые операции. Registry mutation
запрещена во время world traversal, GPU upload не изменяет simulation state, а
maintenance не влияет на gameplay. Script timers, material animation и FX
lifetime относятся к game time, если обратное не доказано.
Сначала реализуется однопоточный эталон. Параллелизм добавляется только внутри
фаз с детерминированным merge: decoding независимых assets, culling chunks или
подготовка immutable draw items. Это снижает риск скрытых race conditions и
расхождений replay.
### Структурированные ошибки
Каждая ошибка должна содержать фазу, путь, archive entry, object/prototype key,
offset и цепочку причины.
```text
MissionLoadError
mission: Campaign.00/Mission.02
object: 17
resource_name: UNITS/.../unit.dat
component: e_tur_...
prototype: objects.rlb::e_tur_...
cause: model archive missing
```
Логическое отсутствие необязательного lightmap, отсутствующий entry в архиве,
неизвестное opaque поле, выход ссылки за диапазон и повреждённый offset имеют
разный severity и разные способы исправления. Ошибка данных должна быть
actionable chain, а не строка вида `failed to load resource`.
## Порядок работ
Движок строится от данных к поведению и от детерминированных CPU-компонентов к
аппаратным. Каждый этап заканчивается исполняемым инструментом и тестовым
критерием. Нельзя начинать полноценный gameplay, пока ресурсный граф и
model/material path не дают воспроизводимый результат.
### Этап 0. Corpus harness
- индексировать оригинальный каталог и вычислить hashes;
- реализовать bounded binary cursor и structured diagnostics;
- создать CLI для массового запуска parser-ов;
- сохранять JSON-отчёт с counts, variants, warnings и failures;
- зафиксировать демоверсию, Часть 1 и Часть 2 как независимые baselines.
Готовность: повторный запуск на каждом неизменённом каталоге даёт идентичный
отчёт. Любой parser умеет завершиться контролируемой ошибкой с offset и
контекстом, а не crash или allocation по непроверенному count.
### Этап 1. Архивы и пути
- реализовать strict/lossless [NRes](../reference/nres.md) reader/writer;
- реализовать [RsLi](../reference/rsli.md) mapping, table transform, lookup,
LZSS и Deflate;
- добавить адаптивный decoder для методов `0x080` и `0x0A0`;
- воспроизвести overlay и известные compatibility quirks;
- реализовать archive-handle cache и ASCII name policy.
Готовность: неизменённые архивы проходят byte-identical roundtrip; поиск всех
имён совпадает с каталогом; malformed corpus отклоняется без выхода за память.
NRes с ненулевым unindexed region обязательно остаётся regression case.
### Этап 2. Граф ресурсов
- разобрать `objects.rlb` и unit DAT;
- построить resolver прямой MSH, рекурсивного parent prototype через
`objects.rlb` и отдельного BASE payload;
- реализовать dependency graph с reachability от миссии;
- добавить parsers CTPT, NDPR и остальных служебных форматов в lossless-режиме;
- создать инспектор прототипа, показывающий все связанные ресурсы.
Готовность: 201 demo-объект раскрывается в 501 прототип. Затем все миссии
Частей 1 и 2 дают 4 701 и 5 845 prototype requests без failures. Недостижимые
отсутствующие ресурсы отмечаются отдельно от критических ошибок в reachable
graph.
### Этап 3. Статический asset viewer
- реализовать [MSH](../reference/msh.md) core streams, slots и batches;
- декодировать Texm во все подтверждённые pixel formats;
- разобрать WEAR и [MAT0](../reference/materials.md) с точными fallback;
- построить современный renderer compatibility layer;
- добавить wireframe, normals, bounds, LOD/group и material debug views.
Готовность: открываются 435/511 моделей, 518/631 textures и 905/1 127 materials
Частей 1/2; batch/index bounds не нарушаются; viewer показывает корректно
текстурированную статическую модель из исходного архива. Красивый viewer всё ещё
означает только asset compatibility, а не готовую игру.
### Этап 4. Анимация и эффекты
- реализовать MSH type 8/type 19 sampling и hierarchy;
- добавить x87-compatible reference path для чувствительных формул;
- реализовать material phase animation;
- разобрать FXID header/commands и runtime instances;
- сначала поддержать все opcodes, встречающиеся в корпусе, сохраняя raw body;
- добавить deterministic RNG stream и effect capture.
Готовность: frame-by-frame poses совпадают с golden reference своей части; все
923/1 065 FXID создаются без parser errors; перезапуск одинакового effect seed
даёт идентичный список emitted primitives.
### Этап 5. Карта и мир
- реализовать `Land.msh` и corrected `TerrainFace28` layout;
- построить terrain rendering и CPU surface queries;
- реализовать `Land.map`, cell grid и graph links;
- визуализировать areals и найденные маршруты;
- разобрать [TMA](../reference/tma.md) и выполнять staged mission loading;
- создать World3D queue, ObjectId и deferred deletion.
Готовность: 65 карт и 60 TMA Частей 1 и 2 загружаются до EOF; все areal links
валидны; objects появляются в правильных transforms; мир выдерживает расчётные
шаги без рендера.
### Этап 6. Gameplay controllers
- подключить input snapshot и camera controller;
- реализовать navigation corridor, Behavior state machine и Wizard boundary;
- создать physical controller и collision manager;
- загрузить control resources в lossless typed model;
- внедрить game time, pause, event queue и end-of-frame callbacks;
- подключить AI layer и symbol/event layer сценариев.
Готовность: юнит получает цель, строит маршрут, движется по terrain, реагирует
на collision и исполняет базовые миссионные события в детерминированном replay.
На этом этапе вводится differential branch для изменённых `AniMesh`, `Control` и
`Effect`; неизменённые DLL используют общий reference path.
### Этап 7. Полный кадр, звук и UI
- реализовать render phases, sorting, lighting, shadows и atmosphere;
- подключить 3D listener, sample cache, FX sounds и mission audio;
- воспроизвести shell/UI loading и post-world pass;
- добавить frame capture до UI и после UI;
- зафиксировать capability fallback profiles.
Готовность: миссия визуально и звуково проходима; каждый draw и sound event
имеет trace; одинаковый replay создаёт одинаковые command lists. На этом этапе
вводится differential branch для `iron3d` и `services`.
### Этап 8. Сеть, сохранения и динамическая совместимость
- реализовать modern transport над versioned game-message schema;
- отдельно исследовать DirectPlay wire и `netZipData` для native compatibility;
- добавить mirrors, ownership transfer и disconnect cleanup;
- восстановить save/campaign state и dispatcher;
- выполнить динамические captures оригинала для render states, script VM и
physics edge cases.
Готовность: одиночная кампания запускается из оригинального каталога,
сохраняется и продолжается; multiplayer replay согласован между peers; full
corpus не создаёт новых parser variants без явной регистрации.
## Тестовый контур
Совместимость нельзя подтвердить одним screenshot. Нужны тесты на уровне bytes,
структур, ссылок, simulation state, команд renderer-а и конечного изображения.
Каждый слой локализует свой класс ошибки.
```text
unit tests
-> parser/property tests
-> corpus validation
-> cross-resource integration
-> deterministic simulation replay
-> render/audio command captures
-> pixel and gameplay parity
```
Failure верхнего уровня всегда должен позволять спуститься к меньшему тесту и
понять причину.
### Unit, property и fuzz tests
Для каждого binary primitive проверяются little-endian чтение, bounded strings,
checked arithmetic и cursor boundaries. Для структур -- минимальный размер,
максимальные counts, пустые arrays, нулевые варианты и редкие branches.
Property tests генерируют случайные корректные NRes/RsLi/WEAR records,
выполняют encode -> decode и сравнивают семантику. Fuzz tests изменяют длины,
offsets, counts и termination bytes и требуют контролируемой ошибки без crash и
чрезмерного выделения памяти.
Критические алгоритмы имеют отдельные vectors: ASCII casefold, NRes permutation
search, RsLi byte transform, LZSS backreferences, quaternion shortest path,
matrix composition и terrain mask remap.
### Corpus validation
Каждый файл оригинального каталога проходит parser своего семейства. Отчёт
содержит hash, variant, counts, warnings, errors и точный offset сбоя. Baseline
демоверсии:
```text
MSH 435
MAT0 905
Texm 518
FXID 923
WEAR 457
Land.msh 6
Land.map 6
TMA 6
unit DAT 425
errors 0
```
Изменение parser-а принимается только если baseline остаётся стабильной либо
новый variant зарегистрирован с образцом и объяснением. Warnings должны быть
именованными: «неизвестное opaque поле» не равно «выход ссылки за диапазон».
### Cross-resource integration
Интеграционный тест начинается с миссии и проходит весь dependency graph:
object -> prototype -> MSH -> WEAR -> MAT0 -> Texm/lightmap/FXID. Он не
ограничивается тем, что файлы существуют: material slot должен указывать на
допустимый MAT0, phase -- на допустимую texture, model batch -- на существующий
WEAR index.
Demo mission total: 201 objects -> 501 prototypes -> 501 object MSH/WEAR.
Чистый object graph даёт 3 873 material slots и 5 049 texture requests; после
включения environment WEAR итог равен 3 879 material slots, 5 067 textures и
18 lightmaps, failures 0. Такой тест ловит ошибки casefold, suffix, fallback и
путей, которые отдельный parser не замечает.
Для каждого отсутствующего узла отчёт хранит полный parent chain, чтобы
различать broken global archive и реально достижимый mission failure.
### Deterministic simulation replay
Записывается начальная миссия, seed, input events, network messages и значения
внешних часов. На контрольных ticks сохраняется canonical state hash:
```text
sorted ObjectId list
transforms and velocities
critical properties and owners
AI/behavior state IDs
active effect state
game clock and RNG states
```
Pointer addresses, allocator order и GPU handles в hash не входят. Два запуска с
одинаковым log должны давать одинаковый state hash на каждом checkpoint. Первое
расхождение гораздо информативнее финального разного результата миссии.
### Render command parity
До pixel comparison сравнивается command list:
```text
camera matrices and viewport
visible ObjectIds
render phase and stable order
model/node/slot/batch IDs
material phase and texture handles
legacy pipeline states
index ranges and transforms
```
Если command lists совпадают, но pixels различаются, проблема находится в
shader/backend, sampling или численной точности. Если command lists уже
различаются, pixel diff лишь скрывает более раннюю ошибку.
Golden captures следует хранить отдельно для статической модели, анимации,
terrain, transparent FX, shadows, lightmap и atmosphere.
### Pixel, audio и network tests
Pixel tests используют фиксированное разрешение, camera, device profile, seed и
timeline. Сравниваются exact pixels для CPU/reference path и tolerance metrics
для GPU path, но tolerance не должна скрывать переставленные прозрачные
primitives.
Audio tests сравнивают список sound events, sample IDs, positions, loop flags и
gains; waveform зависит от mixer/device и является вторичным уровнем. Network
tests воспроизводят captured message sequences, проверяют mirrors, ownership и
disconnect. Для native DirectPlay compatibility дополнительно нужен packet-level
corpus.
## Regression baselines
Corpus validation формирует три независимых отчёта: демоверсия, Часть 1 и
Часть 2. Каждый сохраняет manifest файлов, hashes executable/DLL, variants,
warnings, global archive health и mission reachability.
Ключевые corpus gates:
```text
NRes: 120 файлов / 6 804 entries и 134 / 8 171 для Частей 1/2
TMA: 29 миссий / 864 objects / 28 extras и 31 / 885 / 41
MSH: 435 и 511 моделей
MAT0: 905 и 1 127 материалов
Texm: 518 и 631 текстура
FXID: 923 и 1 065 эффектов
full reachability: 4 701 и 5 845 prototype requests, failures 0
```
Расширенные mission-reachability totals:
```text
Часть 1: 29 TMA, 864 objects, 4 701 prototypes,
36 954 materials, 48 806 textures, 139 lightmaps, failures 0
Часть 2: 31 TMA, 885 objects, 5 845 prototypes,
50 888 materials, 68 603 textures, 214 lightmaps, failures 0
```
Обязательные regression cases:
- NRes с ненулевым unindexed region;
- prototype inheritance через `objects.rlb`;
- unit DAT `description[32]` без NUL;
- TMA epilogue и `extra_count` 0--4;
- empty SWAV entry;
- stale save-slot metadata без payload;
- build-scoped RVA lookup.
Byte-identical asset comparison выполняется только внутри одного корпуса. Между
Частями 1 и 2 сравниваются semantic invariants и decoded representation,
поскольку многие assets пересобраны.
## Точность, скорость и повторяемость
Совместимый движок должен быть корректным, повторяемым и достаточно быстрым.
Эти свойства нельзя получать одним и тем же приёмом. Сначала создаётся простой
эталонный путь, затем он измеряется и оптимизируется без изменения результата.
Главные источники расхождений: x87 extended precision, преобразование float в
integer, порядок операций, старые SIMD implementations, нестабильная сортировка,
RNG и использование разных часов.
### x87 и округление
Оригинальный x86-код мог хранить промежуточные значения в 80-битных регистрах
x87, а в память записывать 32-битный float. Современный compiler чаще использует
SSE с округлением после каждой операции. Различие заметно на границах animation
frame, culling plane и collision threshold.
Для критических формул нужен reference mode:
- фиксированный порядок операций без reassociation;
- запрещённый fast-math;
- явные преобразования и проверенный режим округления;
- тесты возле half-integer и epsilon boundaries;
- при необходимости extended intermediate через `long double` на проверенной
платформе.
Не требуется эмулировать x87 во всём движке. Нужно локализовать функции, где
малое отличие меняет дискретное решение, и держать для них scalar reference path.
### RNG как часть состояния
FX, atmosphere и, вероятно, AI используют случайные значения. Один глобальный
RNG легко расходится, если новая реализация запрашивает дополнительное число для
визуальной оптимизации. Для трассировки полезны именованные streams:
```text
world/gameplay RNG
AI/script RNG
FX instance RNG
atmosphere RNG
non-deterministic cosmetic RNG
```
Для native parity может потребоваться один общий алгоритм и точная sequence. До
подтверждения capture каждый stream хранит seed и счётчик вызовов в trace.
Cosmetic stream не входит в simulation hash.
### Стабильный порядок
Коллекции не должны зависеть от адресов, unordered containers или порядка
завершения worker threads. Для объектов, collision pairs, opaque/transparent
draws и network messages задаются явные stable keys:
- objects -- queue insertion sequence или OriginalObjectId;
- collision pairs -- упорядоченная пара IDs;
- opaque draws -- phase, pipeline key, material, stable insertion ID;
- transparent draws -- layer, quantized distance, stable insertion ID;
- network messages -- sequence и sender.
Даже когда математический результат коммутативен, side effects, cache accesses и
RNG делают порядок наблюдаемым.
### Часы и fixed-step
Monotonic platform clock хранится отдельно от game clock. Pause и time scaling
применяются к game clock. Simulation работает с фиксированным или точно
воспроизводимым шагом, а render может интерполировать presentation state, не
изменяя authoritative world.
Maintenance timers кэшей используют реальные часы или отдельную подтверждённую
шкалу; их срабатывание не должно менять gameplay. При перегрузке лучше выполнить
ограниченное число simulation steps и явно зафиксировать dropped presentation
frames, чем передать огромный `dt` в AI/physics.
### Оптимизация без потери эталона
1. Сохранить scalar reference implementation.
2. Добавить profiler counters на decoding, culling, sorting, animation, upload
и draw.
3. Оптимизировать только измеренный bottleneck.
4. Сравнить SIMD/parallel результат с reference на полном corpus.
5. Оставить runtime switch для отключения оптимизации при диагностике.
`g_FastProc` удобно моделировать как таблицу function objects: все slots сначала
указывают на scalar path, затем безопасные slots заменяются SIMD-вариантами
после self-test на старте.
### Кэш и память
Архивы, decoded blobs, CPU assets и GPU resources имеют отдельные budgets.
Eviction разрешена только для объектов с нулевым external refcount и после
безопасной frame fence. Original delayed cleanup порядка десятков секунд можно
воспроизвести policy-параметрами, не сканируя все entries каждый кадр.
Основные показатели: число открытых архивов, decoded bytes, resident
textures/lightmaps, models, active FX, draw items и deferred-delete size. Любой
неограниченно растущий счётчик является regression. Производительность считается
достаточной только после корректности: стабильные 60 FPS с неверным LOD или
пропущенными эффектами не являются успехом.
## Release gates
Версия не выпускается, если:
- появился новый corpus error;
- изменился byte roundtrip неизменённых ресурсов;
- dependency graph получил failure в достижимом пути;
- deterministic replay расходится;
- command capture изменился без ожидаемого changelog;
- parser допускает allocation по непроверенному count;
- новая оптимизация не имеет scalar reference comparison.
Каждое исправление регистрирует минимальный regression asset или synthetic
vector. Если новый behavior намеренно отличается от предыдущего, изменение
должно иметь compatibility profile, corpus sample и объяснение, почему старый
baseline был неполным или неверным.
## Уровни совместимости
Слово «совместимый» используется только с уровнем:
1. **Archive-compatible** -- открывает и сохраняет контейнеры.
2. **Asset-compatible** -- декодирует модели, материалы, текстуры и эффекты.
3. **Mission-compatible** -- загружает карту и создаёт все объекты.
4. **Runtime-compatible** -- исполняет время, события, поведение и физику.
5. **Presentation-compatible** -- воспроизводит рендер и звук.
6. **Game-compatible** -- позволяет пройти миссии, сохраняться и продолжать.
7. **Native-interoperable** -- взаимодействует с оригинальной сетью и внешним
ABI.
Viewer с красивой моделью находится только на втором уровне.
### Обязательные критерии запуска и данных
- приложение запускается из неизменённого оригинального каталога;
- относительные пути, регистр и legacy encodings разрешаются по исходным
правилам;
- все требуемые NRes/RsLi открываются без предварительной конвертации;
- parsers проверяют границы и не используют неопределённые bytes как указатели;
- неизвестные поля сохраняются lossless;
- все mission-reachable prototype, model, material, texture, lightmap и effect
references разрешаются;
- отсутствие необязательного ресурса следует документированному fallback, а не
случайному default.
### Обязательные критерии мира
- TMA разбирается до точного EOF;
- `Land.msh` и `Land.map` создают корректную поверхность и areal graph;
- ObjectId, owner и mirror semantics устойчивы;
- queue traversal и deferred deletion безопасны;
- pause, game time и simulation steps повторяемы;
- AI/Behavior/Wizard/Control взаимодействуют через заданные границы;
- collision и navigation не подменяют друг друга;
- script events используют logical IDs и переживают удаление объектов;
- deterministic replay совпадает на контрольных ticks.
### Обязательные критерии presentation
- static и animated MSH используют правильные slots, batches и transforms;
- WEAR/MAT0/Texm fallback и phase timing совпадают;
- mip-skip, palettes, Page atlases и lightmaps работают;
- render phases, depth/cull/blend state и transparent order подтверждены
captures;
- FXID commands и RNG дают устойчивый результат;
- camera и 3D sound listener синхронизированы;
- atmosphere, тени, солнце и flares не являются декоративными заглушками;
- UI и world rendering имеют правильную границу;
- golden command captures стабильны, pixel parity измеряется на фиксированных
сценах.
### Обязательные критерии полной игры
- все доступные миссии стартуют, завершаются и корректно сообщают
success/failure;
- campaign dispatcher сохраняет прогресс;
- savegame восстанавливает world, script, AI, RNG и clocks, а не только
placement;
- input remapping, pause, camera modes, sound и настройки работают из UI;
- длительный прогон не накапливает objects, resources или audio sources;
- ошибки данных показывают actionable chain;
- производительность приемлема без отключения подсистем;
- демоверсия, Часть 1 и Часть 2 проходят один и тот же тестовый контур с
раздельными manifests и эталонами.
### Native interoperability
Самый строгий уровень дополнительно требует совпадения x86 ABI экспортов, vtable
slots и calling conventions для подключаемых оригинальных модулей, а также
DirectPlay wire/framing и compression. Этот уровень независим от возможности
играть в новом standalone runtime.
Проект может честно заявлять game compatibility без native DLL/network
interoperability, но это должно быть явно указано. Аналогично pixel-perfect режим
может быть отдельным compatibility profile поверх функционально корректного
renderer-а.
### Совместимость нескольких наборов данных
Критерий полной совместимости применяется отдельно к демоверсии, Части 1 и
Части 2. Прохождение одного набора не позволяет заявлять поддержку остальных.
Обязательное различие:
- **format compatibility** -- один parser принимает все три набора;
- **content compatibility** -- конкретная миссия разрешает весь reachable graph;
- **behavior compatibility** -- runtime совпадает с соответствующей сборкой
изменённых DLL;
- **cross-version support** -- один новый движок выбирает корректные данные и
defaults по fingerprint установки.
Content fingerprint включает hashes executable/DLL и manifest ключевых архивов.
Он не используется для запрета модификаций, но выбирает compatibility profile и
делает отклонение диагностируемым.
## Definition of done
Полное документирование и реализация считаются завершёнными только когда каждый
критерий связан с главой спецификации, executable test и хотя бы одним
corpus/golden case. Утверждение без проверяемого критерия остаётся
исследовательской заметкой, а не контрактом.
File diff suppressed because it is too large Load Diff
+57 -21
View File
@@ -3,7 +3,7 @@ site_name: FParkan
site_url: https://fparkan.popov.link/ site_url: https://fparkan.popov.link/
site_author: Valentin Popov site_author: Valentin Popov
site_description: >- site_description: >-
Utilities and tools for the game “Parkan: Iron Strategy. Техническая книга о восстановлении игрового движка Iron3D из Parkan: Iron Strategy.
# Repository # Repository
repo_name: valentineus/fparkan repo_name: valentineus/fparkan
@@ -16,30 +16,66 @@ copyright: Copyright &copy; 2023 &mdash; 2026 Valentin Popov
theme: theme:
name: material name: material
language: ru language: ru
features:
- navigation.instant
- navigation.sections
- navigation.indexes
- navigation.top
- toc.follow
- search.highlight
- search.suggest
palette: palette:
scheme: slate - media: "(prefers-color-scheme: light)"
scheme: default
primary: indigo
accent: deep orange
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: indigo
accent: deep orange
markdown_extensions:
- admonition
- attr_list
- def_list
- md_in_html
- toc:
permalink: true
- pymdownx.details
- pymdownx.highlight
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
plugins:
- search:
lang:
- ru
- en
# Navigation # Navigation
nav: nav:
- Home: index.md - Начало: index.md
- Specs: - Книга:
- 3D implementation notes: specs/msh-notes.md - I. Путеводитель и методика: tomes/01-guide.md
- AI system: specs/ai.md - II. Запуск, архитектура и игровой цикл: tomes/02-architecture.md
- ArealMap: specs/arealmap.md - III. Ресурсная система и форматы: tomes/03-resources.md
- Behavior system: specs/behavior.md - IV. Мир, миссии и игровой runtime: tomes/04-world.md
- Control system: specs/control.md - V. Геометрия, материалы и рендер: tomes/05-render.md
- FXID: specs/fxid.md - VI. Поведение, управление, звук и сеть: tomes/06-behavior.md
- Materials + Texm: specs/materials-texm.md - VII. Руководство по полной реализации: tomes/07-implementation.md
- Missions: specs/missions.md - VIII. Справочник и доказательная база: tomes/08-evidence.md
- MSH animation: specs/msh-animation.md - Справочник:
- MSH core: specs/msh-core.md - NRes: reference/nres.md
- Network system: specs/network.md - RsLi: reference/rsli.md
- NRes / RsLi: specs/nres.md - TMA: reference/tma.md
- Runtime pipeline: specs/runtime-pipeline.md - MSH: reference/msh.md
- Sound system: specs/sound.md - WEAR и MAT0: reference/materials.md
- Terrain + map loading: specs/terrain-map-loading.md - Texm: reference/texm.md
- UI system: specs/ui.md - Render frame: reference/render-frame.md
- Форматы 3D‑ресурсов (обзор): specs/msh.md - Приложения:
- Глоссарий: appendices/glossary.md
- Границы знания: appendices/knowledge-boundaries.md
# Additional configuration # Additional configuration
extra: extra:
+20
View File
@@ -0,0 +1,20 @@
# Render Parity Dataset
This folder stores parity-test input for `crates/render-parity`.
- `cases.toml`: list of deterministic render cases.
- `reference/*.png`: baseline frames captured from the original renderer.
Expected workflow:
1. Capture baseline PNG frames from original game/editor for each case.
2. Add entries to `cases.toml`.
3. Run:
```bash
cargo run -p render-parity -- \
--manifest parity/cases.toml \
--output-dir target/render-parity/current
```
On failure, diff images are saved to `target/render-parity/current/diff`.
+27
View File
@@ -0,0 +1,27 @@
[meta]
# Global defaults for all cases.
width = 1280
height = 720
lod = 0
group = 0
angle = 0.0
# Per-pixel change threshold for the "changed pixel ratio" metric.
diff_threshold = 8
# Allowed thresholds (case fails if any limit is exceeded).
max_mean_abs = 2.0
max_changed_ratio = 0.010
# Add one block per model.
#
# [[case]]
# id = "animals_a_l_01"
# archive = "../testdata/Parkan - Iron Strategy/animals.rlb"
# model = "A_L_01.msh"
# reference = "reference/animals_a_l_01.png"
# lod = 0
# group = 0
# angle = 0.0
# max_mean_abs = 2.0
# max_changed_ratio = 0.010
View File
+5
View File
@@ -0,0 +1,5 @@
# Тестовые данные
Для тестирования на реальных ресурсах разместите в этом каталоге игровые каталоги.
Игровые файлы не включаются в репозиторий.
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
-201
View File
@@ -1,201 +0,0 @@
# Инструменты в каталоге `tools`
## `archive_roundtrip_validator.py`
Скрипт предназначен для **валидации документации по форматам NRes и RsLi на реальных данных игры**.
Что делает утилита:
- находит архивы по сигнатуре заголовка (а не по расширению файла);
- распаковывает архивы в структуру `manifest.json + entries/*`;
- собирает архивы обратно из `manifest.json`;
- выполняет проверку `unpack -> repack -> byte-compare`;
- формирует отчёт о расхождениях со спецификацией.
Скрипт не изменяет оригинальные файлы игры. Рабочие файлы создаются только в указанном `--workdir` (или во временной папке).
## Поддерживаемые сигнатуры
- `NRes` (`4E 52 65 73`)
- `RsLi` в файловом формате библиотеки: `NL 00 01`
## Основные команды
Сканирование архива по сигнатурам:
```bash
python3 tools/archive_roundtrip_validator.py scan --input tmp/gamedata
```
Распаковка/упаковка одного NRes:
```bash
python3 tools/archive_roundtrip_validator.py nres-unpack \
--archive tmp/gamedata/sounds.lib \
--output tmp/work/nres_sounds
python3 tools/archive_roundtrip_validator.py nres-pack \
--manifest tmp/work/nres_sounds/manifest.json \
--output tmp/work/sounds.repacked.lib
```
Распаковка/упаковка одного RsLi:
```bash
python3 tools/archive_roundtrip_validator.py rsli-unpack \
--archive tmp/gamedata/sprites.lib \
--output tmp/work/rsli_sprites
python3 tools/archive_roundtrip_validator.py rsli-pack \
--manifest tmp/work/rsli_sprites/manifest.json \
--output tmp/work/sprites.repacked.lib
```
Полная валидация документации на всём наборе данных:
```bash
python3 tools/archive_roundtrip_validator.py validate \
--input tmp/gamedata \
--workdir tmp/validation_work \
--report tmp/validation_report.json \
--fail-on-diff
```
## Формат распаковки
Для каждого архива создаются:
- `manifest.json` — все поля заголовка, записи, индексы, смещения, контрольные суммы;
- `entries/*.bin` — payload-файлы.
Имена файлов в `entries` включают индекс записи, поэтому коллизии одинаковых имён внутри архива обрабатываются корректно.
## `init_testdata.py`
Скрипт инициализирует тестовые данные по сигнатурам архивов из спецификации:
- `NRes` (`4E 52 65 73`);
- `RsLi` (`NL 00 01`).
Что делает утилита:
- рекурсивно сканирует все файлы в `--input`;
- копирует найденные `NRes` в `--output/nres/`;
- копирует найденные `RsLi` в `--output/rsli/`;
- сохраняет относительный путь исходного файла внутри целевого каталога;
- создаёт целевые каталоги автоматически, если их нет.
Базовый запуск:
```bash
python3 tools/init_testdata.py --input tmp/gamedata --output testdata
```
Если целевой файл уже существует, скрипт спрашивает подтверждение перезаписи (`yes/no/all/quit`).
Для перезаписи без вопросов используйте `--force`:
```bash
python3 tools/init_testdata.py --input tmp/gamedata --output testdata --force
```
Проверки надёжности:
- `--input` должен существовать и быть каталогом;
- если `--output` указывает на существующий файл, скрипт завершится с ошибкой;
- если `--output` расположен внутри `--input`, каталог вывода исключается из сканирования;
- если `stdin` неинтерактивный и требуется перезапись, нужно явно указать `--force`.
## `msh_doc_validator.py`
Скрипт валидирует ключевые инварианты из документации `/Users/valentineus/Developer/personal/fparkan/docs/specs/msh.md` на реальных данных.
Проверяемые группы:
- модели `*.msh` (вложенные `NRes` в архивах `NRes`);
- текстуры `Texm` (`type_id = 0x6D786554`);
- эффекты `FXID` (`type_id = 0x44495846`).
Что проверяет для моделей:
- обязательные ресурсы (`Res1/2/3/6/13`) и известные опциональные (`Res4/5/7/8/10/15/16/18/19`);
- `size/attr1/attr3` и шаги структур по таблицам;
- диапазоны индексов, батчей и ссылок между таблицами;
- разбор `Res10` как `len + bytes + NUL` для каждого узла;
- матрицу слотов в `Res1` (LOD/group) и границы по `Res2/Res7/Res13/Res19`.
Быстрый запуск:
```bash
python3 tools/msh_doc_validator.py scan --input testdata/nres
python3 tools/msh_doc_validator.py validate --input testdata/nres --print-limit 20
```
С отчётом в JSON:
```bash
python3 tools/msh_doc_validator.py validate \
--input testdata/nres \
--report tmp/msh_validation_report.json \
--fail-on-warnings
```
## `msh_preview_renderer.py`
Примитивный программный рендерер моделей `*.msh` без внешних зависимостей.
- вход: архив `NRes` (например `animals.rlb`) или прямой payload модели;
- выход: изображение `PPM` (`P6`);
- использует `Res3` (позиции), `Res6` (индексы), `Res13` (батчи), `Res1/Res2` (выбор слотов по `lod/group`).
Показать доступные модели в архиве:
```bash
python3 tools/msh_preview_renderer.py list-models --archive testdata/nres/animals.rlb
```
Сгенерировать тестовый рендер:
```bash
python3 tools/msh_preview_renderer.py render \
--archive testdata/nres/animals.rlb \
--model A_L_01.msh \
--output tmp/renders/A_L_01.ppm \
--width 800 \
--height 600 \
--lod 0 \
--group 0 \
--wireframe
```
Ограничения:
- инструмент предназначен для smoke-теста геометрии, а не для пиксельно-точного рендера движка;
- текстуры/материалы/эффектные проходы не эмулируются.
## `msh_export_obj.py`
Экспортирует геометрию `*.msh` в `Wavefront OBJ`, чтобы открыть модель в Blender/MeshLab.
- вход: `NRes` архив (например `animals.rlb`) или прямой payload модели;
- выбор геометрии: через `Res1` slot matrix (`lod/group`) как в рендерере;
- опция `--all-batches` экспортирует все батчи, игнорируя slot matrix.
Показать модели в архиве:
```bash
python3 tools/msh_export_obj.py list-models --archive testdata/nres/animals.rlb
```
Экспорт в OBJ:
```bash
python3 tools/msh_export_obj.py export \
--archive testdata/nres/animals.rlb \
--model A_L_01.msh \
--output tmp/renders/A_L_01.obj \
--lod 0 \
--group 0
```
Файл `OBJ` можно открыть напрямую в Blender (`File -> Import -> Wavefront (.obj)`).
-944
View File
@@ -1,944 +0,0 @@
#!/usr/bin/env python3
"""
Roundtrip tools for NRes and RsLi archives.
The script can:
1) scan archives by header signature (ignores file extensions),
2) unpack / pack NRes archives,
3) unpack / pack RsLi archives,
4) validate docs assumptions by full roundtrip and byte-to-byte comparison.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import shutil
import struct
import tempfile
import zlib
from pathlib import Path
from typing import Any
MAGIC_NRES = b"NRes"
MAGIC_RSLI = b"NL\x00\x01"
class ArchiveFormatError(RuntimeError):
pass
def sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def safe_component(value: str, fallback: str = "item", max_len: int = 80) -> str:
clean = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._-")
if not clean:
clean = fallback
return clean[:max_len]
def first_diff(a: bytes, b: bytes) -> tuple[int | None, str | None]:
if a == b:
return None, None
limit = min(len(a), len(b))
for idx in range(limit):
if a[idx] != b[idx]:
return idx, f"{a[idx]:02x}!={b[idx]:02x}"
return limit, f"len {len(a)}!={len(b)}"
def load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def dump_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, ensure_ascii=False)
handle.write("\n")
def xor_stream(data: bytes, key16: int) -> bytes:
lo = key16 & 0xFF
hi = (key16 >> 8) & 0xFF
out = bytearray(len(data))
for i, value in enumerate(data):
lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
out[i] = value ^ lo
hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
return bytes(out)
def lzss_decompress_simple(data: bytes, expected_size: int) -> bytes:
ring = bytearray([0x20] * 0x1000)
ring_pos = 0xFEE
out = bytearray()
in_pos = 0
control = 0
bits_left = 0
while len(out) < expected_size and in_pos < len(data):
if bits_left == 0:
control = data[in_pos]
in_pos += 1
bits_left = 8
if control & 1:
if in_pos >= len(data):
break
byte = data[in_pos]
in_pos += 1
out.append(byte)
ring[ring_pos] = byte
ring_pos = (ring_pos + 1) & 0x0FFF
else:
if in_pos + 1 >= len(data):
break
low = data[in_pos]
high = data[in_pos + 1]
in_pos += 2
# Real files indicate nibble layout opposite to common LZSS variant:
# high nibble extends offset, low nibble stores (length - 3).
offset = low | ((high & 0xF0) << 4)
length = (high & 0x0F) + 3
for step in range(length):
byte = ring[(offset + step) & 0x0FFF]
out.append(byte)
ring[ring_pos] = byte
ring_pos = (ring_pos + 1) & 0x0FFF
if len(out) >= expected_size:
break
control >>= 1
bits_left -= 1
if len(out) != expected_size:
raise ArchiveFormatError(
f"LZSS size mismatch: expected {expected_size}, got {len(out)}"
)
return bytes(out)
def decode_rsli_payload(
packed: bytes, method: int, sort_to_original: int, unpacked_size: int
) -> bytes:
key16 = sort_to_original & 0xFFFF
if method == 0x000:
out = packed
elif method == 0x020:
if len(packed) < unpacked_size:
raise ArchiveFormatError(
f"method 0x20 packed too short: {len(packed)} < {unpacked_size}"
)
out = xor_stream(packed[:unpacked_size], key16)
elif method == 0x040:
out = lzss_decompress_simple(packed, unpacked_size)
elif method == 0x060:
out = lzss_decompress_simple(xor_stream(packed, key16), unpacked_size)
elif method == 0x100:
try:
out = zlib.decompress(packed, -15)
except zlib.error:
out = zlib.decompress(packed)
else:
raise ArchiveFormatError(f"unsupported RsLi method: 0x{method:03X}")
if len(out) != unpacked_size:
raise ArchiveFormatError(
f"unpacked_size mismatch: expected {unpacked_size}, got {len(out)}"
)
return out
def detect_archive_type(path: Path) -> str | None:
try:
with path.open("rb") as handle:
magic = handle.read(4)
except OSError:
return None
if magic == MAGIC_NRES:
return "nres"
if magic == MAGIC_RSLI:
return "rsli"
return None
def scan_archives(root: Path) -> list[dict[str, Any]]:
found: list[dict[str, Any]] = []
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
archive_type = detect_archive_type(path)
if not archive_type:
continue
found.append(
{
"path": str(path),
"relative_path": str(path.relative_to(root)),
"type": archive_type,
"size": path.stat().st_size,
}
)
return found
def parse_nres(data: bytes, source: str = "<memory>") -> dict[str, Any]:
if len(data) < 16:
raise ArchiveFormatError(f"{source}: NRes too short ({len(data)} bytes)")
magic, version, entry_count, total_size = struct.unpack_from("<4sIII", data, 0)
if magic != MAGIC_NRES:
raise ArchiveFormatError(f"{source}: invalid NRes magic")
issues: list[str] = []
if total_size != len(data):
issues.append(
f"header.total_size={total_size} != actual_size={len(data)} (spec 1.2)"
)
if version != 0x100:
issues.append(f"version=0x{version:08X} != 0x00000100 (spec 1.2)")
directory_offset = total_size - entry_count * 64
if directory_offset < 16 or directory_offset > len(data):
raise ArchiveFormatError(
f"{source}: invalid directory offset {directory_offset} for entry_count={entry_count}"
)
if directory_offset + entry_count * 64 != len(data):
issues.append(
"directory_offset + entry_count*64 != file_size (spec 1.3)"
)
entries: list[dict[str, Any]] = []
for index in range(entry_count):
offset = directory_offset + index * 64
if offset + 64 > len(data):
raise ArchiveFormatError(f"{source}: truncated directory entry {index}")
(
type_id,
attr1,
attr2,
size,
attr3,
name_raw,
data_offset,
sort_index,
) = struct.unpack_from("<IIIII36sII", data, offset)
name_bytes = name_raw.split(b"\x00", 1)[0]
name = name_bytes.decode("latin1", errors="replace")
entries.append(
{
"index": index,
"type_id": type_id,
"attr1": attr1,
"attr2": attr2,
"size": size,
"attr3": attr3,
"name": name,
"name_bytes_hex": name_bytes.hex(),
"name_raw_hex": name_raw.hex(),
"data_offset": data_offset,
"sort_index": sort_index,
}
)
# Spec checks.
expected_sort = sorted(
range(entry_count),
key=lambda idx: bytes.fromhex(entries[idx]["name_bytes_hex"]).lower(),
)
current_sort = [item["sort_index"] for item in entries]
if current_sort != expected_sort:
issues.append(
"sort_index table does not match case-insensitive name order (spec 1.4)"
)
data_regions = sorted(
(
item["index"],
item["data_offset"],
item["size"],
)
for item in entries
)
for idx, data_offset, size in data_regions:
if data_offset % 8 != 0:
issues.append(f"entry {idx}: data_offset={data_offset} not aligned to 8 (spec 1.5)")
if data_offset < 16 or data_offset + size > directory_offset:
issues.append(
f"entry {idx}: data range [{data_offset}, {data_offset + size}) out of data area (spec 1.3)"
)
for i in range(len(data_regions) - 1):
_, start, size = data_regions[i]
_, next_start, _ = data_regions[i + 1]
if start + size > next_start:
issues.append(
f"entry overlap at data_offset={start}, next={next_start}"
)
padding = data[start + size : next_start]
if any(padding):
issues.append(
f"non-zero padding after data block at offset={start + size} (spec 1.5)"
)
return {
"format": "NRes",
"header": {
"magic": "NRes",
"version": version,
"entry_count": entry_count,
"total_size": total_size,
"directory_offset": directory_offset,
},
"entries": entries,
"issues": issues,
}
def build_nres_name_field(entry: dict[str, Any]) -> bytes:
if "name_bytes_hex" in entry:
raw = bytes.fromhex(entry["name_bytes_hex"])
else:
raw = entry.get("name", "").encode("latin1", errors="replace")
raw = raw[:35]
return raw + b"\x00" * (36 - len(raw))
def unpack_nres_file(archive_path: Path, out_dir: Path, source_root: Path | None = None) -> dict[str, Any]:
data = archive_path.read_bytes()
parsed = parse_nres(data, source=str(archive_path))
out_dir.mkdir(parents=True, exist_ok=True)
entries_dir = out_dir / "entries"
entries_dir.mkdir(parents=True, exist_ok=True)
manifest: dict[str, Any] = {
"format": "NRes",
"source_path": str(archive_path),
"source_relative_path": str(archive_path.relative_to(source_root)) if source_root else str(archive_path),
"header": parsed["header"],
"entries": [],
"issues": parsed["issues"],
"source_sha256": sha256_hex(data),
}
for entry in parsed["entries"]:
begin = entry["data_offset"]
end = begin + entry["size"]
if begin < 0 or end > len(data):
raise ArchiveFormatError(
f"{archive_path}: entry {entry['index']} data range outside file"
)
payload = data[begin:end]
base = safe_component(entry["name"], fallback=f"entry_{entry['index']:05d}")
file_name = (
f"{entry['index']:05d}__{base}"
f"__t{entry['type_id']:08X}_a1{entry['attr1']:08X}_a2{entry['attr2']:08X}.bin"
)
(entries_dir / file_name).write_bytes(payload)
manifest_entry = dict(entry)
manifest_entry["data_file"] = f"entries/{file_name}"
manifest_entry["sha256"] = sha256_hex(payload)
manifest["entries"].append(manifest_entry)
dump_json(out_dir / "manifest.json", manifest)
return manifest
def pack_nres_manifest(manifest_path: Path, out_file: Path) -> bytes:
manifest = load_json(manifest_path)
if manifest.get("format") != "NRes":
raise ArchiveFormatError(f"{manifest_path}: not an NRes manifest")
entries = manifest["entries"]
count = len(entries)
version = int(manifest.get("header", {}).get("version", 0x100))
out = bytearray(b"\x00" * 16)
data_offsets: list[int] = []
data_sizes: list[int] = []
for entry in entries:
payload_path = manifest_path.parent / entry["data_file"]
payload = payload_path.read_bytes()
offset = len(out)
out.extend(payload)
padding = (-len(out)) % 8
if padding:
out.extend(b"\x00" * padding)
data_offsets.append(offset)
data_sizes.append(len(payload))
directory_offset = len(out)
expected_sort = sorted(
range(count),
key=lambda idx: bytes.fromhex(entries[idx].get("name_bytes_hex", "")).lower(),
)
for index, entry in enumerate(entries):
name_field = build_nres_name_field(entry)
out.extend(
struct.pack(
"<IIIII36sII",
int(entry["type_id"]),
int(entry["attr1"]),
int(entry["attr2"]),
data_sizes[index],
int(entry["attr3"]),
name_field,
data_offsets[index],
expected_sort[index],
)
)
total_size = len(out)
struct.pack_into("<4sIII", out, 0, MAGIC_NRES, version, count, total_size)
out_file.parent.mkdir(parents=True, exist_ok=True)
out_file.write_bytes(out)
return bytes(out)
def parse_rsli(data: bytes, source: str = "<memory>") -> dict[str, Any]:
if len(data) < 32:
raise ArchiveFormatError(f"{source}: RsLi too short ({len(data)} bytes)")
if data[:4] != MAGIC_RSLI:
raise ArchiveFormatError(f"{source}: invalid RsLi magic")
issues: list[str] = []
reserved_zero = data[2]
version = data[3]
entry_count = struct.unpack_from("<h", data, 4)[0]
presorted_flag = struct.unpack_from("<H", data, 14)[0]
seed = struct.unpack_from("<I", data, 20)[0]
if reserved_zero != 0:
issues.append(f"header[2]={reserved_zero} != 0 (spec 2.2)")
if version != 1:
issues.append(f"version={version} != 1 (spec 2.2)")
if entry_count < 0:
raise ArchiveFormatError(f"{source}: negative entry_count={entry_count}")
table_offset = 32
table_size = entry_count * 32
if table_offset + table_size > len(data):
raise ArchiveFormatError(
f"{source}: encrypted table out of file bounds ({table_offset}+{table_size}>{len(data)})"
)
table_encrypted = data[table_offset : table_offset + table_size]
table_plain = xor_stream(table_encrypted, seed & 0xFFFF)
trailer: dict[str, Any] = {"present": False}
overlay_offset = 0
if len(data) >= 6 and data[-6:-4] == b"AO":
overlay_offset = struct.unpack_from("<I", data, len(data) - 4)[0]
trailer = {
"present": True,
"signature": "AO",
"overlay_offset": overlay_offset,
"raw_hex": data[-6:].hex(),
}
entries: list[dict[str, Any]] = []
sort_values: list[int] = []
for index in range(entry_count):
row = table_plain[index * 32 : (index + 1) * 32]
name_raw = row[0:12]
reserved4 = row[12:16]
flags_signed, sort_to_original = struct.unpack_from("<hh", row, 16)
unpacked_size, data_offset, packed_size = struct.unpack_from("<III", row, 20)
method = flags_signed & 0x1E0
name = name_raw.split(b"\x00", 1)[0].decode("latin1", errors="replace")
effective_offset = data_offset + overlay_offset
entries.append(
{
"index": index,
"name": name,
"name_raw_hex": name_raw.hex(),
"reserved_raw_hex": reserved4.hex(),
"flags_signed": flags_signed,
"flags_u16": flags_signed & 0xFFFF,
"method": method,
"sort_to_original": sort_to_original,
"unpacked_size": unpacked_size,
"data_offset": data_offset,
"effective_data_offset": effective_offset,
"packed_size": packed_size,
}
)
sort_values.append(sort_to_original)
if effective_offset < 0:
issues.append(f"entry {index}: negative effective_data_offset={effective_offset}")
elif effective_offset + packed_size > len(data):
end = effective_offset + packed_size
if method == 0x100 and end == len(data) + 1:
issues.append(
f"entry {index}: deflate packed_size reaches EOF+1 ({end}); "
"observed in game data, likely decoder lookahead byte"
)
else:
issues.append(
f"entry {index}: packed range [{effective_offset}, {end}) out of file"
)
if presorted_flag == 0xABBA:
if sorted(sort_values) != list(range(entry_count)):
issues.append(
"presorted flag is 0xABBA but sort_to_original is not a permutation [0..N-1] (spec 2.2/2.4)"
)
return {
"format": "RsLi",
"header_raw_hex": data[:32].hex(),
"header": {
"magic": "NL\\x00\\x01",
"entry_count": entry_count,
"seed": seed,
"presorted_flag": presorted_flag,
},
"entries": entries,
"issues": issues,
"trailer": trailer,
}
def unpack_rsli_file(archive_path: Path, out_dir: Path, source_root: Path | None = None) -> dict[str, Any]:
data = archive_path.read_bytes()
parsed = parse_rsli(data, source=str(archive_path))
out_dir.mkdir(parents=True, exist_ok=True)
entries_dir = out_dir / "entries"
entries_dir.mkdir(parents=True, exist_ok=True)
manifest: dict[str, Any] = {
"format": "RsLi",
"source_path": str(archive_path),
"source_relative_path": str(archive_path.relative_to(source_root)) if source_root else str(archive_path),
"source_size": len(data),
"header_raw_hex": parsed["header_raw_hex"],
"header": parsed["header"],
"entries": [],
"issues": list(parsed["issues"]),
"trailer": parsed["trailer"],
"source_sha256": sha256_hex(data),
}
for entry in parsed["entries"]:
begin = int(entry["effective_data_offset"])
end = begin + int(entry["packed_size"])
packed = data[begin:end]
base = safe_component(entry["name"], fallback=f"entry_{entry['index']:05d}")
packed_name = f"{entry['index']:05d}__{base}__packed.bin"
(entries_dir / packed_name).write_bytes(packed)
manifest_entry = dict(entry)
manifest_entry["packed_file"] = f"entries/{packed_name}"
manifest_entry["packed_file_size"] = len(packed)
manifest_entry["packed_sha256"] = sha256_hex(packed)
try:
unpacked = decode_rsli_payload(
packed=packed,
method=int(entry["method"]),
sort_to_original=int(entry["sort_to_original"]),
unpacked_size=int(entry["unpacked_size"]),
)
unpacked_name = f"{entry['index']:05d}__{base}__unpacked.bin"
(entries_dir / unpacked_name).write_bytes(unpacked)
manifest_entry["unpacked_file"] = f"entries/{unpacked_name}"
manifest_entry["unpacked_sha256"] = sha256_hex(unpacked)
except ArchiveFormatError as exc:
manifest_entry["unpack_error"] = str(exc)
manifest["issues"].append(
f"entry {entry['index']}: cannot decode method 0x{entry['method']:03X}: {exc}"
)
manifest["entries"].append(manifest_entry)
dump_json(out_dir / "manifest.json", manifest)
return manifest
def _pack_i16(value: int) -> int:
if not (-32768 <= int(value) <= 32767):
raise ArchiveFormatError(f"int16 overflow: {value}")
return int(value)
def pack_rsli_manifest(manifest_path: Path, out_file: Path) -> bytes:
manifest = load_json(manifest_path)
if manifest.get("format") != "RsLi":
raise ArchiveFormatError(f"{manifest_path}: not an RsLi manifest")
entries = manifest["entries"]
count = len(entries)
header_raw = bytes.fromhex(manifest["header_raw_hex"])
if len(header_raw) != 32:
raise ArchiveFormatError(f"{manifest_path}: header_raw_hex must be 32 bytes")
header = bytearray(header_raw)
header[:4] = MAGIC_RSLI
struct.pack_into("<h", header, 4, count)
seed = int(manifest["header"]["seed"])
struct.pack_into("<I", header, 20, seed)
rows = bytearray()
packed_chunks: list[tuple[dict[str, Any], bytes]] = []
for entry in entries:
packed_path = manifest_path.parent / entry["packed_file"]
packed = packed_path.read_bytes()
declared_size = int(entry["packed_size"])
if len(packed) > declared_size:
raise ArchiveFormatError(
f"{packed_path}: packed size {len(packed)} > manifest packed_size {declared_size}"
)
data_offset = int(entry["data_offset"])
packed_chunks.append((entry, packed))
row = bytearray(32)
name_raw = bytes.fromhex(entry["name_raw_hex"])
reserved_raw = bytes.fromhex(entry["reserved_raw_hex"])
if len(name_raw) != 12 or len(reserved_raw) != 4:
raise ArchiveFormatError(
f"entry {entry['index']}: invalid name/reserved raw length"
)
row[0:12] = name_raw
row[12:16] = reserved_raw
struct.pack_into(
"<hhIII",
row,
16,
_pack_i16(int(entry["flags_signed"])),
_pack_i16(int(entry["sort_to_original"])),
int(entry["unpacked_size"]),
data_offset,
declared_size,
)
rows.extend(row)
encrypted_table = xor_stream(bytes(rows), seed & 0xFFFF)
trailer = manifest.get("trailer", {})
trailer_raw = b""
if trailer.get("present"):
raw_hex = trailer.get("raw_hex", "")
trailer_raw = bytes.fromhex(raw_hex)
if len(trailer_raw) != 6:
raise ArchiveFormatError("trailer raw length must be 6 bytes")
source_size = manifest.get("source_size")
table_end = 32 + count * 32
if source_size is not None:
pre_trailer_size = int(source_size) - len(trailer_raw)
if pre_trailer_size < table_end:
raise ArchiveFormatError(
f"invalid source_size={source_size}: smaller than header+table"
)
else:
pre_trailer_size = table_end
for entry, packed in packed_chunks:
pre_trailer_size = max(
pre_trailer_size, int(entry["data_offset"]) + len(packed)
)
out = bytearray(pre_trailer_size)
out[0:32] = header
out[32:table_end] = encrypted_table
occupied = bytearray(pre_trailer_size)
occupied[0:table_end] = b"\x01" * table_end
for entry, packed in packed_chunks:
base_offset = int(entry["data_offset"])
for index, byte in enumerate(packed):
pos = base_offset + index
if pos >= pre_trailer_size:
raise ArchiveFormatError(
f"entry {entry['index']}: data write at {pos} beyond output size {pre_trailer_size}"
)
if occupied[pos] and out[pos] != byte:
raise ArchiveFormatError(
f"entry {entry['index']}: overlapping packed data conflict at offset {pos}"
)
out[pos] = byte
occupied[pos] = 1
out.extend(trailer_raw)
if source_size is not None and len(out) != int(source_size):
raise ArchiveFormatError(
f"packed size {len(out)} != source_size {source_size} from manifest"
)
out_file.parent.mkdir(parents=True, exist_ok=True)
out_file.write_bytes(out)
return bytes(out)
def cmd_scan(args: argparse.Namespace) -> int:
root = Path(args.input).resolve()
archives = scan_archives(root)
if args.json:
print(json.dumps(archives, ensure_ascii=False, indent=2))
else:
print(f"Found {len(archives)} archive(s) in {root}")
for item in archives:
print(f"{item['type']:4} {item['size']:10d} {item['relative_path']}")
return 0
def cmd_nres_unpack(args: argparse.Namespace) -> int:
archive_path = Path(args.archive).resolve()
out_dir = Path(args.output).resolve()
manifest = unpack_nres_file(archive_path, out_dir)
print(f"NRes unpacked: {archive_path}")
print(f"Manifest: {out_dir / 'manifest.json'}")
print(f"Entries : {len(manifest['entries'])}")
if manifest["issues"]:
print("Issues:")
for issue in manifest["issues"]:
print(f"- {issue}")
return 0
def cmd_nres_pack(args: argparse.Namespace) -> int:
manifest_path = Path(args.manifest).resolve()
out_file = Path(args.output).resolve()
packed = pack_nres_manifest(manifest_path, out_file)
print(f"NRes packed: {out_file} ({len(packed)} bytes, sha256={sha256_hex(packed)})")
return 0
def cmd_rsli_unpack(args: argparse.Namespace) -> int:
archive_path = Path(args.archive).resolve()
out_dir = Path(args.output).resolve()
manifest = unpack_rsli_file(archive_path, out_dir)
print(f"RsLi unpacked: {archive_path}")
print(f"Manifest: {out_dir / 'manifest.json'}")
print(f"Entries : {len(manifest['entries'])}")
if manifest["issues"]:
print("Issues:")
for issue in manifest["issues"]:
print(f"- {issue}")
return 0
def cmd_rsli_pack(args: argparse.Namespace) -> int:
manifest_path = Path(args.manifest).resolve()
out_file = Path(args.output).resolve()
packed = pack_rsli_manifest(manifest_path, out_file)
print(f"RsLi packed: {out_file} ({len(packed)} bytes, sha256={sha256_hex(packed)})")
return 0
def cmd_validate(args: argparse.Namespace) -> int:
input_root = Path(args.input).resolve()
archives = scan_archives(input_root)
temp_created = False
if args.workdir:
workdir = Path(args.workdir).resolve()
workdir.mkdir(parents=True, exist_ok=True)
else:
workdir = Path(tempfile.mkdtemp(prefix="nres-rsli-validate-"))
temp_created = True
report: dict[str, Any] = {
"input_root": str(input_root),
"workdir": str(workdir),
"archives_total": len(archives),
"results": [],
"summary": {},
}
failures = 0
try:
for idx, item in enumerate(archives):
rel = item["relative_path"]
archive_path = input_root / rel
marker = f"{idx:04d}_{safe_component(rel, fallback='archive')}"
unpack_dir = workdir / "unpacked" / marker
repacked_file = workdir / "repacked" / f"{marker}.bin"
try:
if item["type"] == "nres":
manifest = unpack_nres_file(archive_path, unpack_dir, source_root=input_root)
repacked = pack_nres_manifest(unpack_dir / "manifest.json", repacked_file)
elif item["type"] == "rsli":
manifest = unpack_rsli_file(archive_path, unpack_dir, source_root=input_root)
repacked = pack_rsli_manifest(unpack_dir / "manifest.json", repacked_file)
else:
continue
original = archive_path.read_bytes()
match = original == repacked
diff_offset, diff_desc = first_diff(original, repacked)
issues = list(manifest.get("issues", []))
result = {
"relative_path": rel,
"type": item["type"],
"size_original": len(original),
"size_repacked": len(repacked),
"sha256_original": sha256_hex(original),
"sha256_repacked": sha256_hex(repacked),
"match": match,
"first_diff_offset": diff_offset,
"first_diff": diff_desc,
"issues": issues,
"entries": len(manifest.get("entries", [])),
"error": None,
}
except Exception as exc: # pylint: disable=broad-except
result = {
"relative_path": rel,
"type": item["type"],
"size_original": item["size"],
"size_repacked": None,
"sha256_original": None,
"sha256_repacked": None,
"match": False,
"first_diff_offset": None,
"first_diff": None,
"issues": [f"processing error: {exc}"],
"entries": None,
"error": str(exc),
}
report["results"].append(result)
if not result["match"]:
failures += 1
if result["issues"] and args.fail_on_issues:
failures += 1
matches = sum(1 for row in report["results"] if row["match"])
mismatches = len(report["results"]) - matches
nres_count = sum(1 for row in report["results"] if row["type"] == "nres")
rsli_count = sum(1 for row in report["results"] if row["type"] == "rsli")
issues_total = sum(len(row["issues"]) for row in report["results"])
report["summary"] = {
"nres_count": nres_count,
"rsli_count": rsli_count,
"matches": matches,
"mismatches": mismatches,
"issues_total": issues_total,
}
if args.report:
dump_json(Path(args.report).resolve(), report)
print(f"Input root : {input_root}")
print(f"Work dir : {workdir}")
print(f"NRes archives : {nres_count}")
print(f"RsLi archives : {rsli_count}")
print(f"Roundtrip match: {matches}/{len(report['results'])}")
print(f"Doc issues : {issues_total}")
if mismatches:
print("\nMismatches:")
for row in report["results"]:
if row["match"]:
continue
print(
f"- {row['relative_path']} [{row['type']}] "
f"diff@{row['first_diff_offset']}: {row['first_diff']}"
)
if issues_total:
print("\nIssues:")
for row in report["results"]:
if not row["issues"]:
continue
print(f"- {row['relative_path']} [{row['type']}]")
for issue in row["issues"]:
print(f" * {issue}")
finally:
if temp_created or args.cleanup:
shutil.rmtree(workdir, ignore_errors=True)
if failures > 0:
return 1
if report["summary"].get("mismatches", 0) > 0 and args.fail_on_diff:
return 1
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="NRes/RsLi tools: scan, unpack, repack, and roundtrip validation."
)
sub = parser.add_subparsers(dest="command", required=True)
scan = sub.add_parser("scan", help="Scan files by header signatures.")
scan.add_argument("--input", required=True, help="Root directory to scan.")
scan.add_argument("--json", action="store_true", help="Print JSON output.")
scan.set_defaults(func=cmd_scan)
nres_unpack = sub.add_parser("nres-unpack", help="Unpack a single NRes archive.")
nres_unpack.add_argument("--archive", required=True, help="Path to NRes file.")
nres_unpack.add_argument("--output", required=True, help="Output directory.")
nres_unpack.set_defaults(func=cmd_nres_unpack)
nres_pack = sub.add_parser("nres-pack", help="Pack NRes archive from manifest.")
nres_pack.add_argument("--manifest", required=True, help="Path to manifest.json.")
nres_pack.add_argument("--output", required=True, help="Output file path.")
nres_pack.set_defaults(func=cmd_nres_pack)
rsli_unpack = sub.add_parser("rsli-unpack", help="Unpack a single RsLi archive.")
rsli_unpack.add_argument("--archive", required=True, help="Path to RsLi file.")
rsli_unpack.add_argument("--output", required=True, help="Output directory.")
rsli_unpack.set_defaults(func=cmd_rsli_unpack)
rsli_pack = sub.add_parser("rsli-pack", help="Pack RsLi archive from manifest.")
rsli_pack.add_argument("--manifest", required=True, help="Path to manifest.json.")
rsli_pack.add_argument("--output", required=True, help="Output file path.")
rsli_pack.set_defaults(func=cmd_rsli_pack)
validate = sub.add_parser(
"validate",
help="Scan all archives and run unpack->repack->byte-compare validation.",
)
validate.add_argument("--input", required=True, help="Root with game data files.")
validate.add_argument(
"--workdir",
help="Working directory for temporary unpack/repack files. "
"If omitted, a temporary directory is used and removed automatically.",
)
validate.add_argument("--report", help="Optional JSON report output path.")
validate.add_argument(
"--fail-on-diff",
action="store_true",
help="Return non-zero exit code if any byte mismatch exists.",
)
validate.add_argument(
"--fail-on-issues",
action="store_true",
help="Return non-zero exit code if any spec issue was detected.",
)
validate.add_argument(
"--cleanup",
action="store_true",
help="Remove --workdir after completion.",
)
validate.set_defaults(func=cmd_validate)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
return int(args.func(args))
if __name__ == "__main__":
raise SystemExit(main())
-204
View File
@@ -1,204 +0,0 @@
#!/usr/bin/env python3
"""
Initialize test data folders by archive signatures.
The script scans all files in --input and copies matching archives into:
--output/nres/<relative path>
--output/rsli/<relative path>
"""
from __future__ import annotations
import argparse
import shutil
import sys
from pathlib import Path
MAGIC_NRES = b"NRes"
MAGIC_RSLI = b"NL\x00\x01"
def is_relative_to(path: Path, base: Path) -> bool:
try:
path.relative_to(base)
except ValueError:
return False
return True
def detect_archive_type(path: Path) -> str | None:
try:
with path.open("rb") as handle:
magic = handle.read(4)
except OSError as exc:
print(f"[warn] cannot read {path}: {exc}", file=sys.stderr)
return None
if magic == MAGIC_NRES:
return "nres"
if magic == MAGIC_RSLI:
return "rsli"
return None
def scan_archives(input_root: Path, excluded_root: Path | None) -> list[tuple[Path, str]]:
found: list[tuple[Path, str]] = []
for path in sorted(input_root.rglob("*")):
if not path.is_file():
continue
if excluded_root and is_relative_to(path.resolve(), excluded_root):
continue
archive_type = detect_archive_type(path)
if archive_type:
found.append((path, archive_type))
return found
def confirm_overwrite(path: Path) -> str:
prompt = (
f"File exists: {path}\n"
"Overwrite? [y]es / [n]o / [a]ll / [q]uit (default: n): "
)
while True:
try:
answer = input(prompt).strip().lower()
except EOFError:
return "quit"
if answer in {"", "n", "no"}:
return "no"
if answer in {"y", "yes"}:
return "yes"
if answer in {"a", "all"}:
return "all"
if answer in {"q", "quit"}:
return "quit"
print("Please answer with y, n, a, or q.")
def copy_archives(
archives: list[tuple[Path, str]],
input_root: Path,
output_root: Path,
force: bool,
) -> int:
copied = 0
skipped = 0
overwritten = 0
overwrite_all = force
type_counts = {"nres": 0, "rsli": 0}
for _, archive_type in archives:
type_counts[archive_type] += 1
print(
f"Found archives: total={len(archives)}, "
f"nres={type_counts['nres']}, rsli={type_counts['rsli']}"
)
for source, archive_type in archives:
rel_path = source.relative_to(input_root)
destination = output_root / archive_type / rel_path
destination.parent.mkdir(parents=True, exist_ok=True)
if destination.exists():
if destination.is_dir():
print(
f"[error] destination is a directory, expected file: {destination}",
file=sys.stderr,
)
return 2
if not overwrite_all:
if not sys.stdin.isatty():
print(
"[error] destination file exists but stdin is not interactive. "
"Use --force to overwrite without prompts.",
file=sys.stderr,
)
return 2
decision = confirm_overwrite(destination)
if decision == "quit":
print("Aborted by user.")
return 130
if decision == "no":
skipped += 1
continue
if decision == "all":
overwrite_all = True
overwritten += 1
try:
shutil.copy2(source, destination)
except OSError as exc:
print(f"[error] failed to copy {source} -> {destination}: {exc}", file=sys.stderr)
return 2
copied += 1
print(
f"Done: copied={copied}, overwritten={overwritten}, skipped={skipped}, "
f"output={output_root}"
)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Initialize test data by scanning NRes/RsLi signatures."
)
parser.add_argument(
"--input",
required=True,
help="Input directory to scan recursively.",
)
parser.add_argument(
"--output",
required=True,
help="Output root directory (archives go to nres/ and rsli/ subdirs).",
)
parser.add_argument(
"--force",
action="store_true",
help="Overwrite destination files without confirmation prompts.",
)
return parser
def main() -> int:
args = build_parser().parse_args()
input_root = Path(args.input)
if not input_root.exists():
print(f"[error] input directory does not exist: {input_root}", file=sys.stderr)
return 2
if not input_root.is_dir():
print(f"[error] input path is not a directory: {input_root}", file=sys.stderr)
return 2
output_root = Path(args.output)
if output_root.exists() and not output_root.is_dir():
print(f"[error] output path exists and is not a directory: {output_root}", file=sys.stderr)
return 2
input_resolved = input_root.resolve()
output_resolved = output_root.resolve()
if input_resolved == output_resolved:
print("[error] input and output directories must be different.", file=sys.stderr)
return 2
excluded_root: Path | None = None
if is_relative_to(output_resolved, input_resolved):
excluded_root = output_resolved
print(f"Notice: output is inside input, skipping scan under: {excluded_root}")
archives = scan_archives(input_root, excluded_root)
output_root.mkdir(parents=True, exist_ok=True)
return copy_archives(archives, input_root, output_root, force=args.force)
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
-357
View File
@@ -1,357 +0,0 @@
#!/usr/bin/env python3
"""
Export NGI MSH geometry to Wavefront OBJ.
The exporter is intended for inspection/debugging and uses the same
batch/slot selection logic as msh_preview_renderer.py.
"""
from __future__ import annotations
import argparse
import math
import struct
from pathlib import Path
from typing import Any
import archive_roundtrip_validator as arv
MAGIC_NRES = b"NRes"
def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes:
start = int(entry["data_offset"])
end = start + int(entry["size"])
return blob[start:end]
def _parse_nres(blob: bytes, source: str) -> dict[str, Any]:
if blob[:4] != MAGIC_NRES:
raise RuntimeError(f"{source}: not an NRes payload")
return arv.parse_nres(blob, source=source)
def _by_type(entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]:
out: dict[int, list[dict[str, Any]]] = {}
for row in entries:
out.setdefault(int(row["type_id"]), []).append(row)
return out
def _get_single(by_type: dict[int, list[dict[str, Any]]], type_id: int, label: str) -> dict[str, Any]:
rows = by_type.get(type_id, [])
if not rows:
raise RuntimeError(f"missing resource type {type_id} ({label})")
return rows[0]
def _pick_model_payload(archive_path: Path, model_name: str | None) -> tuple[bytes, str]:
root_blob = archive_path.read_bytes()
parsed = _parse_nres(root_blob, str(archive_path))
msh_entries = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")]
if msh_entries:
chosen: dict[str, Any] | None = None
if model_name:
model_l = model_name.lower()
for row in msh_entries:
name_l = str(row["name"]).lower()
if name_l == model_l:
chosen = row
break
if chosen is None:
for row in msh_entries:
if str(row["name"]).lower().startswith(model_l):
chosen = row
break
else:
chosen = msh_entries[0]
if chosen is None:
names = ", ".join(str(row["name"]) for row in msh_entries[:12])
raise RuntimeError(
f"model '{model_name}' not found in {archive_path}. Available: {names}"
)
return _entry_payload(root_blob, chosen), str(chosen["name"])
by_type = _by_type(parsed["entries"])
if all(k in by_type for k in (1, 2, 3, 6, 13)):
return root_blob, archive_path.name
raise RuntimeError(
f"{archive_path} does not contain .msh entries and does not look like a direct model payload"
)
def _extract_geometry(
model_blob: bytes,
*,
lod: int,
group: int,
max_faces: int,
all_batches: bool,
) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]], dict[str, int]]:
parsed = _parse_nres(model_blob, "<model>")
by_type = _by_type(parsed["entries"])
res1 = _get_single(by_type, 1, "Res1")
res2 = _get_single(by_type, 2, "Res2")
res3 = _get_single(by_type, 3, "Res3")
res6 = _get_single(by_type, 6, "Res6")
res13 = _get_single(by_type, 13, "Res13")
pos_blob = _entry_payload(model_blob, res3)
if len(pos_blob) % 12 != 0:
raise RuntimeError(f"Res3 size is not divisible by 12: {len(pos_blob)}")
vertex_count = len(pos_blob) // 12
positions = [struct.unpack_from("<3f", pos_blob, i * 12) for i in range(vertex_count)]
idx_blob = _entry_payload(model_blob, res6)
if len(idx_blob) % 2 != 0:
raise RuntimeError(f"Res6 size is not divisible by 2: {len(idx_blob)}")
index_count = len(idx_blob) // 2
indices = list(struct.unpack_from(f"<{index_count}H", idx_blob, 0))
batch_blob = _entry_payload(model_blob, res13)
if len(batch_blob) % 20 != 0:
raise RuntimeError(f"Res13 size is not divisible by 20: {len(batch_blob)}")
batch_count = len(batch_blob) // 20
batches: list[tuple[int, int, int, int]] = []
for i in range(batch_count):
off = i * 20
idx_count = struct.unpack_from("<H", batch_blob, off + 8)[0]
idx_start = struct.unpack_from("<I", batch_blob, off + 10)[0]
base_vertex = struct.unpack_from("<I", batch_blob, off + 16)[0]
batches.append((idx_count, idx_start, base_vertex, i))
res2_blob = _entry_payload(model_blob, res2)
if len(res2_blob) < 0x8C:
raise RuntimeError("Res2 is too small (< 0x8C)")
slot_blob = res2_blob[0x8C:]
if len(slot_blob) % 68 != 0:
raise RuntimeError(f"Res2 slot area is not divisible by 68: {len(slot_blob)}")
slot_count = len(slot_blob) // 68
slots: list[tuple[int, int, int, int]] = []
for i in range(slot_count):
off = i * 68
tri_start, tri_count, batch_start, slot_batch_count = struct.unpack_from("<4H", slot_blob, off)
slots.append((tri_start, tri_count, batch_start, slot_batch_count))
res1_blob = _entry_payload(model_blob, res1)
node_stride = int(res1["attr3"])
node_count = int(res1["attr1"])
node_slot_indices: list[int] = []
if not all_batches and node_stride >= 38 and len(res1_blob) >= node_count * node_stride:
if lod < 0 or lod > 2:
raise RuntimeError(f"lod must be 0..2 (got {lod})")
if group < 0 or group > 4:
raise RuntimeError(f"group must be 0..4 (got {group})")
matrix_index = lod * 5 + group
for n in range(node_count):
off = n * node_stride + 8 + matrix_index * 2
slot_idx = struct.unpack_from("<H", res1_blob, off)[0]
if slot_idx == 0xFFFF:
continue
if slot_idx >= slot_count:
continue
node_slot_indices.append(slot_idx)
faces: list[tuple[int, int, int]] = []
used_batches = 0
used_slots = 0
def append_batch(batch_idx: int) -> None:
nonlocal used_batches
if batch_idx < 0 or batch_idx >= len(batches):
return
idx_count, idx_start, base_vertex, _ = batches[batch_idx]
if idx_count < 3:
return
end = idx_start + idx_count
if end > len(indices):
return
used_batches += 1
tri_count = idx_count // 3
for t in range(tri_count):
i0 = indices[idx_start + t * 3 + 0] + base_vertex
i1 = indices[idx_start + t * 3 + 1] + base_vertex
i2 = indices[idx_start + t * 3 + 2] + base_vertex
if i0 >= vertex_count or i1 >= vertex_count or i2 >= vertex_count:
continue
faces.append((i0, i1, i2))
if len(faces) >= max_faces:
return
if node_slot_indices:
for slot_idx in node_slot_indices:
if len(faces) >= max_faces:
break
_tri_start, _tri_count, batch_start, slot_batch_count = slots[slot_idx]
used_slots += 1
for bi in range(batch_start, batch_start + slot_batch_count):
append_batch(bi)
if len(faces) >= max_faces:
break
else:
for bi in range(batch_count):
append_batch(bi)
if len(faces) >= max_faces:
break
if not faces:
raise RuntimeError("no faces selected for export")
meta = {
"vertex_count": vertex_count,
"index_count": index_count,
"batch_count": batch_count,
"slot_count": slot_count,
"node_count": node_count,
"used_slots": used_slots,
"used_batches": used_batches,
"face_count": len(faces),
}
return positions, faces, meta
def _compute_vertex_normals(
positions: list[tuple[float, float, float]],
faces: list[tuple[int, int, int]],
) -> list[tuple[float, float, float]]:
acc = [[0.0, 0.0, 0.0] for _ in positions]
for i0, i1, i2 in faces:
p0 = positions[i0]
p1 = positions[i1]
p2 = positions[i2]
ux = p1[0] - p0[0]
uy = p1[1] - p0[1]
uz = p1[2] - p0[2]
vx = p2[0] - p0[0]
vy = p2[1] - p0[1]
vz = p2[2] - p0[2]
nx = uy * vz - uz * vy
ny = uz * vx - ux * vz
nz = ux * vy - uy * vx
acc[i0][0] += nx
acc[i0][1] += ny
acc[i0][2] += nz
acc[i1][0] += nx
acc[i1][1] += ny
acc[i1][2] += nz
acc[i2][0] += nx
acc[i2][1] += ny
acc[i2][2] += nz
normals: list[tuple[float, float, float]] = []
for nx, ny, nz in acc:
ln = math.sqrt(nx * nx + ny * ny + nz * nz)
if ln <= 1e-12:
normals.append((0.0, 1.0, 0.0))
else:
normals.append((nx / ln, ny / ln, nz / ln))
return normals
def _write_obj(
output_path: Path,
object_name: str,
positions: list[tuple[float, float, float]],
faces: list[tuple[int, int, int]],
) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
normals = _compute_vertex_normals(positions, faces)
with output_path.open("w", encoding="utf-8", newline="\n") as out:
out.write("# Exported by msh_export_obj.py\n")
out.write(f"o {object_name}\n")
for x, y, z in positions:
out.write(f"v {x:.9g} {y:.9g} {z:.9g}\n")
for nx, ny, nz in normals:
out.write(f"vn {nx:.9g} {ny:.9g} {nz:.9g}\n")
for i0, i1, i2 in faces:
a = i0 + 1
b = i1 + 1
c = i2 + 1
out.write(f"f {a}//{a} {b}//{b} {c}//{c}\n")
def cmd_list_models(args: argparse.Namespace) -> int:
archive_path = Path(args.archive).resolve()
blob = archive_path.read_bytes()
parsed = _parse_nres(blob, str(archive_path))
rows = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")]
print(f"Archive: {archive_path}")
print(f"MSH entries: {len(rows)}")
for row in rows:
print(f"- {row['name']}")
return 0
def cmd_export(args: argparse.Namespace) -> int:
archive_path = Path(args.archive).resolve()
output_path = Path(args.output).resolve()
model_blob, model_label = _pick_model_payload(archive_path, args.model)
positions, faces, meta = _extract_geometry(
model_blob,
lod=int(args.lod),
group=int(args.group),
max_faces=int(args.max_faces),
all_batches=bool(args.all_batches),
)
obj_name = Path(model_label).stem or "msh_model"
_write_obj(output_path, obj_name, positions, faces)
print(f"Exported model : {model_label}")
print(f"Output OBJ : {output_path}")
print(f"Object name : {obj_name}")
print(
"Geometry : "
f"vertices={meta['vertex_count']}, faces={meta['face_count']}, "
f"batches={meta['used_batches']}/{meta['batch_count']}, slots={meta['used_slots']}/{meta['slot_count']}"
)
print(
"Mode : "
f"lod={args.lod}, group={args.group}, all_batches={bool(args.all_batches)}"
)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Export NGI MSH geometry to Wavefront OBJ."
)
sub = parser.add_subparsers(dest="command", required=True)
list_models = sub.add_parser("list-models", help="List .msh entries in an NRes archive.")
list_models.add_argument("--archive", required=True, help="Path to archive (e.g. animals.rlb).")
list_models.set_defaults(func=cmd_list_models)
export = sub.add_parser("export", help="Export one model to OBJ.")
export.add_argument("--archive", required=True, help="Path to NRes archive or direct model payload.")
export.add_argument(
"--model",
help="Model entry name (*.msh) inside archive. If omitted, first .msh is used.",
)
export.add_argument("--output", required=True, help="Output .obj path.")
export.add_argument("--lod", type=int, default=0, help="LOD index 0..2 (default: 0).")
export.add_argument("--group", type=int, default=0, help="Group index 0..4 (default: 0).")
export.add_argument("--max-faces", type=int, default=120000, help="Face limit (default: 120000).")
export.add_argument(
"--all-batches",
action="store_true",
help="Ignore slot matrix selection and export all batches.",
)
export.set_defaults(func=cmd_export)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
return int(args.func(args))
if __name__ == "__main__":
raise SystemExit(main())
-481
View File
@@ -1,481 +0,0 @@
#!/usr/bin/env python3
"""
Primitive software renderer for NGI MSH models.
Output format: binary PPM (P6), no external dependencies.
"""
from __future__ import annotations
import argparse
import math
import struct
from pathlib import Path
from typing import Any
import archive_roundtrip_validator as arv
MAGIC_NRES = b"NRes"
def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes:
start = int(entry["data_offset"])
end = start + int(entry["size"])
return blob[start:end]
def _parse_nres(blob: bytes, source: str) -> dict[str, Any]:
if blob[:4] != MAGIC_NRES:
raise RuntimeError(f"{source}: not an NRes payload")
return arv.parse_nres(blob, source=source)
def _by_type(entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]:
out: dict[int, list[dict[str, Any]]] = {}
for row in entries:
out.setdefault(int(row["type_id"]), []).append(row)
return out
def _pick_model_payload(archive_path: Path, model_name: str | None) -> tuple[bytes, str]:
root_blob = archive_path.read_bytes()
parsed = _parse_nres(root_blob, str(archive_path))
msh_entries = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")]
if msh_entries:
chosen: dict[str, Any] | None = None
if model_name:
model_l = model_name.lower()
for row in msh_entries:
name_l = str(row["name"]).lower()
if name_l == model_l:
chosen = row
break
if chosen is None:
for row in msh_entries:
if str(row["name"]).lower().startswith(model_l):
chosen = row
break
else:
chosen = msh_entries[0]
if chosen is None:
names = ", ".join(str(row["name"]) for row in msh_entries[:12])
raise RuntimeError(
f"model '{model_name}' not found in {archive_path}. Available: {names}"
)
return _entry_payload(root_blob, chosen), str(chosen["name"])
# Fallback: treat file itself as a model NRes payload.
by_type = _by_type(parsed["entries"])
if all(k in by_type for k in (1, 2, 3, 6, 13)):
return root_blob, archive_path.name
raise RuntimeError(
f"{archive_path} does not contain .msh entries and does not look like a direct model payload"
)
def _get_single(by_type: dict[int, list[dict[str, Any]]], type_id: int, label: str) -> dict[str, Any]:
rows = by_type.get(type_id, [])
if not rows:
raise RuntimeError(f"missing resource type {type_id} ({label})")
return rows[0]
def _extract_geometry(
model_blob: bytes,
*,
lod: int,
group: int,
max_faces: int,
) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]], dict[str, int]]:
parsed = _parse_nres(model_blob, "<model>")
by_type = _by_type(parsed["entries"])
res1 = _get_single(by_type, 1, "Res1")
res2 = _get_single(by_type, 2, "Res2")
res3 = _get_single(by_type, 3, "Res3")
res6 = _get_single(by_type, 6, "Res6")
res13 = _get_single(by_type, 13, "Res13")
# Positions
pos_blob = _entry_payload(model_blob, res3)
if len(pos_blob) % 12 != 0:
raise RuntimeError(f"Res3 size is not divisible by 12: {len(pos_blob)}")
vertex_count = len(pos_blob) // 12
positions = [struct.unpack_from("<3f", pos_blob, i * 12) for i in range(vertex_count)]
# Indices
idx_blob = _entry_payload(model_blob, res6)
if len(idx_blob) % 2 != 0:
raise RuntimeError(f"Res6 size is not divisible by 2: {len(idx_blob)}")
index_count = len(idx_blob) // 2
indices = list(struct.unpack_from(f"<{index_count}H", idx_blob, 0))
# Batches
batch_blob = _entry_payload(model_blob, res13)
if len(batch_blob) % 20 != 0:
raise RuntimeError(f"Res13 size is not divisible by 20: {len(batch_blob)}")
batch_count = len(batch_blob) // 20
batches: list[tuple[int, int, int, int]] = []
for i in range(batch_count):
off = i * 20
# Keep only fields used by renderer:
# indexCount, indexStart, baseVertex
idx_count = struct.unpack_from("<H", batch_blob, off + 8)[0]
idx_start = struct.unpack_from("<I", batch_blob, off + 10)[0]
base_vertex = struct.unpack_from("<I", batch_blob, off + 16)[0]
batches.append((idx_count, idx_start, base_vertex, i))
# Slots
res2_blob = _entry_payload(model_blob, res2)
if len(res2_blob) < 0x8C:
raise RuntimeError("Res2 is too small (< 0x8C)")
slot_blob = res2_blob[0x8C:]
if len(slot_blob) % 68 != 0:
raise RuntimeError(f"Res2 slot area is not divisible by 68: {len(slot_blob)}")
slot_count = len(slot_blob) // 68
slots: list[tuple[int, int, int, int]] = []
for i in range(slot_count):
off = i * 68
tri_start, tri_count, batch_start, slot_batch_count = struct.unpack_from("<4H", slot_blob, off)
slots.append((tri_start, tri_count, batch_start, slot_batch_count))
# Nodes / slot matrix
res1_blob = _entry_payload(model_blob, res1)
node_stride = int(res1["attr3"])
node_count = int(res1["attr1"])
node_slot_indices: list[int] = []
if node_stride >= 38 and len(res1_blob) >= node_count * node_stride:
if lod < 0 or lod > 2:
raise RuntimeError(f"lod must be 0..2 (got {lod})")
if group < 0 or group > 4:
raise RuntimeError(f"group must be 0..4 (got {group})")
matrix_index = lod * 5 + group
for n in range(node_count):
off = n * node_stride + 8 + matrix_index * 2
slot_idx = struct.unpack_from("<H", res1_blob, off)[0]
if slot_idx == 0xFFFF:
continue
if slot_idx >= slot_count:
continue
node_slot_indices.append(slot_idx)
# Build triangle list.
faces: list[tuple[int, int, int]] = []
used_batches = 0
used_slots = 0
def append_batch(batch_idx: int) -> None:
nonlocal used_batches
if batch_idx < 0 or batch_idx >= len(batches):
return
idx_count, idx_start, base_vertex, _ = batches[batch_idx]
if idx_count < 3:
return
end = idx_start + idx_count
if end > len(indices):
return
used_batches += 1
tri_count = idx_count // 3
for t in range(tri_count):
i0 = indices[idx_start + t * 3 + 0] + base_vertex
i1 = indices[idx_start + t * 3 + 1] + base_vertex
i2 = indices[idx_start + t * 3 + 2] + base_vertex
if i0 >= vertex_count or i1 >= vertex_count or i2 >= vertex_count:
continue
faces.append((i0, i1, i2))
if len(faces) >= max_faces:
return
if node_slot_indices:
for slot_idx in node_slot_indices:
if len(faces) >= max_faces:
break
_tri_start, _tri_count, batch_start, slot_batch_count = slots[slot_idx]
used_slots += 1
for bi in range(batch_start, batch_start + slot_batch_count):
append_batch(bi)
if len(faces) >= max_faces:
break
else:
# Fallback if slot matrix is unavailable: draw all batches.
for bi in range(batch_count):
append_batch(bi)
if len(faces) >= max_faces:
break
meta = {
"vertex_count": vertex_count,
"index_count": index_count,
"batch_count": batch_count,
"slot_count": slot_count,
"node_count": node_count,
"used_slots": used_slots,
"used_batches": used_batches,
"face_count": len(faces),
}
if not faces:
raise RuntimeError("no faces selected for rendering")
return positions, faces, meta
def _write_ppm(path: Path, width: int, height: int, rgb: bytearray) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("wb") as handle:
handle.write(f"P6\n{width} {height}\n255\n".encode("ascii"))
handle.write(rgb)
def _render_software(
positions: list[tuple[float, float, float]],
faces: list[tuple[int, int, int]],
*,
width: int,
height: int,
yaw_deg: float,
pitch_deg: float,
wireframe: bool,
) -> bytearray:
xs = [p[0] for p in positions]
ys = [p[1] for p in positions]
zs = [p[2] for p in positions]
cx = (min(xs) + max(xs)) * 0.5
cy = (min(ys) + max(ys)) * 0.5
cz = (min(zs) + max(zs)) * 0.5
span = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs))
radius = max(span * 0.5, 1e-3)
yaw = math.radians(yaw_deg)
pitch = math.radians(pitch_deg)
cyaw = math.cos(yaw)
syaw = math.sin(yaw)
cpitch = math.cos(pitch)
spitch = math.sin(pitch)
camera_dist = radius * 3.2
scale = min(width, height) * 0.95
# Transform all vertices once.
vx: list[float] = []
vy: list[float] = []
vz: list[float] = []
sx: list[float] = []
sy: list[float] = []
for x, y, z in positions:
x0 = x - cx
y0 = y - cy
z0 = z - cz
x1 = cyaw * x0 + syaw * z0
z1 = -syaw * x0 + cyaw * z0
y2 = cpitch * y0 - spitch * z1
z2 = spitch * y0 + cpitch * z1 + camera_dist
if z2 < 1e-3:
z2 = 1e-3
vx.append(x1)
vy.append(y2)
vz.append(z2)
sx.append(width * 0.5 + (x1 / z2) * scale)
sy.append(height * 0.5 - (y2 / z2) * scale)
rgb = bytearray([16, 18, 24] * (width * height))
zbuf = [float("inf")] * (width * height)
light_dir = (0.35, 0.45, 1.0)
l_len = math.sqrt(light_dir[0] ** 2 + light_dir[1] ** 2 + light_dir[2] ** 2)
light = (light_dir[0] / l_len, light_dir[1] / l_len, light_dir[2] / l_len)
def edge(ax: float, ay: float, bx: float, by: float, px: float, py: float) -> float:
return (px - ax) * (by - ay) - (py - ay) * (bx - ax)
for i0, i1, i2 in faces:
x0 = sx[i0]
y0 = sy[i0]
x1 = sx[i1]
y1 = sy[i1]
x2 = sx[i2]
y2 = sy[i2]
area = edge(x0, y0, x1, y1, x2, y2)
if area == 0.0:
continue
# Shading from camera-space normal.
ux = vx[i1] - vx[i0]
uy = vy[i1] - vy[i0]
uz = vz[i1] - vz[i0]
wx = vx[i2] - vx[i0]
wy = vy[i2] - vy[i0]
wz = vz[i2] - vz[i0]
nx = uy * wz - uz * wy
ny = uz * wx - ux * wz
nz = ux * wy - uy * wx
n_len = math.sqrt(nx * nx + ny * ny + nz * nz)
if n_len > 0.0:
nx /= n_len
ny /= n_len
nz /= n_len
intensity = nx * light[0] + ny * light[1] + nz * light[2]
if intensity < 0.0:
intensity = 0.0
shade = int(45 + 200 * intensity)
color = (shade, shade, min(255, shade + 18))
minx = int(max(0, math.floor(min(x0, x1, x2))))
maxx = int(min(width - 1, math.ceil(max(x0, x1, x2))))
miny = int(max(0, math.floor(min(y0, y1, y2))))
maxy = int(min(height - 1, math.ceil(max(y0, y1, y2))))
if minx > maxx or miny > maxy:
continue
z0 = vz[i0]
z1 = vz[i1]
z2 = vz[i2]
for py in range(miny, maxy + 1):
fy = py + 0.5
row = py * width
for px in range(minx, maxx + 1):
fx = px + 0.5
w0 = edge(x1, y1, x2, y2, fx, fy)
w1 = edge(x2, y2, x0, y0, fx, fy)
w2 = edge(x0, y0, x1, y1, fx, fy)
if area > 0:
if w0 < 0 or w1 < 0 or w2 < 0:
continue
else:
if w0 > 0 or w1 > 0 or w2 > 0:
continue
inv_area = 1.0 / area
bz0 = w0 * inv_area
bz1 = w1 * inv_area
bz2 = w2 * inv_area
depth = bz0 * z0 + bz1 * z1 + bz2 * z2
idx = row + px
if depth >= zbuf[idx]:
continue
zbuf[idx] = depth
p = idx * 3
rgb[p + 0] = color[0]
rgb[p + 1] = color[1]
rgb[p + 2] = color[2]
if wireframe:
def draw_line(xa: float, ya: float, xb: float, yb: float) -> None:
x0i = int(round(xa))
y0i = int(round(ya))
x1i = int(round(xb))
y1i = int(round(yb))
dx = abs(x1i - x0i)
sx_step = 1 if x0i < x1i else -1
dy = -abs(y1i - y0i)
sy_step = 1 if y0i < y1i else -1
err = dx + dy
x = x0i
y = y0i
while True:
if 0 <= x < width and 0 <= y < height:
p = (y * width + x) * 3
rgb[p + 0] = 240
rgb[p + 1] = 245
rgb[p + 2] = 255
if x == x1i and y == y1i:
break
e2 = 2 * err
if e2 >= dy:
err += dy
x += sx_step
if e2 <= dx:
err += dx
y += sy_step
for i0, i1, i2 in faces:
draw_line(sx[i0], sy[i0], sx[i1], sy[i1])
draw_line(sx[i1], sy[i1], sx[i2], sy[i2])
draw_line(sx[i2], sy[i2], sx[i0], sy[i0])
return rgb
def cmd_list_models(args: argparse.Namespace) -> int:
archive_path = Path(args.archive).resolve()
blob = archive_path.read_bytes()
parsed = _parse_nres(blob, str(archive_path))
rows = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")]
print(f"Archive: {archive_path}")
print(f"MSH entries: {len(rows)}")
for row in rows:
print(f"- {row['name']}")
return 0
def cmd_render(args: argparse.Namespace) -> int:
archive_path = Path(args.archive).resolve()
output_path = Path(args.output).resolve()
model_blob, model_label = _pick_model_payload(archive_path, args.model)
positions, faces, meta = _extract_geometry(
model_blob,
lod=int(args.lod),
group=int(args.group),
max_faces=int(args.max_faces),
)
rgb = _render_software(
positions,
faces,
width=int(args.width),
height=int(args.height),
yaw_deg=float(args.yaw),
pitch_deg=float(args.pitch),
wireframe=bool(args.wireframe),
)
_write_ppm(output_path, int(args.width), int(args.height), rgb)
print(f"Rendered model: {model_label}")
print(f"Output : {output_path}")
print(
"Geometry : "
f"vertices={meta['vertex_count']}, faces={meta['face_count']}, "
f"batches={meta['used_batches']}/{meta['batch_count']}, slots={meta['used_slots']}/{meta['slot_count']}"
)
print(f"Mode : lod={args.lod}, group={args.group}, wireframe={bool(args.wireframe)}")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Primitive NGI MSH renderer (software, dependency-free)."
)
sub = parser.add_subparsers(dest="command", required=True)
list_models = sub.add_parser("list-models", help="List .msh entries in an NRes archive.")
list_models.add_argument("--archive", required=True, help="Path to archive (e.g. animals.rlb).")
list_models.set_defaults(func=cmd_list_models)
render = sub.add_parser("render", help="Render one model to PPM image.")
render.add_argument("--archive", required=True, help="Path to NRes archive or direct model payload.")
render.add_argument(
"--model",
help="Model entry name (*.msh) inside archive. If omitted, first .msh is used.",
)
render.add_argument("--output", required=True, help="Output .ppm file path.")
render.add_argument("--lod", type=int, default=0, help="LOD index 0..2 (default: 0).")
render.add_argument("--group", type=int, default=0, help="Group index 0..4 (default: 0).")
render.add_argument("--max-faces", type=int, default=120000, help="Face limit (default: 120000).")
render.add_argument("--width", type=int, default=1280, help="Image width (default: 1280).")
render.add_argument("--height", type=int, default=720, help="Image height (default: 720).")
render.add_argument("--yaw", type=float, default=35.0, help="Yaw angle in degrees (default: 35).")
render.add_argument("--pitch", type=float, default=18.0, help="Pitch angle in degrees (default: 18).")
render.add_argument("--wireframe", action="store_true", help="Draw white wireframe overlay.")
render.set_defaults(func=cmd_render)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
return int(args.func(args))
if __name__ == "__main__":
raise SystemExit(main())