Compare commits
26
Commits
master
..
7416fdc7e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7416fdc7e9 | ||
|
|
78fc5f1deb
|
||
|
|
50c2cf4686
|
||
|
|
a63290fbc8 | ||
|
|
96a25b6c0e | ||
|
|
f4262cf369 | ||
|
|
9b100b8fc3 | ||
|
|
9fceeb9a0a | ||
|
|
4b7f1a16b9 | ||
|
|
ada3b903ad
|
||
|
|
31d849ddbf | ||
|
|
4ef08d0bf6 | ||
|
|
598137ed13
|
||
|
|
cb0ca2f2f0
|
||
|
|
7346e695c4
|
||
|
|
bb827c3928
|
||
|
|
efab61a45c
|
||
|
|
0d7ae6a017 | ||
|
|
a281ffa32e | ||
|
|
18d4c6cf9f | ||
|
|
0e19660eb5 | ||
|
|
8a69872576
|
||
|
|
aa68906a3d
|
||
|
|
8bf3b7b209
|
||
|
|
669fb40a70
|
||
|
|
9c0df3d299
|
@@ -3,7 +3,7 @@ name: Docs Deploy
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- devel
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy-docs:
|
deploy-docs:
|
||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Run renovate
|
- name: Run renovate
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ jobs:
|
|||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
components: clippy
|
components: clippy
|
||||||
@@ -21,7 +21,35 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: lint
|
needs: lint
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- 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@v7
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- name: Install headless GL runtime
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y xvfb libgl1-mesa-dri libgles2-mesa-dev mesa-utils
|
||||||
|
- name: Build render-demo binary
|
||||||
|
run: cargo build -p render-demo --features demo
|
||||||
|
- name: Run frame parity suite
|
||||||
|
run: |
|
||||||
|
xvfb-run -s "-screen 0 1280x720x24" cargo run -p render-parity -- \
|
||||||
|
--manifest parity/cases.toml \
|
||||||
|
--output-dir target/render-parity/current \
|
||||||
|
--demo-bin target/debug/parkan-render-demo \
|
||||||
|
--keep-going
|
||||||
|
- name: Upload parity artifacts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: render-parity-artifacts
|
||||||
|
path: target/render-parity/current
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# msh-core
|
||||||
|
|
||||||
|
Парсер core-части формата `MSH`.
|
||||||
|
|
||||||
|
Покрывает:
|
||||||
|
|
||||||
|
- `Res1`, `Res2`, `Res3`, `Res6`, `Res13` (обязательные);
|
||||||
|
- `Res4`, `Res5`, `Res10` (опциональные);
|
||||||
|
- slot lookup по `node/lod/group`.
|
||||||
|
|
||||||
|
Тесты:
|
||||||
|
|
||||||
|
- прогон по всем `.msh` в `testdata`;
|
||||||
|
- синтетическая минимальная модель.
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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,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("..")
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# render-core
|
||||||
|
|
||||||
|
CPU-подготовка draw-данных для моделей `MSH`.
|
||||||
|
|
||||||
|
Покрывает:
|
||||||
|
|
||||||
|
- обход `node -> slot -> batch`;
|
||||||
|
- раскрытие индексов в triangle-list (`position + uv0`);
|
||||||
|
- расчёт bounds по вершинам.
|
||||||
|
|
||||||
|
Тесты:
|
||||||
|
|
||||||
|
- построение рендер-сеток на реальных `.msh` из `testdata`;
|
||||||
|
- unit-test bounds.
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fn main() {
|
||||||
|
#[cfg(windows)]
|
||||||
|
println!("cargo:rustc-link-lib=advapi32");
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, ¢er_shift);
|
||||||
|
let vp = mat4_mul(&view, &model_m);
|
||||||
|
mat4_mul(&proj, &vp)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
unsafe fn draw_frame(
|
||||||
|
gl: &glow::Context,
|
||||||
|
program: glow::NativeProgram,
|
||||||
|
u_mvp: Option<&glow::NativeUniformLocation>,
|
||||||
|
u_use_tex: Option<&glow::NativeUniformLocation>,
|
||||||
|
u_tex: Option<&glow::NativeUniformLocation>,
|
||||||
|
a_pos: u32,
|
||||||
|
a_uv: u32,
|
||||||
|
vbo: glow::NativeBuffer,
|
||||||
|
ebo: glow::NativeBuffer,
|
||||||
|
vao: Option<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
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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, ®istry, key)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to resolve '{key}' from objects.rlb: {err}"))
|
||||||
|
.unwrap_or_else(|| panic!("missing model resolution for '{key}'"));
|
||||||
|
|
||||||
|
let got_archive = proto
|
||||||
|
.archive_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.map(|name| name.to_ascii_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert_eq!(got_archive, archive_name.to_ascii_lowercase());
|
||||||
|
assert!(
|
||||||
|
proto.model_name.eq_ignore_ascii_case(model_name),
|
||||||
|
"unexpected model for key '{key}': got '{}', expected '{}'",
|
||||||
|
proto.model_name,
|
||||||
|
model_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "terrain-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nres = { path = "../nres" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[package]
|
||||||
|
name = "texm"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
nres = { path = "../nres" }
|
||||||
|
proptest = "1"
|
||||||
@@ -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).
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "tma"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
encoding_rs = "0.8"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "unitdat"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
encoding_rs = "0.8"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# Глоссарий
|
||||||
|
|
||||||
|
Глоссарий объясняет термины в том смысле, в котором они используются в этой
|
||||||
|
книге. Короткое определение не заменяет профильную главу: практический контракт
|
||||||
|
понятия раскрывается в соответствующем томе или справочной странице.
|
||||||
|
|
||||||
|
## Бинарные файлы и ABI
|
||||||
|
|
||||||
|
**PE (Portable Executable)** -- формат исполняемых файлов Windows: EXE и DLL.
|
||||||
|
Он содержит заголовки, секции, таблицы импортов и экспортов, relocations и
|
||||||
|
адрес точки входа.
|
||||||
|
|
||||||
|
**Image base** -- предпочтительный адрес начала загруженного PE-образа.
|
||||||
|
**VA** -- виртуальный адрес в процессе. **RVA** -- адрес относительно image
|
||||||
|
base.
|
||||||
|
|
||||||
|
**Import** -- внешняя функция или переменная, которую модуль получает из другой
|
||||||
|
DLL. **Export** -- символ, предоставляемый другим модулям. Имя, ordinal и
|
||||||
|
calling convention вместе образуют часть binary contract.
|
||||||
|
|
||||||
|
**ABI** -- соглашение о двоичном взаимодействии: размещение аргументов, возврат
|
||||||
|
значений, очистка stack, layout структур, порядок virtual methods и правила
|
||||||
|
владения.
|
||||||
|
|
||||||
|
**Calling convention** -- часть ABI, определяющая передачу аргументов и очистку
|
||||||
|
stack. Для исследованного 32-bit code важны `__cdecl`, `__stdcall` и
|
||||||
|
`__thiscall`.
|
||||||
|
|
||||||
|
**Vtable** -- массив указателей на virtual methods C++-объекта. Запись
|
||||||
|
`vtable +0x34` означает вызов указателя по байтовому смещению `0x34` от начала
|
||||||
|
таблицы.
|
||||||
|
|
||||||
|
**Static analysis** исследует файл без исполнения: disassembly, strings,
|
||||||
|
imports, call graph и data flow. **Dynamic analysis** наблюдает работающую
|
||||||
|
программу: breakpoints, traces, API hooks, memory state и packet/frame captures.
|
||||||
|
|
||||||
|
**Evidence** -- повторяемое наблюдение. **Inference** -- вывод, объединяющий
|
||||||
|
несколько наблюдений. **Hypothesis** -- рабочее предположение, ещё не
|
||||||
|
подтверждённое достаточным экспериментом.
|
||||||
|
|
||||||
|
## Форматы данных
|
||||||
|
|
||||||
|
**Archive** -- контейнер, объединяющий множество ресурсов. **Entry** -- запись
|
||||||
|
его каталога. **Payload** -- полезные bytes конкретной записи.
|
||||||
|
|
||||||
|
**Magic** -- короткая сигнатура формата, например `NRes` или `Texm`.
|
||||||
|
**Version** -- номер варианта layout. Проверка одной magic без проверки version
|
||||||
|
и размеров недостаточна.
|
||||||
|
|
||||||
|
**Offset** -- положение данных относительно начала файла или структуры.
|
||||||
|
**Size** -- число bytes. **Stride** -- размер одного элемента массива.
|
||||||
|
**Alignment** -- требование начинать данные на offset, кратном заданному числу.
|
||||||
|
|
||||||
|
**Little-endian** -- порядок, в котором младший byte многобайтного числа
|
||||||
|
расположен первым. Основные числовые поля форматов Iron3D используют этот
|
||||||
|
порядок.
|
||||||
|
|
||||||
|
**Fixed-size string** -- поле заранее известной длины. Полезная строка
|
||||||
|
заканчивается первым NUL, но оставшиеся bytes могут содержать служебный хвост и
|
||||||
|
должны сохраняться.
|
||||||
|
|
||||||
|
**Opaque field** -- поле с доказанными offset и size, но не установленным
|
||||||
|
предметным смыслом. Его безопасно читать и копировать, но нельзя очищать или
|
||||||
|
переосмысливать без эксперимента.
|
||||||
|
|
||||||
|
**Invariant** -- условие, которое обязано выполняться: range лежит внутри
|
||||||
|
payload, индекс указывает на существующий элемент, count соответствует размеру
|
||||||
|
секции.
|
||||||
|
|
||||||
|
**Strict reader** отклоняет любое нарушение контракта. **Compatibility reader**
|
||||||
|
дополнительно воспроизводит только известные особенности оригинала.
|
||||||
|
|
||||||
|
**Fallback** -- явно предписанный запасной путь, например material `DEFAULT`,
|
||||||
|
затем entry 0. **Heuristic** -- догадка по похожим данным; она не должна
|
||||||
|
незаметно заменять доказанный fallback.
|
||||||
|
|
||||||
|
**Roundtrip** -- последовательность decode -> encode. **Byte-identical
|
||||||
|
roundtrip** создаёт файл, полностью совпадающий с исходным. **Lossless editor**
|
||||||
|
может изменить известное поле, сохранив все остальные bytes и порядок записей.
|
||||||
|
|
||||||
|
## Ресурсы
|
||||||
|
|
||||||
|
**NRes** -- основной контейнер ресурсов с каталогом в конце файла.
|
||||||
|
|
||||||
|
**RsLi** -- библиотечный архив с каталогом в начале файла и несколькими методами
|
||||||
|
упаковки payload.
|
||||||
|
|
||||||
|
**TMA** -- mission data: paths, clans, placed objects, properties, land path и
|
||||||
|
extras.
|
||||||
|
|
||||||
|
**MSH** -- модель Iron3D, представленная как NRes с entries для geometry,
|
||||||
|
nodes, slots, batches, animation и auxiliary streams.
|
||||||
|
|
||||||
|
**WEAR** -- таблица внешнего вида модели, переводящая material index в MAT0
|
||||||
|
name и lightmap slots.
|
||||||
|
|
||||||
|
**MAT0** -- материал: phases, parameters, animation blocks и texture references.
|
||||||
|
|
||||||
|
**Texm** -- texture payload с header, palette, mip chain и optional Page atlas.
|
||||||
|
|
||||||
|
**FXID** -- ресурс эффектов: команды, references, lifetime, random/time modes и
|
||||||
|
runtime instances.
|
||||||
|
|
||||||
|
## Игровой runtime
|
||||||
|
|
||||||
|
**Engine** -- программная среда, которая загружает данные, ведёт время,
|
||||||
|
исполняет мир и формирует изображение/звук. **Game** -- правила, миссии и
|
||||||
|
content поверх engine services.
|
||||||
|
|
||||||
|
**World** -- долгоживущее состояние миссии: objects, terrain, время, кланы и
|
||||||
|
managers. **Scene** -- представление части мира для конкретной обработки,
|
||||||
|
обычно текущей камеры.
|
||||||
|
|
||||||
|
**Game object** -- сущность с идентичностью, transform, properties и lifecycle.
|
||||||
|
**Component/controller** -- специализированная часть поведения: animation,
|
||||||
|
physics, AI или rendering representation.
|
||||||
|
|
||||||
|
**Simulation** отвечает за изменение мира. **Tick** -- один расчётный шаг.
|
||||||
|
**Frame** -- одно подготовленное изображение. Число ticks и frames за единицу
|
||||||
|
времени не обязано совпадать.
|
||||||
|
|
||||||
|
**Event/message** -- типизированное сообщение между objects или subsystems.
|
||||||
|
**Queue traversal** -- стабильный обход зарегистрированных объектов.
|
||||||
|
**Deferred deletion** -- перенос фактического удаления до безопасной границы.
|
||||||
|
|
||||||
|
**Snapshot** -- согласованное состояние, которое renderer читает без изменения
|
||||||
|
simulation. **Determinism** -- одинаковый результат при одинаковом initial
|
||||||
|
state, input, времени и порядке событий.
|
||||||
|
|
||||||
|
**Authority** -- subsystem или network peer, которому разрешено окончательно
|
||||||
|
менять состояние объекта. **Mirror object** -- локальное представление объекта,
|
||||||
|
authority которого находится у другого player.
|
||||||
|
|
||||||
|
## Геометрия и рендеринг
|
||||||
|
|
||||||
|
**Vertex** -- вершина geometry. **Index** -- номер вершины. **Triangle** --
|
||||||
|
примитив из трёх индексов.
|
||||||
|
|
||||||
|
**Node** -- элемент hierarchy модели со своим local transform. **Slot** в MSH
|
||||||
|
-- выбранная геометрическая группа для комбинации node, LOD и group. **Batch**
|
||||||
|
-- непрерывный индексный диапазон с material slot и render state.
|
||||||
|
|
||||||
|
**Transform** переводит данные между coordinate spaces. **Matrix** задаёт
|
||||||
|
линейное преобразование и translation. Порядок умножения matrices является
|
||||||
|
частью контракта.
|
||||||
|
|
||||||
|
**Bounds** -- упрощённый объём для быстрых тестов. **AABB** -- min/max по осям.
|
||||||
|
**Bounding sphere** -- center и radius.
|
||||||
|
|
||||||
|
**Renderer** преобразует подготовленную сцену в изображение. **Backend** --
|
||||||
|
реализация поверх конкретного API или устройства.
|
||||||
|
|
||||||
|
**Draw call** -- команда нарисовать диапазон primitives. **Indexed draw**
|
||||||
|
использует index buffer и base vertex.
|
||||||
|
|
||||||
|
**Material phase** -- одно временное состояние анимированного материала.
|
||||||
|
**Texture** -- двумерный массив texels. **Mip chain** -- последовательность
|
||||||
|
уменьшенных уровней texture. **Atlas** -- texture с несколькими под-
|
||||||
|
изображениями.
|
||||||
|
|
||||||
|
**Fixed-function pipeline** -- старый graphics pipeline, где приложение
|
||||||
|
выбирает predefined transform, lighting, texture-stage и blend states вместо
|
||||||
|
пользовательских shaders.
|
||||||
|
|
||||||
|
**Depth test**, **culling**, **alpha test** и **blending** -- render states,
|
||||||
|
которые влияют на порядок и видимость fragments.
|
||||||
|
|
||||||
|
**Pixel parity** -- совпадение конечного изображения при фиксированных camera,
|
||||||
|
time, seed, resolution и device profile.
|
||||||
|
|
||||||
|
## Навигация, звук и сеть
|
||||||
|
|
||||||
|
**Areal** -- логическая область карты с границей, class/flags и связями с
|
||||||
|
соседями. **Areal graph** -- граф областей и переходов. **Cell grid** --
|
||||||
|
пространственный индекс для быстрых candidate queries.
|
||||||
|
|
||||||
|
**Pathfinding** -- поиск маршрута по graph. **Corridor** -- локальная полоса,
|
||||||
|
построенная из последовательности areals. **Local steering** корректирует
|
||||||
|
ближайший шаг внутри corridor.
|
||||||
|
|
||||||
|
**Collision proxy** -- упрощённое представление объекта для столкновений.
|
||||||
|
**Broad phase** быстро находит потенциальные пары. **Narrow phase** выполняет
|
||||||
|
точную проверку и вычисляет contact.
|
||||||
|
|
||||||
|
**Sample** -- декодированные звуковые данные. **Source** -- конкретный
|
||||||
|
экземпляр воспроизведения с position, gain, loop state и временем. **Listener**
|
||||||
|
-- положение и ориентация слушателя для 3D spatialization.
|
||||||
|
|
||||||
|
**Transport** -- механизм доставки bytes между peers. **Protocol** -- framing,
|
||||||
|
message types, порядок и правила подтверждения. **Wire compatibility** --
|
||||||
|
способность обмениваться данными с оригинальным клиентом.
|
||||||
|
|
||||||
|
**Serialization** -- преобразование typed state в byte sequence. **Framing** --
|
||||||
|
способ отделить одно сообщение от следующего. **Reliable delivery** гарантирует
|
||||||
|
доставку/порядок в пределах выбранной модели; **unreliable delivery** допускает
|
||||||
|
потери ради задержки.
|
||||||
|
|
||||||
|
**Player ID** транспорта и **game player number** -- разные идентичности.
|
||||||
|
**Ownership transfer** меняет authority объекта. **Replication** передаёт
|
||||||
|
состояние или события remote mirrors.
|
||||||
@@ -0,0 +1,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
@@ -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
|
||||||
|
сохранение.
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# WEAR и MAT0
|
||||||
|
|
||||||
|
MSH batch хранит только `material_index`. WEAR переводит этот индекс в имя
|
||||||
|
материала, а MAT0 по этому имени описывает phases, parameters и texture
|
||||||
|
references.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Batch20.material_index
|
||||||
|
-> WEAR row
|
||||||
|
-> MAT0 entry
|
||||||
|
-> active phase
|
||||||
|
-> textureName
|
||||||
|
```
|
||||||
|
|
||||||
|
## WEAR
|
||||||
|
|
||||||
|
WEAR -- текстовый ресурс type ID `0x52414557`, обычно `*.wea` рядом с моделью.
|
||||||
|
|
||||||
|
```text
|
||||||
|
<wearCount>
|
||||||
|
<legacyId> <materialName>
|
||||||
|
...
|
||||||
|
|
||||||
|
[empty line]
|
||||||
|
[LIGHTMAPS
|
||||||
|
<lightmapCount>
|
||||||
|
<legacyId> <lightmapName>
|
||||||
|
...]
|
||||||
|
```
|
||||||
|
|
||||||
|
`legacyId` сохраняется, но выбор выполняется по позиции строки и имени. Между
|
||||||
|
основной таблицей и `LIGHTMAPS` нужен пустой разделитель.
|
||||||
|
|
||||||
|
## MAT0
|
||||||
|
|
||||||
|
MAT0 имеет type ID `0x3054414D`, обычно расположен в `Material.lib`. `attr1`
|
||||||
|
содержит runtime flags, `attr2` -- версию payload.
|
||||||
|
|
||||||
|
```c
|
||||||
|
#pragma pack(push, 1)
|
||||||
|
struct Mat0PrefixV4Plus {
|
||||||
|
uint16_t phase_count;
|
||||||
|
uint16_t animation_block_count;
|
||||||
|
uint8_t metadata_a;
|
||||||
|
uint8_t metadata_b;
|
||||||
|
uint32_t metadata_c_raw;
|
||||||
|
uint32_t metadata_d_raw;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Phase34 {
|
||||||
|
uint8_t parameters[18];
|
||||||
|
char texture_name[16];
|
||||||
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
```
|
||||||
|
|
||||||
|
Versioned fields читаются только если версия их содержит. Для старых версий
|
||||||
|
используются runtime defaults, а raw values сохраняются.
|
||||||
|
|
||||||
|
## Fallback
|
||||||
|
|
||||||
|
Material resolve:
|
||||||
|
|
||||||
|
1. имя из WEAR;
|
||||||
|
2. `DEFAULT`;
|
||||||
|
3. entry с индексом 0.
|
||||||
|
|
||||||
|
Пустое texture name означает намеренно нетекстурированную поверхность. Lightmap
|
||||||
|
fallback отдельный: отсутствующий lightmap даёт slot `-1`.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# MSH
|
||||||
|
|
||||||
|
Файл `*.msh` является NRes-контейнером. Geometry, узлы, slots, batches,
|
||||||
|
animation и служебные streams лежат в entries с разными `type_id`.
|
||||||
|
|
||||||
|
## Entry map
|
||||||
|
|
||||||
|
```text
|
||||||
|
type 1 nodes and slot selection
|
||||||
|
type 2 header 0x8C + Slot68 records
|
||||||
|
type 3 positions float3
|
||||||
|
type 4 packed normals
|
||||||
|
type 5 packed UV0
|
||||||
|
type 6 index buffer u16
|
||||||
|
type 7 triangle descriptors
|
||||||
|
type 8 animation keys
|
||||||
|
type 9 service stream
|
||||||
|
type 10 strings and node names
|
||||||
|
type 13 Batch20 records
|
||||||
|
type 15 auxiliary stream
|
||||||
|
type 17 auxiliary data
|
||||||
|
type 18 rare stream
|
||||||
|
type 19 animation frame map
|
||||||
|
type 20 rare auxiliary table
|
||||||
|
```
|
||||||
|
|
||||||
|
Reader ищет entries по type, но сохраняет исходный порядок для roundtrip.
|
||||||
|
|
||||||
|
## Node and slot selection
|
||||||
|
|
||||||
|
Type 1 обычно состоит из records по 38 bytes:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct Node38 {
|
||||||
|
uint16_t hdr0;
|
||||||
|
uint16_t parent_or_link;
|
||||||
|
uint16_t anim_map_start;
|
||||||
|
uint16_t fallback_key;
|
||||||
|
uint16_t slot_index[15];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`slot_index[lod * 5 + group]` выбирает geometry slot. `0xFFFF` означает
|
||||||
|
отсутствие геометрии для комбинации LOD/group.
|
||||||
|
|
||||||
|
## Slot and batch
|
||||||
|
|
||||||
|
Type 2 содержит header `0x8C`, затем `Slot68`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct Slot68 {
|
||||||
|
uint16_t tri_start;
|
||||||
|
uint16_t tri_count;
|
||||||
|
uint16_t batch_start;
|
||||||
|
uint16_t batch_count;
|
||||||
|
float aabb_min[3];
|
||||||
|
float aabb_max[3];
|
||||||
|
float sphere_center[3];
|
||||||
|
float sphere_radius;
|
||||||
|
uint32_t opaque[5];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Type 13 задаёт draw ranges:
|
||||||
|
|
||||||
|
```c
|
||||||
|
#pragma pack(push, 1)
|
||||||
|
struct Batch20 {
|
||||||
|
uint16_t batch_flags;
|
||||||
|
uint16_t material_index;
|
||||||
|
uint16_t opaque4;
|
||||||
|
uint16_t opaque6;
|
||||||
|
uint16_t index_count;
|
||||||
|
uint32_t index_start;
|
||||||
|
uint16_t opaque14;
|
||||||
|
uint32_t base_vertex;
|
||||||
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
```
|
||||||
|
|
||||||
|
Index check выполняется как `base_vertex + index < vertex_count` для всего
|
||||||
|
используемого slice.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# NRes
|
||||||
|
|
||||||
|
`NRes` -- основной контейнер ресурсов Iron3D. Он используется как внешний
|
||||||
|
архив и как внутренний контейнер модели `*.msh`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Header: 16 bytes]
|
||||||
|
[Data region: payload with alignment]
|
||||||
|
[Directory: entry_count * 64 bytes]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Header
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct NResHeader16 {
|
||||||
|
char magic[4]; // "NRes"
|
||||||
|
uint32_t version; // 0x00000100
|
||||||
|
int32_t entry_count; // >= 0
|
||||||
|
uint32_t total_size; // equals file size
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`directory_offset = total_size - entry_count * 64`. Reader проверяет отсутствие
|
||||||
|
переполнений, `directory_offset >= 16` и точное окончание каталога на
|
||||||
|
`total_size`.
|
||||||
|
|
||||||
|
## Entry
|
||||||
|
|
||||||
|
```c
|
||||||
|
#pragma pack(push, 1)
|
||||||
|
struct NResEntry64 {
|
||||||
|
uint32_t type_id;
|
||||||
|
uint32_t attr1;
|
||||||
|
uint32_t attr2;
|
||||||
|
uint32_t size;
|
||||||
|
uint32_t attr3;
|
||||||
|
char name[36];
|
||||||
|
uint32_t data_offset;
|
||||||
|
uint32_t sort_index;
|
||||||
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
```
|
||||||
|
|
||||||
|
Имя содержит bounded C-string до 35 полезных bytes. `sort_index` задаёт
|
||||||
|
отображение из sorted position в original entry index. В строгом режиме все
|
||||||
|
`sort_index` образуют перестановку `0..N-1`.
|
||||||
|
|
||||||
|
## Data region
|
||||||
|
|
||||||
|
Payload каждой записи лежит после header и до начала каталога. Игровые архивы
|
||||||
|
выравнивают следующий payload до 8 bytes нулями, но reader не должен требовать
|
||||||
|
плотного покрытия data region.
|
||||||
|
|
||||||
|
Различаются:
|
||||||
|
|
||||||
|
- active payload -- диапазон, на который указывает entry;
|
||||||
|
- gap/padding -- bytes между активными диапазонами;
|
||||||
|
- unindexed preserved region -- произвольные bytes, не принадлежащие entry.
|
||||||
|
|
||||||
|
Lossless editor сохраняет все три категории. Compact writer может исключить
|
||||||
|
unindexed regions только при явной операции repack.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Render frame
|
||||||
|
|
||||||
|
Кадр является последней стадией цикла, а не самостоятельной функцией renderer-а.
|
||||||
|
До draw calls уже накоплен input, рассчитан tick, применены отложенные операции,
|
||||||
|
выбрана камера и обновлён 3D sound listener.
|
||||||
|
|
||||||
|
## Frame skeleton
|
||||||
|
|
||||||
|
```text
|
||||||
|
system messages and input
|
||||||
|
-> simulation calculation
|
||||||
|
-> deferred object operations
|
||||||
|
-> animation and transforms
|
||||||
|
-> camera and sound listener
|
||||||
|
-> visibility and render queues
|
||||||
|
-> materials and draw passes
|
||||||
|
-> renderer completion
|
||||||
|
-> end-of-render callbacks and UI
|
||||||
|
```
|
||||||
|
|
||||||
|
В `World3D::stdRenderGame` доказан крупный порядок: camera передаётся Terrain,
|
||||||
|
настраиваются viewport/matrices, вызываются renderer boundary slots,
|
||||||
|
устанавливается `in_render`, выполняется traversal мира, закрывается world/shade
|
||||||
|
pass, вызывается renderer completion, снимается `in_render`, рассылается
|
||||||
|
end-of-render.
|
||||||
|
|
||||||
|
## Draw item
|
||||||
|
|
||||||
|
Подготовленный draw item содержит:
|
||||||
|
|
||||||
|
- node world matrix;
|
||||||
|
- batch flags and index range;
|
||||||
|
- WEAR material handle;
|
||||||
|
- MAT0 active phase and coefficients;
|
||||||
|
- texture handle;
|
||||||
|
- optional lightmap handle;
|
||||||
|
- render phase and sorting key;
|
||||||
|
- legacy pipeline state.
|
||||||
|
|
||||||
|
Подготовленный item должен ссылаться на immutable данные кадра. Изменение phase
|
||||||
|
или texture cache посреди прохода не должно менять уже собранную очередь.
|
||||||
|
|
||||||
|
## Parity risks
|
||||||
|
|
||||||
|
- x87 precision and rounding;
|
||||||
|
- scalar/SIMD `g_FastProc` differences;
|
||||||
|
- object, batch and transparent primitive order;
|
||||||
|
- depth, cull, alpha test and blend transitions;
|
||||||
|
- mip-skip, palette and Page coordinates;
|
||||||
|
- material fallback and phase selection;
|
||||||
|
- RNG sequence for FX and atmosphere;
|
||||||
|
- device capability fallback;
|
||||||
|
- simulation time quantization.
|
||||||
|
|
||||||
|
Для отладки нужен deterministic frame capture: camera state, visible object IDs,
|
||||||
|
draw-item list, pipeline keys, matrices и hashes промежуточных buffers.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# RsLi
|
||||||
|
|
||||||
|
`RsLi` -- библиотечный архив Iron3D с каталогом в начале файла и payloads после
|
||||||
|
него.
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Header: 32 bytes]
|
||||||
|
[Entry table: entry_count * 32 bytes]
|
||||||
|
[Payloads]
|
||||||
|
[optional trailer]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Header fields
|
||||||
|
|
||||||
|
```text
|
||||||
|
+0x00 char[2] "NL"
|
||||||
|
+0x02 u8 reserved
|
||||||
|
+0x03 u8 version = 1
|
||||||
|
+0x04 i16 entry_count
|
||||||
|
+0x0E u16 presorted_flag = 0xABBA
|
||||||
|
+0x14 u32 xor_seed
|
||||||
|
```
|
||||||
|
|
||||||
|
Остальные bytes сохраняются без нормализации.
|
||||||
|
|
||||||
|
## Entry
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct RsLiEntry32 {
|
||||||
|
char name[12];
|
||||||
|
uint8_t service[4];
|
||||||
|
int16_t flags;
|
||||||
|
int16_t sort_to_original;
|
||||||
|
uint32_t unpacked_size;
|
||||||
|
uint32_t data_offset_raw;
|
||||||
|
uint32_t packed_size;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Имя обычно хранится в uppercase ASCII. `sort_to_original` связывает sorted
|
||||||
|
position с исходной записью.
|
||||||
|
|
||||||
|
## Table transform
|
||||||
|
|
||||||
|
Entry table проходит обратимое потоковое XOR-преобразование. Начальное
|
||||||
|
состояние берётся из младших 16 bits `xor_seed` и продолжается через всю
|
||||||
|
таблицу, не сбрасываясь на границе записи.
|
||||||
|
|
||||||
|
## Storage methods
|
||||||
|
|
||||||
|
```text
|
||||||
|
0x000 raw block
|
||||||
|
0x020 byte transform only
|
||||||
|
0x040 LZSS
|
||||||
|
0x060 transform + LZSS
|
||||||
|
0x080 adaptive Huffman + LZSS
|
||||||
|
0x0A0 transform + adaptive Huffman + LZSS
|
||||||
|
0x100 raw Deflate
|
||||||
|
```
|
||||||
|
|
||||||
|
После любого пути должно получиться ровно `unpacked_size` bytes. Методы
|
||||||
|
`0x080` и `0x0A0` подтверждены decoder-кодом, но не живыми payload демоверсии
|
||||||
|
или обеих частей.
|
||||||
|
|
||||||
|
## Compatibility quirk
|
||||||
|
|
||||||
|
`sprites.lib::INTERF8.TEX` объявляет Deflate range на один byte дальше EOF.
|
||||||
|
Совместимый reader допускает `packed_size - 1` только для этого именованного
|
||||||
|
случая. Строгий режим сообщает `deflate_eof_plus_one`.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Texm
|
||||||
|
|
||||||
|
`Texm` -- основной формат изображений Iron3D. Payload содержит header,
|
||||||
|
необязательную палитру, mip chain и иногда `Page` chunk.
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct TexmHeader32 {
|
||||||
|
uint32_t magic; // 'Texm'
|
||||||
|
uint32_t width;
|
||||||
|
uint32_t height;
|
||||||
|
uint32_t mip_count;
|
||||||
|
uint32_t flags4;
|
||||||
|
uint32_t flags5;
|
||||||
|
uint32_t unknown6;
|
||||||
|
uint32_t format;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pixel formats
|
||||||
|
|
||||||
|
```text
|
||||||
|
0 Indexed8 + palette 256 * 4 bytes
|
||||||
|
565 R5 G6 B5
|
||||||
|
556 R5 G5 B6
|
||||||
|
4444 A4 R4 G4 B4
|
||||||
|
88 L8 A8
|
||||||
|
888 RGB8 in four-byte element
|
||||||
|
8888 A8 R8 G8 B8
|
||||||
|
```
|
||||||
|
|
||||||
|
Короткие каналы расширяются до 8 bits повторением значимых bits. Для 888
|
||||||
|
служебный четвёртый byte сохраняется при roundtrip.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
TexmHeader32
|
||||||
|
[palette 1024 bytes, only for format 0]
|
||||||
|
level 0 pixels
|
||||||
|
level 1 pixels
|
||||||
|
...
|
||||||
|
level mip_count-1 pixels
|
||||||
|
[optional Page chunk]
|
||||||
|
```
|
||||||
|
|
||||||
|
Размер mip level вычисляется через `max(1, width >> i)` и
|
||||||
|
`max(1, height >> i)`. Parser суммирует размеры с проверкой переполнения до
|
||||||
|
чтения данных.
|
||||||
|
|
||||||
|
## Page chunk
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct PageHeader8 {
|
||||||
|
uint32_t magic; // 'Page'
|
||||||
|
uint32_t rect_count;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PageRect8 {
|
||||||
|
int16_t x;
|
||||||
|
int16_t width;
|
||||||
|
int16_t y;
|
||||||
|
int16_t height;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Chunk обязан иметь размер `8 + rect_count * 8`. Rectangles находятся в pixel
|
||||||
|
space базового mip и масштабируются после mip-skip.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# TMA
|
||||||
|
|
||||||
|
`data.tma` -- основное описание расстановки и логической конфигурации миссии.
|
||||||
|
Файл перечисляет paths, clans, objects, свойства, ссылку на ландшафт и extras.
|
||||||
|
|
||||||
|
## String primitive
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct LpString {
|
||||||
|
uint32_t byte_length;
|
||||||
|
uint8_t bytes[byte_length];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Reader продвигается ровно на `4 + byte_length`. Завершающий NUL не является
|
||||||
|
обязательной частью framing. Для человекочитаемого вида используется legacy
|
||||||
|
ANSI/CP1251 view, но исходные bytes сохраняются.
|
||||||
|
|
||||||
|
## Top level
|
||||||
|
|
||||||
|
```text
|
||||||
|
u32 format_version
|
||||||
|
u32 path_count
|
||||||
|
PathRecord paths[path_count]
|
||||||
|
u32 clan_section_version
|
||||||
|
u32 clan_count
|
||||||
|
ClanRecord clans[clan_count]
|
||||||
|
u32 object_section_version
|
||||||
|
u32 object_count
|
||||||
|
PlacedObject objects[object_count]
|
||||||
|
LpString land_path
|
||||||
|
u32 mission_flag
|
||||||
|
LpString description_raw
|
||||||
|
u32 extra_section_version
|
||||||
|
u32 extra_count
|
||||||
|
ExtraRecord28 extras[extra_count]
|
||||||
|
```
|
||||||
|
|
||||||
|
Все 60 TMA Частей 1 и 2 проходят parser до точного EOF. Версии стабильны:
|
||||||
|
верхний уровень `1`, clan section `6`, object section `10`, property schema
|
||||||
|
`1`, trailing section `1`.
|
||||||
|
|
||||||
|
## PlacedObject
|
||||||
|
|
||||||
|
```text
|
||||||
|
u32 raw_kind
|
||||||
|
u32 class_or_flags
|
||||||
|
LpString resource_name
|
||||||
|
u32 raw_after_resource
|
||||||
|
u32 identity_or_clan_raw
|
||||||
|
f32 position[3]
|
||||||
|
f32 orientation[3]
|
||||||
|
f32 scale[3]
|
||||||
|
LpString instance_name
|
||||||
|
u32 raw_after_name
|
||||||
|
i32 link0
|
||||||
|
i32 link1
|
||||||
|
u32 property_schema_version
|
||||||
|
u32 property_count
|
||||||
|
Property properties[property_count]
|
||||||
|
```
|
||||||
|
|
||||||
|
`Property` состоит из четырёх raw `u32` и имени. Typed views разрешены только
|
||||||
|
для доказанных property names и consumers.
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# AI system
|
|
||||||
|
|
||||||
Документ описывает подсистему искусственного интеллекта: принятие решений, pathfinding и стратегическое поведение противников.
|
|
||||||
|
|
||||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ai.dll`.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# ArealMap
|
|
||||||
|
|
||||||
Документ описывает формат и структуру карты мира: зоны/сектора, координаты, размещение объектов и связь с terrain и миссиями.
|
|
||||||
|
|
||||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ArealMap.dll`.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Behavior system
|
|
||||||
|
|
||||||
Документ описывает поведенческую логику юнитов: state machine/behavior-паттерны, взаимодействия и базовые правила боевого поведения.
|
|
||||||
|
|
||||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Behavior.dll`.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Control system
|
|
||||||
|
|
||||||
Документ описывает подсистему управления: mapping ввода (клавиатура, мышь, геймпад), обработку событий и буферизацию команд.
|
|
||||||
|
|
||||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Control.dll`.
|
|
||||||
@@ -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`).
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Missions
|
|
||||||
|
|
||||||
Документ описывает формат миссий и сценариев: начальное состояние, триггеры и связь миссий с картой мира.
|
|
||||||
|
|
||||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `MisLoad.dll`.
|
|
||||||
@@ -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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
@@ -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‑скининг по узлам** (нет per‑vertex 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 вершин глобального bounding‑hull (`vec3[8]`) |
|
|
||||||
| `0x60..0x6F` | `0x10` | `float[4]` | Глобальная bounding‑sphere: `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** — axis‑aligned 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 single‑precision).
|
|
||||||
**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, дополнительные данные для эффектов/деформаций.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
# 3D implementation notes
|
|
||||||
|
|
||||||
Контрольные заметки, сводки алгоритмов и остаточные семантические вопросы по 3D-подсистемам.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5.1. Порядок байт
|
|
||||||
|
|
||||||
Все значения хранятся в **little‑endian** порядке (платформа x86/Win32).
|
|
||||||
|
|
||||||
## 5.2. Выравнивание
|
|
||||||
|
|
||||||
- **NRes‑ресурсы:** данные каждого ресурса внутри NRes‑архива выровнены по границе **8 байт** (0‑padding).
|
|
||||||
- **Внутренняя структура ресурсов:** таблицы 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 из референсного оригинального ассета той же структуры.
|
|
||||||
@@ -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 и не является форматом на диске.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Network system
|
|
||||||
|
|
||||||
Документ описывает сетевую подсистему: протокол обмена, синхронизацию состояния и сетевую архитектуру (client-server/P2P).
|
|
||||||
|
|
||||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Net.dll`.
|
|
||||||
@@ -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) // Диапазон: 0–4095
|
|
||||||
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). Диапазон: 257–286.
|
|
||||||
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` на последней записи файла.
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
# Runtime pipeline
|
|
||||||
|
|
||||||
Документ фиксирует runtime-поведение движка: кто кого вызывает в кадре, как проходят рендер, коллизия и подключение эффектов.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1.15. Алгоритм рендера модели (реконструкция)
|
|
||||||
|
|
||||||
```
|
|
||||||
Вход: model, instanceTransform, cameraFrustum
|
|
||||||
|
|
||||||
1. Определить current_lod ∈ {0, 1, 2} (по дистанции до камеры / настройкам).
|
|
||||||
|
|
||||||
2. Для каждого node (nodeIndex = 0 .. nodeCount−1):
|
|
||||||
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‑рендер‑пайплайн.
|
|
||||||
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Sound system
|
|
||||||
|
|
||||||
Документ описывает аудиоподсистему: форматы звуковых ресурсов, воспроизведение эффектов и голосов, а также интеграцию со звуковым API.
|
|
||||||
|
|
||||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга звуковых модулей движка.
|
|
||||||
@@ -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"`).
|
|
||||||
|
|
||||||
Ландшафт использует **микротекстуры** (micro‑texture mapping chunks) — маленькие повторяющиеся текстуры, тайлящиеся по поверхности.
|
|
||||||
|
|
||||||
## 4.3. Защита от пустых примитивов
|
|
||||||
|
|
||||||
Terrain.dll содержит проверки:
|
|
||||||
|
|
||||||
- `"Rendering empty primitive!"` — перед первым вызовом отрисовки.
|
|
||||||
- `"Rendering empty primitive2!"` — перед вторым вызовом отрисовки.
|
|
||||||
|
|
||||||
Это подтверждает многопроходный рендер (как минимум 2 прохода для ландшафта).
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# UI system
|
|
||||||
|
|
||||||
Документ описывает интерфейсную подсистему: ресурсы UI, шрифты, minimap, layout и обработку пользовательского ввода в интерфейсе.
|
|
||||||
|
|
||||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга UI-компонентов движка.
|
|
||||||
@@ -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. Что должна сделать реализация, если правило нарушено?
|
||||||
|
|
||||||
|
Если на один из вопросов нет ответа, утверждение остаётся согласованным выводом
|
||||||
|
или открытым вопросом, а не закрытым контрактом.
|
||||||
@@ -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.
|
||||||
@@ -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 без
|
||||||
|
потери.
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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
+56
-20
@@ -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 © 2023 — 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:
|
||||||
|
- media: "(prefers-color-scheme: light)"
|
||||||
|
scheme: default
|
||||||
|
primary: indigo
|
||||||
|
accent: deep orange
|
||||||
|
- media: "(prefers-color-scheme: dark)"
|
||||||
scheme: slate
|
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:
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
# Тестовые данные
|
||||||
|
|
||||||
|
Для тестирования на реальных ресурсах разместите в этом каталоге игровые каталоги.
|
||||||
|
|
||||||
|
Игровые файлы не включаются в репозиторий.
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
|
||||||
-201
@@ -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)`).
|
|
||||||
@@ -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())
|
|
||||||
@@ -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
@@ -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())
|
|
||||||
@@ -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())
|
|
||||||
Reference in New Issue
Block a user