Compare commits

8 Commits

Author SHA1 Message Date
a281ffa32e feat: Enhance model and texture loading with improved error handling and new features
Some checks failed
Test / Lint (push) Failing after 1m10s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
- Introduced `LoadedModel` and `LoadedTexture` structs for better encapsulation of model and texture data.
- Added functions to load models and textures from archives, including support for resolving textures based on materials and wear entries.
- Implemented error handling for missing textures, materials, and wear entries.
- Updated the rendering pipeline to support texture loading and binding, including command-line arguments for texture customization.
- Enhanced the `texm` crate with new decoding capabilities for various pixel formats, including indexed textures.
- Added tests for texture decoding and loading to ensure reliability and correctness.
- Updated documentation to reflect changes in the material and texture resolution process.
2026-02-19 05:19:18 +04:00
18d4c6cf9f feat(render-parity): add deterministic frame comparison tool
- Introduced `render-parity` crate for comparing rendered frames against reference images.
- Added command-line options for specifying manifest and output directory.
- Implemented image comparison metrics: mean absolute difference, maximum absolute difference, and changed pixel ratio.
- Created a configuration file `cases.toml` for defining test cases with global defaults and specific parameters.
- Added functionality to capture frames from `render-demo` and save diff images on discrepancies.
- Updated documentation to include usage instructions and CI model for automated testing.
2026-02-19 05:02:26 +04:00
0e19660eb5 Refactor documentation structure and add new specifications
- Updated MSH documentation to reflect changes in material, wear, and texture specifications.
- Introduced new `render.md` file detailing the render pipeline process.
- Removed outdated sections from `runtime-pipeline.md` and redirected to `render.md`.
- Added detailed specifications for `Texm` texture format and `WEAR` wear table.
- Updated navigation in `mkdocs.yml` to align with new documentation structure.
2026-02-19 04:46:23 +04:00
8a69872576 Refactor code structure for improved readability and maintainability
All checks were successful
Test / Lint (push) Successful in 1m8s
Test / Test (push) Successful in 1m15s
2026-02-12 11:07:25 +00:00
aa68906a3d feat: добавить экспорт в формат OBJ для рендеринга террейна и опциональных полигонов 2026-02-12 10:36:15 +00:00
8bf3b7b209 feat: добавить 3D рендерер для террейна с поддержкой Land.msh и Land.map 2026-02-12 10:24:42 +00:00
669fb40a70 Add terrain map documentation validator
This commit introduces a new Python script, `terrain_map_doc_validator.py`, which validates terrain and map documentation assumptions against actual game data. The validator checks for the presence and correctness of various data chunks in the `Land.msh` and `Land.map` files, reporting any issues found during the validation process. It also generates a summary report of the validation results, including counts of errors and warnings, and statistics related to the map and mesh data.
2026-02-12 10:17:41 +00:00
9c0df3d299 feat: добавить скрипт для детерминированного аудита FXID "absolute parity" 2026-02-12 08:22:51 +00:00
43 changed files with 7076 additions and 2267 deletions

View File

@@ -25,3 +25,31 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- name: Cargo test
run: cargo test --workspace --all-features -- --nocapture
render-parity:
name: Render parity
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Install headless GL runtime
run: |
sudo apt-get update
sudo apt-get install -y xvfb libgl1-mesa-dri libgles2-mesa-dev mesa-utils
- name: Build render-demo binary
run: cargo build -p render-demo --features demo
- name: Run frame parity suite
run: |
xvfb-run -s "-screen 0 1280x720x24" cargo run -p render-parity -- \
--manifest parity/cases.toml \
--output-dir target/render-parity/current \
--demo-bin target/debug/parkan-render-demo \
--keep-going
- name: Upload parity artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: render-parity-artifacts
path: target/render-parity/current
if-no-files-found: ignore

View File

@@ -0,0 +1,7 @@
[package]
name = "msh-core"
version = "0.1.0"
edition = "2021"
[dependencies]
nres = { path = "../nres" }

14
crates/msh-core/README.md Normal file
View File

@@ -0,0 +1,14 @@
# msh-core
Парсер core-части формата `MSH`.
Покрывает:
- `Res1`, `Res2`, `Res3`, `Res6`, `Res13` (обязательные);
- `Res4`, `Res5`, `Res10` (опциональные);
- slot lookup по `node/lod/group`.
Тесты:
- прогон по всем `.msh` в `testdata`;
- синтетическая минимальная модель.

View File

@@ -0,0 +1,74 @@
use core::fmt;
#[derive(Debug)]
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 {}

392
crates/msh-core/src/lib.rs Normal file
View File

@@ -0,0 +1,392 @@
pub mod error;
use crate::error::Error;
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)?;
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 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 = String::from_utf8_lossy(text).to_string();
out.push(Some(decoded));
off = end;
}
Ok(out)
}
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;

View File

@@ -0,0 +1,296 @@
use super::*;
use nres::Archive;
use std::fs;
use std::path::{Path, PathBuf};
fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_files_recursive(&path, out);
} else if path.is_file() {
out.push(path);
}
}
}
fn nres_test_files() -> Vec<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata");
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")
}
#[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() {
// Nested NRes with required resources only.
let mut payload = Vec::new();
payload.extend_from_slice(b"NRes");
payload.extend_from_slice(&0x100u32.to_le_bytes());
payload.extend_from_slice(&5u32.to_le_bytes()); // entry_count
payload.extend_from_slice(&0u32.to_le_bytes()); // total_size placeholder
let mut resource_offsets = Vec::new();
let mut resource_sizes = Vec::new();
let mut resource_types = Vec::new();
let mut resource_attr3 = Vec::new();
let mut resource_names = Vec::new();
let add_resource = |payload: &mut Vec<u8>,
offsets: &mut Vec<u32>,
sizes: &mut Vec<u32>,
types: &mut Vec<u32>,
attr3: &mut Vec<u32>,
names: &mut Vec<String>,
kind: u32,
name: &str,
data: &[u8],
attr3_val: u32| {
offsets.push(u32::try_from(payload.len()).expect("offset overflow"));
payload.extend_from_slice(data);
while !payload.len().is_multiple_of(8) {
payload.push(0);
}
sizes.push(u32::try_from(data.len()).expect("size overflow"));
types.push(kind);
attr3.push(attr3_val);
names.push(name.to_string());
};
let node = {
let mut b = vec![0u8; 38];
// slot[0][0] = 0
b[8..10].copy_from_slice(&0u16.to_le_bytes());
for i in 1..15 {
let off = 8 + i * 2;
b[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes());
}
b
};
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(&0u16.to_le_bytes()); // batch_start
res2[0x8C + 6..0x8C + 8].copy_from_slice(&1u16.to_le_bytes()); // batch_count
let positions = [0f32, 0f32, 0f32, 1f32, 0f32, 0f32, 0f32, 1f32, 0f32]
.iter()
.flat_map(|v| v.to_le_bytes())
.collect::<Vec<_>>();
let indices = [0u16, 1, 2]
.iter()
.flat_map(|v| v.to_le_bytes())
.collect::<Vec<_>>();
let batch = {
let mut b = vec![0u8; 20];
b[0..2].copy_from_slice(&0u16.to_le_bytes());
b[2..4].copy_from_slice(&0u16.to_le_bytes());
b[8..10].copy_from_slice(&3u16.to_le_bytes()); // index_count
b[10..14].copy_from_slice(&0u32.to_le_bytes()); // index_start
b[16..20].copy_from_slice(&0u32.to_le_bytes()); // base_vertex
b
};
add_resource(
&mut payload,
&mut resource_offsets,
&mut resource_sizes,
&mut resource_types,
&mut resource_attr3,
&mut resource_names,
RES1_NODE_TABLE,
"Res1",
&node,
38,
);
add_resource(
&mut payload,
&mut resource_offsets,
&mut resource_sizes,
&mut resource_types,
&mut resource_attr3,
&mut resource_names,
RES2_SLOTS,
"Res2",
&res2,
68,
);
add_resource(
&mut payload,
&mut resource_offsets,
&mut resource_sizes,
&mut resource_types,
&mut resource_attr3,
&mut resource_names,
RES3_POSITIONS,
"Res3",
&positions,
12,
);
add_resource(
&mut payload,
&mut resource_offsets,
&mut resource_sizes,
&mut resource_types,
&mut resource_attr3,
&mut resource_names,
RES6_INDICES,
"Res6",
&indices,
2,
);
add_resource(
&mut payload,
&mut resource_offsets,
&mut resource_sizes,
&mut resource_types,
&mut resource_attr3,
&mut resource_names,
RES13_BATCHES,
"Res13",
&batch,
20,
);
let directory_offset = payload.len();
for i in 0..resource_types.len() {
payload.extend_from_slice(&resource_types[i].to_le_bytes());
payload.extend_from_slice(&1u32.to_le_bytes()); // attr1
payload.extend_from_slice(&0u32.to_le_bytes()); // attr2
payload.extend_from_slice(&resource_sizes[i].to_le_bytes());
payload.extend_from_slice(&resource_attr3[i].to_le_bytes());
let mut name_raw = [0u8; 36];
let bytes = resource_names[i].as_bytes();
name_raw[..bytes.len()].copy_from_slice(bytes);
payload.extend_from_slice(&name_raw);
payload.extend_from_slice(&resource_offsets[i].to_le_bytes());
payload.extend_from_slice(&(i as u32).to_le_bytes()); // sort index
}
let total_size = u32::try_from(payload.len()).expect("size overflow");
payload[12..16].copy_from_slice(&total_size.to_le_bytes());
assert_eq!(
directory_offset + resource_types.len() * 64,
payload.len(),
"synthetic nested NRes layout invalid"
);
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));
}

View File

@@ -0,0 +1,8 @@
[package]
name = "render-core"
version = "0.1.0"
edition = "2021"
[dependencies]
msh-core = { path = "../msh-core" }
nres = { path = "../nres" }

View File

@@ -0,0 +1,14 @@
# render-core
CPU-подготовка draw-данных для моделей `MSH`.
Покрывает:
- обход `node -> slot -> batch`;
- раскрытие индексов в triangle-list (`position + uv0`);
- расчёт bounds по вершинам.
Тесты:
- построение рендер-сеток на реальных `.msh` из `testdata`;
- unit-test bounds.

View File

@@ -0,0 +1,119 @@
use msh_core::Model;
#[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 batch_count: usize,
}
impl RenderMesh {
pub fn triangle_count(&self) -> usize {
self.vertices.len() / 3
}
}
/// Builds an expanded triangle list for a specific LOD/group pair.
///
/// The output is suitable for simple `glDrawArrays(GL_TRIANGLES, ...)` paths.
pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh {
let mut vertices = Vec::new();
let mut batch_count = 0usize;
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;
}
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 {
continue;
};
let Some(pos) = model.positions.get(final_idx) else {
continue;
};
let uv = uv0
.and_then(|uvs| uvs.get(final_idx))
.copied()
.map(|packed| [packed[0] as f32 / 1024.0, packed[1] as f32 / 1024.0])
.unwrap_or([0.0, 0.0]);
vertices.push(RenderVertex {
position: *pos,
uv0: uv,
});
}
batch_count += 1;
}
}
RenderMesh {
vertices,
batch_count,
}
}
pub fn compute_bounds(vertices: &[[f32; 3]]) -> Option<([f32; 3], [f32; 3])> {
let mut iter = vertices.iter();
let first = iter.next()?;
let mut min_v = *first;
let mut max_v = *first;
for v in iter {
for i in 0..3 {
if v[i] < min_v[i] {
min_v[i] = v[i];
}
if v[i] > max_v[i] {
max_v[i] = v[i];
}
}
}
Some((min_v, max_v))
}
pub fn compute_bounds_for_mesh(vertices: &[RenderVertex]) -> Option<([f32; 3], [f32; 3])> {
let mut iter = vertices.iter();
let first = iter.next()?;
let mut min_v = first.position;
let mut max_v = first.position;
for v in iter {
for i in 0..3 {
if v.position[i] < min_v[i] {
min_v[i] = v.position[i];
}
if v.position[i] > max_v[i] {
max_v[i] = v.position[i];
}
}
}
Some((min_v, max_v))
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,131 @@
use super::*;
use msh_core::parse_model_payload;
use nres::Archive;
use std::fs;
use std::path::{Path, PathBuf};
fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_files_recursive(&path, out);
} else if path.is_file() {
out.push(path);
}
}
}
fn nres_test_files() -> Vec<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata");
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.vertices.is_empty() {
meshes_non_empty += 1;
}
if compute_bounds_for_mesh(&mesh.vertices).is_some() {
bounds_non_empty += 1;
}
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]);
}

View File

@@ -0,0 +1,22 @@
[package]
name = "render-demo"
version = "0.1.0"
edition = "2021"
[features]
default = []
demo = ["dep:sdl2", "dep:glow", "dep:image"]
[dependencies]
msh-core = { path = "../msh-core" }
nres = { path = "../nres" }
render-core = { path = "../render-core" }
texm = { path = "../texm" }
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] }
glow = { version = "0.16", optional = true }
image = { version = "0.25", optional = true, default-features = false, features = ["png"] }
[[bin]]
name = "parkan-render-demo"
path = "src/main.rs"
required-features = ["demo"]

View File

@@ -0,0 +1,73 @@
# render-demo
Тестовый рендерер Parkan-моделей на Rust (`SDL2 + OpenGL ES 2.0`).
## Назначение
- Проверить, что `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
```
Параметры:
- `--archive` (обязательный): NRes-архив с `.msh` entry.
- `--model` (опционально): имя модели; если не задано, берётся первая `.msh`.
- `--lod` (опционально, default `0`).
- `--group` (опционально, default `0`).
- `--width`, `--height` (опционально, default `1280x720`).
- `--angle` (опционально): фиксированный угол поворота вокруг Y (в радианах).
- `--spin-rate` (опционально, default `0.35`): скорость вращения в интерактивном режиме.
- `--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 анимации).
- Вывод через `glDrawArrays(GL_TRIANGLES)` из расширенного triangle-list (позиции+UV).

View File

@@ -0,0 +1,4 @@
fn main() {
#[cfg(windows)]
println!("cargo:rustc-link-lib=advapi32");
}

View File

@@ -0,0 +1,527 @@
use msh_core::{parse_model_payload, Model};
use nres::{Archive, EntryRef};
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 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 = String::from_utf8_lossy(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 = String::from_utf8_lossy(&name_raw[..name_end])
.trim()
.to_string();
if !name.is_empty() {
return Ok(Some(name));
}
}
Ok(None)
}
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 std::fs;
use std::path::{Path, PathBuf};
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 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());
}
}

View File

@@ -0,0 +1,803 @@
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::path::{Path, PathBuf};
use std::time::Instant;
struct Args {
archive: PathBuf,
model: Option<String>,
lod: usize,
group: usize,
width: u32,
height: u32,
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,
}
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 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"));
}
}
"--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,
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]"
);
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.vertices.is_empty() {
return Err(format!(
"model has no renderable triangles for lod={} group={}",
args.lod, args.group
));
}
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 gl_attr = video.gl_attr();
gl_attr.set_context_profile(sdl2::video::GLProfile::GLES);
gl_attr.set_context_version(2, 0);
gl_attr.set_depth_size(24);
gl_attr.set_double_buffer(true);
}
let mut window_builder = video.window(
"Parkan Render Demo (SDL2 + OpenGL ES 2.0)",
args.width,
args.height,
);
window_builder.opengl();
if args.capture.is_some() {
window_builder.hidden();
} else {
window_builder.resizable();
}
let window = window_builder
.build()
.map_err(|err| format!("failed to create window: {err}"))?;
let gl_ctx = window
.gl_create_context()
.map_err(|err| format!("failed to create OpenGL context: {err}"))?;
window
.gl_make_current(&gl_ctx)
.map_err(|err| format!("failed to make GL context current: {err}"))?;
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 gl = unsafe {
glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _)
};
let program = unsafe { create_program(&gl)? };
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())? };
unsafe {
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
gl.buffer_data_u8_slice(
glow::ARRAY_BUFFER,
cast_slice_u8(&vertex_data),
glow::STATIC_DRAW,
);
gl.bind_buffer(glow::ARRAY_BUFFER, None);
}
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,
gpu_texture.as_ref(),
mesh.vertices.len(),
&args,
center,
camera_distance,
capture_path,
)
} else {
run_interactive(
&sdl,
&window,
&gl,
program,
u_mvp.as_ref(),
u_use_tex.as_ref(),
u_tex.as_ref(),
a_pos,
a_uv,
vbo,
gpu_texture.as_ref(),
mesh.vertices.len(),
&args,
center,
camera_distance,
)
};
unsafe {
if let Some(texture) = gpu_texture {
gl.delete_texture(texture.handle);
}
gl.delete_buffer(vbo);
gl.delete_program(program);
}
result
}
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,
texture: Option<&GpuTexture>,
vertex_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, center, camera_distance, angle);
unsafe {
draw_frame(
gl,
program,
u_mvp,
u_use_tex,
u_tex,
a_pos,
a_uv,
vbo,
texture,
vertex_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: &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,
texture: Option<&GpuTexture>,
vertex_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();
'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, center, camera_distance, angle);
unsafe {
draw_frame(
gl,
program,
u_mvp,
u_use_tex,
u_tex,
a_pos,
a_uv,
vbo,
texture,
vertex_count,
w,
h,
&mvp,
);
}
window.gl_swap_window();
}
Ok(())
}
fn compute_mvp(
width: u32,
height: u32,
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(60.0_f32.to_radians(), aspect, 0.01, camera_distance * 10.0);
let view = mat4_translation(0.0, 0.0, -camera_distance);
let center_shift = mat4_translation(-center[0], -center[1], -center[2]);
let rot = mat4_rotation_y(angle_rad);
let model_m = mat4_mul(&rot, &center_shift);
let vp = mat4_mul(&view, &model_m);
mat4_mul(&proj, &vp)
}
#[allow(clippy::too_many_arguments)]
unsafe fn draw_frame(
gl: &glow::Context,
program: glow::NativeProgram,
u_mvp: Option<&glow::NativeUniformLocation>,
u_use_tex: Option<&glow::NativeUniformLocation>,
u_tex: Option<&glow::NativeUniformLocation>,
a_pos: u32,
a_uv: u32,
vbo: glow::NativeBuffer,
texture: Option<&GpuTexture>,
vertex_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);
}
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
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_arrays(
glow::TRIANGLES,
0,
vertex_count.min(i32::MAX as usize) as i32,
);
gl.disable_vertex_attrib_array(a_uv);
gl.disable_vertex_attrib_array(a_pos);
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) -> Result<glow::NativeProgram, String> {
let vs_src = 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);
}
"#;
let fs_src = 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);
}
"#;
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 cast_slice_u8<T>(slice: &[T]) -> &[u8] {
unsafe { std::slice::from_raw_parts(slice.as_ptr() as *const u8, std::mem::size_of_val(slice)) }
}
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
}

View File

@@ -0,0 +1,9 @@
[package]
name = "render-parity"
version = "0.1.0"
edition = "2021"
[dependencies]
image = { version = "0.25", default-features = false, features = ["png"] }
serde = { version = "1", features = ["derive"] }
toml = "0.8"

View File

@@ -0,0 +1,16 @@
# render-parity
Deterministic frame-diff runner for `parkan-render-demo`.
Usage:
```bash
cargo run -p render-parity -- \
--manifest parity/cases.toml \
--output-dir target/render-parity/current
```
Options:
- `--demo-bin <path>`: use prebuilt `parkan-render-demo` binary instead of `cargo run`.
- `--keep-going`: continue all cases even after failures.

View File

@@ -0,0 +1,212 @@
use image::{ImageBuffer, Rgba, RgbaImage};
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ManifestMeta {
pub width: Option<u32>,
pub height: Option<u32>,
pub lod: Option<usize>,
pub group: Option<usize>,
pub angle: Option<f32>,
pub diff_threshold: Option<u8>,
pub max_mean_abs: Option<f32>,
pub max_changed_ratio: Option<f32>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CaseSpec {
pub id: String,
pub archive: String,
pub model: Option<String>,
pub reference: String,
pub width: Option<u32>,
pub height: Option<u32>,
pub lod: Option<usize>,
pub group: Option<usize>,
pub angle: Option<f32>,
pub diff_threshold: Option<u8>,
pub max_mean_abs: Option<f32>,
pub max_changed_ratio: Option<f32>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ParityManifest {
#[serde(default)]
pub meta: ManifestMeta,
#[serde(rename = "case", default)]
pub cases: Vec<CaseSpec>,
}
#[derive(Debug, Clone)]
pub struct DiffMetrics {
pub width: u32,
pub height: u32,
pub mean_abs: f32,
pub max_abs: u8,
pub changed_pixels: u64,
pub changed_ratio: f32,
}
pub fn compare_images(
reference: &RgbaImage,
actual: &RgbaImage,
diff_threshold: u8,
) -> Result<DiffMetrics, String> {
let (rw, rh) = reference.dimensions();
let (aw, ah) = actual.dimensions();
if rw != aw || rh != ah {
return Err(format!(
"image size mismatch: reference={}x{}, actual={}x{}",
rw, rh, aw, ah
));
}
let mut diff_sum = 0u64;
let mut max_abs = 0u8;
let mut changed_pixels = 0u64;
let pixel_count = u64::from(rw).saturating_mul(u64::from(rh));
for (ref_px, act_px) in reference.pixels().zip(actual.pixels()) {
let mut pixel_changed = false;
for chan in 0..3 {
let a = i16::from(ref_px[chan]);
let b = i16::from(act_px[chan]);
let diff = (a - b).unsigned_abs() as u8;
diff_sum = diff_sum.saturating_add(u64::from(diff));
if diff > max_abs {
max_abs = diff;
}
if diff > diff_threshold {
pixel_changed = true;
}
}
if pixel_changed {
changed_pixels = changed_pixels.saturating_add(1);
}
}
let channels = pixel_count.saturating_mul(3);
let mean_abs = if channels == 0 {
0.0
} else {
diff_sum as f32 / channels as f32
};
let changed_ratio = if pixel_count == 0 {
0.0
} else {
changed_pixels as f32 / pixel_count as f32
};
Ok(DiffMetrics {
width: rw,
height: rh,
mean_abs,
max_abs,
changed_pixels,
changed_ratio,
})
}
pub fn build_diff_image(reference: &RgbaImage, actual: &RgbaImage) -> Result<RgbaImage, String> {
let (rw, rh) = reference.dimensions();
let (aw, ah) = actual.dimensions();
if rw != aw || rh != ah {
return Err(format!(
"image size mismatch: reference={}x{}, actual={}x{}",
rw, rh, aw, ah
));
}
let mut out: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(rw, rh);
for (dst, (ref_px, act_px)) in out
.pixels_mut()
.zip(reference.pixels().zip(actual.pixels()))
{
let dr = (i16::from(ref_px[0]) - i16::from(act_px[0])).unsigned_abs() as u8;
let dg = (i16::from(ref_px[1]) - i16::from(act_px[1])).unsigned_abs() as u8;
let db = (i16::from(ref_px[2]) - i16::from(act_px[2])).unsigned_abs() as u8;
*dst = Rgba([dr, dg, db, 255]);
}
Ok(out)
}
pub fn evaluate_metrics(
metrics: &DiffMetrics,
max_mean_abs: f32,
max_changed_ratio: f32,
) -> Vec<String> {
let mut violations = Vec::new();
if metrics.mean_abs > max_mean_abs {
violations.push(format!(
"mean_abs {:.4} > allowed {:.4}",
metrics.mean_abs, max_mean_abs
));
}
if metrics.changed_ratio > max_changed_ratio {
violations.push(format!(
"changed_ratio {:.4}% > allowed {:.4}%",
metrics.changed_ratio * 100.0,
max_changed_ratio * 100.0
));
}
violations
}
#[cfg(test)]
mod tests {
use super::*;
fn solid(w: u32, h: u32, r: u8, g: u8, b: u8) -> RgbaImage {
let mut img = RgbaImage::new(w, h);
for px in img.pixels_mut() {
*px = Rgba([r, g, b, 255]);
}
img
}
#[test]
fn compare_identical_images() {
let ref_img = solid(4, 3, 10, 20, 30);
let act_img = solid(4, 3, 10, 20, 30);
let metrics = compare_images(&ref_img, &act_img, 2).expect("comparison must succeed");
assert_eq!(metrics.width, 4);
assert_eq!(metrics.height, 3);
assert_eq!(metrics.max_abs, 0);
assert_eq!(metrics.changed_pixels, 0);
assert_eq!(metrics.mean_abs, 0.0);
assert_eq!(metrics.changed_ratio, 0.0);
}
#[test]
fn compare_detects_changes_and_thresholds() {
let mut ref_img = solid(2, 2, 100, 100, 100);
let mut act_img = solid(2, 2, 100, 100, 100);
ref_img.put_pixel(1, 1, Rgba([120, 100, 100, 255]));
act_img.put_pixel(1, 1, Rgba([100, 100, 100, 255]));
let metrics = compare_images(&ref_img, &act_img, 5).expect("comparison must succeed");
assert_eq!(metrics.max_abs, 20);
assert_eq!(metrics.changed_pixels, 1);
assert!((metrics.changed_ratio - 0.25).abs() < 1e-6);
assert!(metrics.mean_abs > 0.0);
let violations = evaluate_metrics(&metrics, 2.0, 0.20);
assert_eq!(violations.len(), 1);
assert!(violations[0].contains("changed_ratio"));
}
#[test]
fn build_diff_image_returns_per_channel_abs_diff() {
let mut ref_img = solid(1, 1, 100, 150, 200);
let mut act_img = solid(1, 1, 90, 180, 170);
ref_img.put_pixel(0, 0, Rgba([100, 150, 200, 255]));
act_img.put_pixel(0, 0, Rgba([90, 180, 170, 255]));
let diff = build_diff_image(&ref_img, &act_img).expect("diff image must build");
let px = diff.get_pixel(0, 0);
assert_eq!(px[0], 10);
assert_eq!(px[1], 30);
assert_eq!(px[2], 30);
assert_eq!(px[3], 255);
}
}

View File

@@ -0,0 +1,405 @@
use image::RgbaImage;
use render_parity::{
build_diff_image, compare_images, evaluate_metrics, CaseSpec, ManifestMeta, ParityManifest,
};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
const DEFAULT_MANIFEST: &str = "parity/cases.toml";
const DEFAULT_OUTPUT_DIR: &str = "target/render-parity/current";
const DEFAULT_WIDTH: u32 = 1280;
const DEFAULT_HEIGHT: u32 = 720;
const DEFAULT_LOD: usize = 0;
const DEFAULT_GROUP: usize = 0;
const DEFAULT_ANGLE: f32 = 0.0;
const DEFAULT_DIFF_THRESHOLD: u8 = 8;
const DEFAULT_MAX_MEAN_ABS: f32 = 2.0;
const DEFAULT_MAX_CHANGED_RATIO: f32 = 0.01;
struct Args {
manifest: PathBuf,
output_dir: PathBuf,
demo_bin: Option<PathBuf>,
keep_going: bool,
}
#[derive(Debug, Clone)]
struct EffectiveCase {
id: String,
archive: PathBuf,
model: Option<String>,
reference: PathBuf,
width: u32,
height: u32,
lod: usize,
group: usize,
angle: f32,
diff_threshold: u8,
max_mean_abs: f32,
max_changed_ratio: f32,
}
fn main() {
let args = match parse_args() {
Ok(v) => v,
Err(err) => {
eprintln!("{err}");
print_help();
std::process::exit(2);
}
};
if let Err(err) = run(args) {
eprintln!("{err}");
std::process::exit(1);
}
}
fn parse_args() -> Result<Args, String> {
let mut manifest = PathBuf::from(DEFAULT_MANIFEST);
let mut output_dir = PathBuf::from(DEFAULT_OUTPUT_DIR);
let mut demo_bin = None;
let mut keep_going = false;
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
match arg.as_str() {
"--manifest" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --manifest"))?;
manifest = PathBuf::from(value);
}
"--output-dir" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --output-dir"))?;
output_dir = PathBuf::from(value);
}
"--demo-bin" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --demo-bin"))?;
demo_bin = Some(PathBuf::from(value));
}
"--keep-going" => {
keep_going = true;
}
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
other => {
return Err(format!("unknown argument: {other}"));
}
}
}
Ok(Args {
manifest,
output_dir,
demo_bin,
keep_going,
})
}
fn print_help() {
eprintln!(
"render-parity [--manifest <cases.toml>] [--output-dir <dir>] [--demo-bin <path>] [--keep-going]"
);
eprintln!(" --manifest path to parity manifest (default: {DEFAULT_MANIFEST})");
eprintln!(" --output-dir where current renders and diff images are written");
eprintln!(" --demo-bin prebuilt parkan-render-demo binary path");
eprintln!(" --keep-going continue all cases even after failures");
}
fn run(args: Args) -> Result<(), String> {
let workspace = workspace_root()?;
let manifest_path = resolve_path(&workspace, &args.manifest);
let output_dir = resolve_path(&workspace, &args.output_dir);
let demo_bin = args
.demo_bin
.as_ref()
.map(|path| resolve_path(&workspace, path));
let manifest_raw = fs::read_to_string(&manifest_path)
.map_err(|err| format!("failed to read manifest {}: {err}", manifest_path.display()))?;
let manifest: ParityManifest = toml::from_str(&manifest_raw).map_err(|err| {
format!(
"failed to parse manifest {}: {err}",
manifest_path.display()
)
})?;
if manifest.cases.is_empty() {
println!(
"render-parity: no cases in {} (nothing to validate)",
manifest_path.display()
);
return Ok(());
}
fs::create_dir_all(&output_dir).map_err(|err| {
format!(
"failed to create output directory {}: {err}",
output_dir.display()
)
})?;
let manifest_dir = manifest_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| workspace.clone());
let mut failed_cases = 0usize;
for case in &manifest.cases {
let effective = make_effective_case(&manifest.meta, case, &manifest_dir)?;
let case_file = output_dir.join(format!("{}.png", sanitize_case_id(&effective.id)));
let diff_file = output_dir
.join("diff")
.join(format!("{}.png", sanitize_case_id(&effective.id)));
let run_res = run_single_case(
&workspace, // ensure `cargo run` executes from workspace root
demo_bin.as_deref(),
&effective,
&case_file,
&diff_file,
);
match run_res {
Ok(()) => {}
Err(err) => {
failed_cases = failed_cases.saturating_add(1);
eprintln!("[FAIL] {}: {}", effective.id, err);
if !args.keep_going {
break;
}
}
}
}
if failed_cases > 0 {
return Err(format!(
"render-parity failed: {} case(s) did not match reference frames",
failed_cases
));
}
println!("render-parity: all cases passed");
Ok(())
}
fn run_single_case(
workspace: &Path,
demo_bin: Option<&Path>,
case: &EffectiveCase,
case_file: &Path,
diff_file: &Path,
) -> Result<(), String> {
run_render_capture(workspace, demo_bin, case, case_file)?;
let reference = load_rgba(&case.reference)?;
let actual = load_rgba(case_file)?;
let metrics = compare_images(&reference, &actual, case.diff_threshold)?;
let violations = evaluate_metrics(&metrics, case.max_mean_abs, case.max_changed_ratio);
if violations.is_empty() {
println!(
"[OK] {} mean_abs={:.4} changed={:.4}% max_abs={} ({}x{})",
case.id,
metrics.mean_abs,
metrics.changed_ratio * 100.0,
metrics.max_abs,
metrics.width,
metrics.height
);
return Ok(());
}
if let Some(parent) = diff_file.parent() {
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create diff output directory {}: {err}",
parent.display()
)
})?;
}
let diff = build_diff_image(&reference, &actual)?;
diff.save(diff_file)
.map_err(|err| format!("failed to save diff image {}: {err}", diff_file.display()))?;
let mut details = String::new();
for item in violations {
if !details.is_empty() {
details.push_str("; ");
}
details.push_str(&item);
}
Err(format!(
"{} | diff={} | mean_abs={:.4}, changed={:.4}% ({} px), max_abs={}",
details,
diff_file.display(),
metrics.mean_abs,
metrics.changed_ratio * 100.0,
metrics.changed_pixels,
metrics.max_abs
))
}
fn run_render_capture(
workspace: &Path,
demo_bin: Option<&Path>,
case: &EffectiveCase,
out_path: &Path,
) -> Result<(), String> {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create capture directory {}: {err}",
parent.display()
)
})?;
}
let mut cmd = if let Some(bin) = demo_bin {
Command::new(bin)
} else {
let mut command = Command::new("cargo");
command.args(["run", "-p", "render-demo", "--features", "demo", "--"]);
command
};
cmd.current_dir(workspace)
.arg("--archive")
.arg(&case.archive)
.arg("--lod")
.arg(case.lod.to_string())
.arg("--group")
.arg(case.group.to_string())
.arg("--width")
.arg(case.width.to_string())
.arg("--height")
.arg(case.height.to_string())
.arg("--angle")
.arg(case.angle.to_string())
.arg("--capture")
.arg(out_path);
if let Some(model) = case.model.as_deref() {
cmd.arg("--model").arg(model);
}
let output = cmd.output().map_err(|err| {
let mode = if demo_bin.is_some() {
"parkan-render-demo"
} else {
"cargo run -p render-demo"
};
format!("failed to execute {} for case {}: {err}", mode, case.id)
})?;
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"render command exited with status {:?}\nstdout:\n{}\nstderr:\n{}",
output.status.code(),
stdout,
stderr
));
}
Ok(())
}
fn load_rgba(path: &Path) -> Result<RgbaImage, String> {
image::open(path)
.map_err(|err| format!("failed to load image {}: {err}", path.display()))
.map(|img| img.to_rgba8())
}
fn make_effective_case(
meta: &ManifestMeta,
case: &CaseSpec,
manifest_dir: &Path,
) -> Result<EffectiveCase, String> {
let width = case.width.or(meta.width).unwrap_or(DEFAULT_WIDTH);
let height = case.height.or(meta.height).unwrap_or(DEFAULT_HEIGHT);
if width == 0 || height == 0 {
return Err(format!(
"case '{}' has invalid dimensions {}x{}",
case.id, width, height
));
}
let archive = resolve_path(manifest_dir, Path::new(&case.archive));
let reference = resolve_path(manifest_dir, Path::new(&case.reference));
if !archive.is_file() {
return Err(format!(
"case '{}' archive not found: {}",
case.id,
archive.display()
));
}
if !reference.is_file() {
return Err(format!(
"case '{}' reference frame not found: {}",
case.id,
reference.display()
));
}
Ok(EffectiveCase {
id: case.id.clone(),
archive,
model: case.model.clone(),
reference,
width,
height,
lod: case.lod.or(meta.lod).unwrap_or(DEFAULT_LOD),
group: case.group.or(meta.group).unwrap_or(DEFAULT_GROUP),
angle: case.angle.or(meta.angle).unwrap_or(DEFAULT_ANGLE),
diff_threshold: case
.diff_threshold
.or(meta.diff_threshold)
.unwrap_or(DEFAULT_DIFF_THRESHOLD),
max_mean_abs: case
.max_mean_abs
.or(meta.max_mean_abs)
.unwrap_or(DEFAULT_MAX_MEAN_ABS),
max_changed_ratio: case
.max_changed_ratio
.or(meta.max_changed_ratio)
.unwrap_or(DEFAULT_MAX_CHANGED_RATIO),
})
}
fn sanitize_case_id(id: &str) -> String {
id.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
fn workspace_root() -> Result<PathBuf, String> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.map_err(|err| format!("failed to resolve workspace root: {err}"))?;
Ok(root)
}
fn resolve_path(base: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
base.join(path)
}
}

7
crates/texm/Cargo.toml Normal file
View File

@@ -0,0 +1,7 @@
[package]
name = "texm"
version = "0.1.0"
edition = "2021"
[dependencies]
nres = { path = "../nres" }

15
crates/texm/README.md Normal file
View File

@@ -0,0 +1,15 @@
# texm
Парсер формата текстур `Texm`.
Покрывает:
- header (`width/height/mipCount/flags/format`);
- core size расчёт;
- optional `Page` chunk;
- строгую валидацию layout.
Тесты:
- прогон по реальным `Texm` из `testdata`;
- синтетические edge-cases (indexed + page, minimal rgba).

85
crates/texm/src/error.rs Normal file
View File

@@ -0,0 +1,85 @@
use core::fmt;
#[derive(Debug)]
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 {}

421
crates/texm/src/lib.rs Normal file
View File

@@ -0,0 +1,421 @@
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,
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.max(1) >> 1;
h = h.max(1) >> 1;
if w == 0 {
w = 1;
}
if h == 0 {
h = 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);
if poff + 3 >= 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;

195
crates/texm/src/tests.rs Normal file
View File

@@ -0,0 +1,195 @@
use super::*;
use nres::Archive;
use std::fs;
use std::path::{Path, PathBuf};
fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_files_recursive(&path, out);
} else if path.is_file() {
out.push(path);
}
}
}
fn nres_test_files() -> Vec<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata");
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 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 mut payload = Vec::new();
payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes());
payload.extend_from_slice(&1u32.to_le_bytes()); // width
payload.extend_from_slice(&1u32.to_le_bytes()); // height
payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count
payload.extend_from_slice(&0u32.to_le_bytes()); // flags4
payload.extend_from_slice(&0u32.to_le_bytes()); // flags5
payload.extend_from_slice(&0u32.to_le_bytes()); // unk6
payload.extend_from_slice(&8888u32.to_le_bytes()); // format
payload.extend_from_slice(&[1, 2, 3, 4]); // one pixel
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 mut payload = Vec::new();
payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes());
payload.extend_from_slice(&1u32.to_le_bytes()); // width
payload.extend_from_slice(&1u32.to_le_bytes()); // height
payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count
payload.extend_from_slice(&0u32.to_le_bytes()); // flags4
payload.extend_from_slice(&0u32.to_le_bytes()); // flags5
payload.extend_from_slice(&0u32.to_le_bytes()); // unk6
payload.extend_from_slice(&8888u32.to_le_bytes()); // format
payload.extend_from_slice(&[0x40, 0x11, 0x22, 0x33]); // A,R,G,B in little-endian order
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_parse_indexed_with_page_chunk() {
let mut payload = Vec::new();
payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes());
payload.extend_from_slice(&2u32.to_le_bytes()); // width
payload.extend_from_slice(&2u32.to_le_bytes()); // height
payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count
payload.extend_from_slice(&0u32.to_le_bytes()); // flags4
payload.extend_from_slice(&0u32.to_le_bytes()); // flags5
payload.extend_from_slice(&0u32.to_le_bytes()); // unk6
payload.extend_from_slice(&0u32.to_le_bytes()); // format indexed8
payload.extend_from_slice(&[0u8; 1024]); // palette
payload.extend_from_slice(&[1, 2, 3, 4]); // pixels
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() {
let mut payload = Vec::new();
payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes());
payload.extend_from_slice(&2u32.to_le_bytes()); // width
payload.extend_from_slice(&1u32.to_le_bytes()); // height
payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count
payload.extend_from_slice(&0u32.to_le_bytes()); // flags4
payload.extend_from_slice(&0u32.to_le_bytes()); // flags5
payload.extend_from_slice(&0u32.to_le_bytes()); // unk6
payload.extend_from_slice(&0u32.to_le_bytes()); // format indexed8
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
payload.extend_from_slice(&palette);
payload.extend_from_slice(&[1u8, 2u8]); // two pixels
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, 2);
assert_eq!(decoded.height, 1);
assert_eq!(decoded.rgba8, vec![10, 20, 30, 255, 40, 50, 60, 200]);
}

View File

@@ -1,89 +1,20 @@
# FXID
Документ фиксирует спецификацию ресурса эффекта `FXID` на уровне, достаточном для:
- 1:1 загрузки и исполнения в совместимом runtime;
- построения валидатора payload;
- создания lossless-конвертера (`binary -> IR -> binary`);
- создания редактора с безопасным редактированием полей.
`FXID` — бинарный формат эффекта в движке Parkan: Iron Strategy.
Эта страница задаёт контракт формата и исполнения на уровне, достаточном для 1:1 порта рендера/симуляции эффектов и для lossless-инструментов.
Связанный контейнер: [NRes / RsLi](nres.md).
---
## 1. Контейнер
## 1. Источники и статус восстановления
- Тип ресурса в `NRes`: `0x44495846` (`FXID`).
- Значения `attr1/attr2/attr3` в типовых игровых данных стабильны, но при редактуре их нужно сохранять как есть.
Спецификация восстановлена по:
- `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
## 2. Бинарный формат
Все значения little-endian.
### 3.1. Header (60 байт, `0x3C`)
### 2.1. Заголовок (60 байт)
```c
struct FxHeader60 {
@@ -105,94 +36,26 @@ struct FxHeader60 {
};
```
Командный поток начинается строго с `offset = 0x3C`.
Поток команд начинается строго с `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. Общий формат команды
### 2.2. Команда
Каждая команда:
- `uint32 cmd_word`;
- далее body фиксированного размера по opcode.
1. `uint32 cmd_word`
2. body фиксированного размера, зависящего от `opcode`
`cmd_word`:
Поля `cmd_word`:
- `opcode = cmd_word & 0xFF`;
- `enabled = (cmd_word >> 8) & 1`;
- `bits 9..31` в датасете нулевые, но их надо сохранять 1:1.
- `opcode = cmd_word & 0xFF`
- `enabled = (cmd_word >> 8) & 1`
- `bits 9..31` нужно сохранять 1:1
Выравнивания между командами нет.
### 4.2. Размеры
### 2.3. Размеры команд
| Opcode | Размер записи |
| Opcode | Размер |
|---:|---:|
| 1 | 224 |
| 2 | 148 |
@@ -205,630 +68,121 @@ Post-обработка после mode:
| 9 | 208 |
| 10 | 208 |
### 4.3. Opcode -> runtime-класс (vtable)
## 3. Смысл заголовка
| 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` |
- `cmd_count`: число команд в потоке.
- `time_mode`: способ вычисления текущего коэффициента эффекта.
- `duration_sec`: длительность (в рантайме переводится в миллисекунды).
- `phase_jitter`: амплитуда случайного фазового сдвига.
- `flags`: флаги поведения (видимость, альфа-модификаторы, режимы гейтинга).
- `settings_id`: индекс профиля/настроек эффекта.
- `rand_shift_*`: случайный пространственный сдвиг.
- `pivot_*`: локальная опора.
- `scale_*`: базовый масштаб инстанса эффекта.
### 4.4. Общий вызовной контракт команды
## 4. Флаги заголовка
После создания команды (`sub_10007650`):
Практически важные биты:
1. `cmd->enabled = cmd_word.bit8`.
2. `cmd->Init(fx_queue, fx_instance)` (`vfunc +4`).
3. команда добавляется в список инстанса.
- `0x0001`: случайный сдвиг фазы
- `0x0008`: случайный пространственный сдвиг (`rand_shift_*`)
- `0x0010`: ветки видимости/окклюзии
- `0x0020`: треугольный ремап альфы
- `0x0040`: инверсия исходного active-state
- `0x0080`, `0x0100`: фильтрация по времени суток
- `0x0200`: умножение альфы на нормализованное время жизни
- `0x0400`, `0x1000`: дополнительные биты состояния менеджера эффекта
- `0x0800`: дополнительный гейтинг
В runtime cycle:
Неизвестные биты должны сохраняться без изменений.
- `vfunc +8`: update/compute (bool);
- `vfunc +12`: emission/render callback;
- `vfunc +20`: toggle active;
- `vfunc +16`/`+24`: служебные функции (зависят от opcode).
## 5. `time_mode` (0..17)
---
База:
## 5. Загрузка FXID (engine-accurate)
- `tn = (now - start) / (end - start)`
- `prev = предыдущая вычисленная альфа`
`sub_10007650`:
Поддерживаемые семейства режимов:
```c
void FxLoad(FxInstance* fx, uint8_t* payload) {
FxHeader60* h = (FxHeader60*)payload;
- константный режим;
- линейный (`tn`), обратный (`1-tn`), циклический (`fract(tn)`);
- режимы от внешних параметров мира/очереди;
- режимы на основе норм векторов состояния;
- режимы с ограничением вниз/вверх относительно `prev`.
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);
- при `flags & 0x0200` применяется `alpha *= tn`;
- при `flags & 0x0020` применяется triangular remap.
Command* cmd = CreateByOpcode(op, ptr); // может вернуть null
if (cmd) {
cmd->enabled = (w >> 8) & 1;
## 6. Resource-ссылки внутри команд
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` присутствует ссылка вида:
Для 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
char archive[32];
char name[32];
};
```
Поведение loader'а:
Контракт:
- оба имени обязаны быть непустыми;
- кэширование по `(_strcmpi archive, _strcmpi name)`;
- загрузка/резолв через manager resource API.
- строки ASCII, нуль-терминированные;
- сравнение имён регистронезависимое;
- обычно:
- `opcode 2`: `sounds.lib` + `*.wav`
- остальные: `material.lib` + имя материала/эффекта.
Наблюдение по данным:
## 7. Runtime-контракт исполнения
- для `opcode 2`: обычно `sounds.lib` + `*.wav`;
- для остальных: обычно `material.lib` + material name.
На создании инстанса:
---
1. Заголовок копируется в runtime-состояние.
2. Вычисляется `end_time`.
3. Для каждой команды создаётся runtime-объект по `opcode`.
4. В объект копируется `enabled`.
5. Объект инициализируется контекстом эффекта.
## 8. Полная карта body по opcode (field-level)
На каждом кадре:
Смещения указаны от начала команды (включая `cmd_word`).
1. Вычисляется текущий коэффициент/альфа по `time_mode` и `flags`.
2. Выполняется update каждой команды.
3. Выполняется emit/render часть активных команд.
4. Применяются события Start/Stop/Restart.
### 8.1. Opcode 1 (`off_1001E78C`, size=224)
## 8. Строгий парсер (рекомендуемый)
Основные методы:
1. Проверить `len(payload) >= 60`.
2. Прочитать `cmd_count`.
3. Идти от `ptr = 0x3C`.
4. Для каждой команды:
- проверить `ptr + 4 <= len`;
- прочитать `opcode`;
- проверить, что `opcode` поддержан;
- проверить `ptr + size(opcode) <= len`;
- сдвинуть `ptr += size(opcode)`.
5. Проверить `ptr == len(payload)`.
- init: `sub_1000F4B0`;
- update: `sub_1000F6E0`;
- emit: `nullsub_2`;
- toggle: `sub_1000F490`.
## 9. Writer и редактор
```c
struct FxCmd01 {
uint32_t word; // +0
uint32_t mode; // +4 (enum, см. ниже)
float t_start; // +8
float t_end; // +12
Для lossless-совместимости:
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`.
- пересчитывать только `cmd_count` и размеры контейнера;
- сохранять порядок команд.
### 10.5. IR/JSON (рекомендуемая форма)
## 10. Что требуется для 1:1 переноса
```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": "..."
}
]
}
```
1. Полная поддержка opcode `1..10`.
2. Точный контракт вычисления `time_mode` и `flags`.
3. Точное поведение `ResourceRef64`.
4. Повторяемый RNG и одинаковая политика плавающей точки.
---
## 11. Статус валидации
## 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`).
- Формальные инварианты FXID зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`.
- В текущем рабочем окружении нет полного набора игровых архивов (`testdata` без payload), поэтому массовая повторная проверка корпуса здесь не выполнялась.

130
docs/specs/material.md Normal file
View File

@@ -0,0 +1,130 @@
# Material (`MAT0`)
`MAT0` описывает материал и его фазовую анимацию.
Связанные страницы:
- [Wear table (`WEAR`)](wear.md)
- [Texture (`Texm`)](texture.md)
- [Render pipeline](render.md)
## 1. Контейнер
- Тип ресурса: `0x3054414D` (`MAT0`).
- Обычно хранится в `Material.lib`.
- `attr1` используется как битовое поле runtime-флагов материала.
- `attr2` задаёт версию заголовка payload.
## 2. Бинарный layout
```c
struct Mat0Payload {
uint16_t phaseCount;
uint16_t animBlockCount; // должно быть < 20
// если attr2 >= 2
uint8_t metaA8;
uint8_t metaB8;
// если attr2 >= 3
uint32_t metaC32;
// если attr2 >= 4
uint32_t metaD32;
PhaseRecord34 phases[phaseCount];
AnimBlockRaw anim[animBlockCount];
};
```
Если `attr2 < 2`, используются runtime-значения по умолчанию:
- `metaA = 255`
- `metaB = 255`
- `metaC = 1.0f`
- `metaD = 0`
## 3. Фазы материала
```c
struct PhaseRecord34 {
uint8_t params[18];
char textureName[16];
};
```
В рантайме запись разворачивается в структуру ~76 байт:
- набор коэффициентов цвета/освещения/прозрачности;
- индекс слота текстуры;
- дополнительные целочисленные поля.
`textureName`:
- пустая строка -> фаза без текстуры (`texSlot = -1`);
- непустая строка -> загрузка текстуры по имени.
## 4. Анимационные блоки
```c
struct AnimBlockRaw {
uint32_t headerRaw; // mode = low 3 bits, interpMask = остальные
uint16_t keyCount;
KeyRaw keys[keyCount];
};
struct KeyRaw {
uint16_t k0;
uint16_t k1;
uint16_t k2; // opaque, сохранять 1:1
};
```
`k2` нельзя удалять или нормализовать: это часть бинарного контракта.
## 5. Выбор текущей фазы
Материал выбирает фазу по времени и по режиму анимации блока:
- loop;
- ping-pong;
- one-shot с clamp;
- random-offset.
При смешивании интерполируется только часть полей, остальные копируются из активной фазы.
Для 1:1 совместимости важно сохранить эту выборочную интерполяцию.
## 6. Загрузка и fallback
При запросе материала по имени:
1. Точный поиск по имени.
2. Если не найдено — fallback на `DEFAULT`.
3. Если `DEFAULT` отсутствует — используется запись с индексом `0`.
## 7. Атрибуты и флаги
Практически важные биты `attr1`:
- бит загрузки текстурной фазы с расширенными флагами;
- флаги аппаратного профиля;
- 4-битный режим (`nibbleMode`);
- дополнительный флаг material-поведения.
Неизвестные биты должны сохраняться без изменений.
## 8. Ограничения
- `animBlockCount < 20`
- `phaseCount` и фактический размер секции фаз должны совпадать
- `textureName` должен быть NUL-terminated и укладываться в 16 байт
## 9. Правила writer/editor
1. Сохранять `attr1/attr2/attr3`.
2. Не менять `metaA/B/C/D` без явного запроса.
3. Сохранять opaque-поля анимации (включая `k2`) 1:1.
4. Проверять выход за границы payload при парсинге.
## 10. Статус валидации
- Инварианты MAT0 зафиксированы в текущем toolchain проекта (`docs/specs` + `tools`).
- В этом окружении нет полного игрового корпуса, поэтому статистика по всем материалам не пересчитывалась.

View File

@@ -1,874 +1,8 @@
# Materials, WEAR, MAT0 и Texm
# Materials, WEAR, 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
```
- [Material (`MAT0`)](material.md)
- [Wear table (`WEAR`)](wear.md)
- [Texture (`Texm`)](texture.md)
- [Render pipeline](render.md)

View File

@@ -1,105 +1,112 @@
# MSH animation
Документ описывает анимационные ресурсы MSH: `Res8`, `Res19` и runtime-интерполяцию.
`MSH animation` описывает связку `Res8 + Res19` и runtime-правила сэмплирования/смешивания поз.
---
Связанные страницы:
## 1.13. Ресурсы анимации: Res8 и Res19
- [MSH core](msh-core.md)
- [Render pipeline](render.md)
- **Res8** — массив анимационных ключей фиксированного размера 24 байта.
- **Res19** — `uint16` mappingмассив «frame → keyIndex` (с per-node смещением).
## 1. Ресурсы анимации
### 1.13.1. Формат Res8 (ключ 24 байта)
### 1.1. `Res8` (пул ключей)
```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
float pos_x;
float pos_y;
float pos_z;
float time;
int16_t qx;
int16_t qy;
int16_t qz;
int16_t qw;
};
```
Декодирование quaternion-компонент:
Декодирование quaternion-компонент: `q = s16 / 32767.0`.
### 1.2. `Res19` (карта кадров)
```c
q = s16 * (1.0f / 32767.0f)
uint16_t map_words[]; // size/2 элементов
```
### 1.13.2. Формат Res19
`Res19.attr2` хранит глобальную длину таймлайна (число кадров).
Res19 читается как непрерывный массив `uint16`:
### 1.3. Связь с `Res1`
```c
uint16_t map[]; // размер = size(Res19)/2
```
Для каждого узла:
Per-node управление mapping'ом берётся из заголовка узла Res1:
- `anim_map_start` (`hdr2`) — начало блока в `Res19` или `0xFFFF`.
- `fallback_key` (`hdr3`) — индекс fallback-ключа в `Res8`.
- `node.hdr2` (`Res1 + 0x04`) = `mapStart` (`0xFFFF` => map отсутствует);
- `node.hdr3` (`Res1 + 0x06`) = `fallbackKeyIndex` и одновременно верхняя граница валидного `map`‑значения.
## 2. Сэмплирование узла
### 1.13.3. Выбор ключа для времени `t` (`sub_10012880`)
Вход: время `t`, текущий узел.
Выход: `quat(w,x,y,z)` и `pos(x,y,z)`.
1) Вычислить frameиндекс:
### 2.1. Индекс кадра
```c
frame = (int64)(t - 0.5f); // x87 FISTP-путь
```
Движок использует x87-совместимое округление для выражения `t - 0.5`.
Для 1:1 повторения нужно сохранить ту же политику плавающей точки.
Для строгой 1:1 эмуляции используйте именно поведение x87 `FISTP` (а не «упрощённый floor»), т.к. путь в оригинале опирается на FPU rounding mode.
### 2.2. Выбор key index
2) Проверка условий fallback:
1. Если кадр вне диапазона `frame_count` -> `fallback_key`.
2. Если `anim_map_start == 0xFFFF` -> `fallback_key`.
3. Иначе берётся `map_words[anim_map_start + frame]`:
- если значение `>= fallback_key`, тоже используется `fallback_key`;
- иначе используется значение из map.
- `frame >= model.animFrameCount` (`model+0x9C`, из `NResEntry(Res19).attr2`);
- `mapStart == 0xFFFF`;
- `map[mapStart + frame] >= fallbackKeyIndex`.
### 2.3. Интерполяция
Если любое условие истинно:
```c
keyIndex = fallbackKeyIndex;
```
Если выбран fallback, возвращается ровно этот ключ без интерполяции.
Иначе:
```c
keyIndex = map[mapStart + frame];
```
1. Берутся соседние ключи `k0` и `k1`.
2. Если `t` точно равен `k0.time` или `k1.time`, возвращается соответствующий ключ.
3. Иначе:
- `alpha = (t - k0.time) / (k1.time - k0.time)`
- `pos = lerp(k0.pos, k1.pos, alpha)`
- `quat = slerp_like(k0.quat, k1.quat, alpha)`
3) Сэмплирование:
Кватернион в runtime хранится в порядке `[w, x, y, z]`.
- `k0 = Res8[keyIndex]`
- `k1 = Res8[keyIndex + 1]` (для интерполяции сегмента)
## 3. Смешивание двух сэмплов
Пути:
При blending между позами A и B:
- если `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. Выбираются валидные стороны по `blend` и валидности времени.
2. Если активна одна сторона, берётся она.
3. Если активны обе:
- применяется shortest-path flip для `qB`;
- выполняется quaternion blend;
- позиция смешивается линейно.
### 1.13.4. Межкадровое смешивание (`sub_10012560`)
Матрица строится из quaternion, а translation подставляется отдельным шагом.
Функция смешивает два сэмпла (например, из двух animation time-позиций) с коэффициентом `blend`:
## 4. Каноника writer
1) получить два `(quat, pos)` через `sub_10012880`;
2) выполнить shortestpath коррекцию знака quaternion:
Рекомендуемые правила:
```c
if (|q0 + q1|^2 < |q0 - q1|^2) q1 = -q1;
```
1. Ключи узлов писать подряд в `Res8` в порядке узлов.
2. `fallback_key` узла указывает на последний ключ его трека.
3. Для узлов с map выделять блок длины `frame_count` в `Res19`.
4. Для статических узлов: `anim_map_start = 0xFFFF`, один ключ с `time=0`.
5. `Res8.attr1 = key_count`, `Res8.attr3 = 4`.
6. `Res19.attr1 = map_word_count`, `Res19.attr2 = frame_count`, `Res19.attr3 = 2`.
3) смешать quaternion (fastproc) и построить orientationматрицу;
4) translation писать отдельно как `lerp(pos0, pos1, blend)` в ячейки `m[3], m[7], m[11]`.
## 5. Валидация перед сохранением
### 1.13.5. Что хранится в `Res19.attr2`
- `Res8.size % 24 == 0`
- `Res19.size % 2 == 0`
- каждый `fallback_key < key_count`
- для узла с map: `anim_map_start + frame_count <= map_word_count`
- внутри трека времена ключей строго возрастают
При загрузке `sub_10015FD0` записывает `NResEntry(Res19).attr2` в `model+0x9C`.
Это поле используется как верхняя граница frameиндекса в п.1.13.3.
---
## 6. Статус валидации
- Форматные проверки включены в `tools/msh_doc_validator.py`.
- В текущем окружении полный игровой корпус MSH не подключен в `testdata`, поэтому массовый прогон здесь не выполнялся.

View File

@@ -1,492 +1,178 @@
# MSH core
Документ описывает core-часть формата MSH: геометрию, узлы, батчи, LOD и slot-матрицу.
`MSH core` описывает геометрию, слоты, батчи и базовые таблицы модели.
Документ покрывает контракт, необходимый для 1:1 воспроизведения рендера и коллизии.
Связанный формат контейнера: [NRes / RsLi](nres.md).
Связанные страницы:
---
- [MSH animation](msh-animation.md)
- [Material](material.md)
- [Texture (Texm)](texture.md)
- [Render pipeline](render.md)
- [NRes / RsLi](nres.md)
## 1.1. Общая архитектура
## 1. Общая модель
Модель состоит из набора именованных ресурсов внутри одного NResархива. Каждый ресурс идентифицируется **целочисленным типом** (`resource_type`), который передаётся API функции `niReadData` (vtableметод `+0x18`) через связку `niFind` (vtableметод `+0x0C`, `+0x20`).
MSH-модель хранится как `NRes`-контейнер.
Связь таблиц строится по `type`, а не по порядку записей.
Рендер‑модель использует **rigidскининг по узлам** (нет pervertex bone weights). Каждый batch геометрии привязан к одному узлу и рисуется с матрицей этого узла.
Базовый путь геометрии:
## 1.2. Общая структура файла модели
1. `Res1` выбирает slot по `(node, lod, group)`.
2. `Res2.slot` задаёт диапазоны треугольников и батчей.
3. `Res13` задаёт диапазон индексов и `baseVertex`.
4. `Res6` даёт `uint16` индексы.
5. `Res3/Res4/Res5` дают вершины, нормали и UV.
```
┌────────────────────────────────────┐
│ 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каталог
└────────────────────────────────────┘
```
## 2. Карта core-ресурсов
Ресурсы в квадратных скобках — **опциональные**. Загрузчик проверяет их наличие перед чтением (`niFindRes` возвращает `1` при отсутствии).
| Type | Ресурс | Обязательность | Stride / layout |
|---:|---|---|---|
| 1 | Node table | обязательный | обычно 38 байт |
| 2 | Header + slots | обязательный | `0x8C + n*68` |
| 3 | Positions | обязательный | 12 |
| 4 | Packed normals | обычно обязательный | 4 |
| 5 | Packed UV0 | обычно обязательный | 4 |
| 6 | Index buffer | обязательный | 2 |
| 7 | Tri descriptors | для коллизии/пикинга | 16 |
| 8 | Anim key pool | для анимированных | 24 |
| 10 | Node strings | опциональный | variable |
| 13 | Batch table | обязательный | 20 |
| 15 | Доп. stream | опциональный | 8 |
| 16 | Доп. stream | опциональный | 8 |
| 18 | Доп. stream | опциональный | 4 |
| 19 | Anim map | для анимированных | 2 |
| 20 | Доп. таблица | опциональный | variable |
## 1.3. Порядок загрузки ресурсов (из `sub_10015FD0` в AniMesh.dll)
## 3. Основные структуры
Функция `sub_10015FD0` выполняет инициализацию внутренней структуры модели размером **0xA4** (164 байта). Ниже приведён точный порядок загрузки и маппинг ресурсов на поля структуры:
| Шаг | Тип ресурса | Поле структуры | Описание |
|-----|-------------|----------------|-----------------------------------------|
| 1 | 1 | `+0x00` | Node table (Res1) |
| 2 | 2 | `+0x04` | Model header (Res2) |
| 3 | 3 | `+0x0C` | Vertex positions (Res3) |
| 4 | 4 | `+0x10` | Packed normals (Res4) |
| 5 | 5 | `+0x14` | Packed UV0 (Res5) |
| 6 | 10 (0x0A) | `+0x20` | String table (Res10) |
| 7 | 8 | `+0x18` | Keyframe / animation track data (Res8) |
| 8 | 19 (0x13) | `+0x1C` | Animation mapping (Res19) |
| 9 | 7 | `+0x24` | Triangle descriptors (Res7) |
| 10 | 13 (0x0D) | `+0x28` | Batch table (Res13) |
| 11 | 6 | `+0x2C` | Index buffer (Res6) |
| 12 | 15 (0x0F) | `+0x34` | Доп. vertex stream (Res15), опционально |
| 13 | 16 (0x10) | `+0x38` | Доп. vertex stream (Res16), опционально |
| 14 | 18 (0x12) | `+0x64` | Vertex color (Res18), опционально |
| 15 | 20 (0x14) | `+0x30` | Доп. таблица (Res20), опционально |
### Производные поля (вычисляются после загрузки)
| Поле | Формула | Описание |
|---------|-------------------------|------------------------------------------------------------------------------------------------|
| `+0x08` | `Res2_ptr + 0x8C` | Указатель на slot table (140 байт от начала Res2) |
| `+0x3C` | `= Res3_ptr` | Копия указателя positions (stream ptr) |
| `+0x40` | `= 0x0C` (12) | Stride позиций: `sizeof(float3)` |
| `+0x44` | `= Res4_ptr` | Копия указателя normals (stream ptr) |
| `+0x48` | `= 4` | Stride нормалей: 4 байта |
| `+0x4C` | `Res16_ptr` или `0` | Stream A Res16 (tangent) |
| `+0x50` | `= 8` если `+0x4C != 0` | Stride stream A (используется только при наличии Res16) |
| `+0x54` | `Res16_ptr + 4` или `0` | Stream B Res16 (bitangent) |
| `+0x58` | `= 8` если `+0x54 != 0` | Stride stream B (используется только при наличии Res16) |
| `+0x5C` | `= Res5_ptr` | Копия указателя UV0 (stream ptr) |
| `+0x60` | `= 4` | Stride UV0: 4 байта |
| `+0x68` | `= 4` или `0` | Stride Res18 (если найден) |
| `+0x8C` | `= Res15_ptr` | Копия указателя Res15 |
| `+0x90` | `= 8` | Stride Res15: 8 байт |
| `+0x94` | `= 0` | Зарезервировано/unk94: инициализируется нулём при загрузке; не является флагом Res18 |
| `+0x9C` | NRes entry Res19 `+8` | Метаданные из каталожной записи Res19 |
| `+0xA0` | NRes entry Res20 `+4` | Метаданные из каталожной записи Res20 (заполняется только если Res20 найден и открыт, иначе 0) |
**Примечание к метаданным:** поле `+0x9C` читается из каталожной записи NRes для ресурса 19 (смещение `+8` в записи каталога, т.е. `attribute_2`). Поле `+0xA0` — из каталожной записи для ресурса 20 (смещение `+4`, т.е. `attribute_1`) **только если Res20 найден и `niOpenRes` вернул ненулевой указатель**; иначе `+0xA0 = 0`. Индекс записи определяется как `entry_index * 64`, после чего считывается поле.
---
### 1.3.1. Ссылки на функции и паттерны вызовов (для проверки реверса)
- `AniMesh.dll!sub_10015FD0` — загрузка ресурсов модели через vtable интерфейса NRes:
- `niFindRes(type, ...)` вызывается через `call [vtable+0x20]`
- `niOpenRes(...)` / чтение указателя — через `call [vtable+0x18]`
- `AniMesh.dll!sub_10015FD0` выставляет производные поля (`Res2_ptr+0x8C`, stride'ы), обнуляет `model+0x94`, и при отсутствии Res16 обнуляет только указатели потоков (`+0x4C`, `+0x54`).
- `AniMesh.dll!sub_10004840` / `sub_10004870` / `sub_100048A0` — использование runtime mappingтаблицы (`+0x18`, индекс `boneId*4`) и таблицы указателей треков (`+0x08`) после построения анимационного объекта.
## 1.4. Ресурс Res2 — Model Header (140 байт) + Slot Table
Ресурс Res2 содержит:
```
┌───────────────────────────────────┐ Смещение 0
│ Model Header (140 байт = 0x8C) │
├───────────────────────────────────┤ Смещение 140 (0x8C)
│ Slot Table │
│ (slot_count × 68 байт) │
└───────────────────────────────────┘
```
### 1.4.1. Model Header (первые 140 байт)
Поле `Res2[0x00..0x8B]` используется как **35 float** (без внутренних таблиц/индексов). Это подтверждено прямыми копированиями в `AniMesh.dll!sub_1000A460`:
- `qmemcpy(this+0x54, Res2+0x00, 0x60)` — первые 24 float;
- копирование `Res2+0x60` размером `0x10` — ещё 4 float;
- `qmemcpy(this+0x134, Res2+0x70, 0x1C)` — ещё 7 float.
Итоговая раскладка:
| Диапазон | Размер | Тип | Семантика |
|--------------|--------|-------------|----------------------------------------------------------------------|
| `0x00..0x5F` | `0x60` | `float[24]` | 8 вершин глобального boundinghull (`vec3[8]`) |
| `0x60..0x6F` | `0x10` | `float[4]` | Глобальная boundingsphere: `center.xyz + radius` |
| `0x70..0x8B` | `0x1C` | `float[7]` | Глобальный «капсульный»/сегментный bound: `A.xyz`, `B.xyz`, `radius` |
Для рендера и broadphase движок использует как слотbounds (`Res2 slot`), так и этот глобальный набор bounds (в зависимости от контекста вызова/LOD и наличия слота).
### 1.4.2. Slot Table (массив записей по 68 байт)
Slot — ключевая структура, связывающая узел иерархии с конкретной геометрией для конкретного LOD и группы. Каждая запись — **68 байт** (0x44).
**Важно:** смещения в таблице ниже указаны в **десятичном формате** (байты). В скобках приведён hexэквивалент (например, 48 (0x30)).
| Смещение | Размер | Тип | Описание |
|-----------|--------|----------|-----------------------------------------------------|
| 0 | 2 | uint16 | `triStart` — индекс первого треугольника в Res7 |
| 2 | 2 | uint16 | `triCount` — длина диапазона треугольников (`Res7`) |
| 4 | 2 | uint16 | `batchStart` — индекс первого batch'а в Res13 |
| 6 | 2 | uint16 | `batchCount` — количество batch'ей |
| 8 | 4 | float | `aabbMin.x` |
| 12 | 4 | float | `aabbMin.y` |
| 16 | 4 | float | `aabbMin.z` |
| 20 | 4 | float | `aabbMax.x` |
| 24 | 4 | float | `aabbMax.y` |
| 28 | 4 | float | `aabbMax.z` |
| 32 | 4 | float | `sphereCenter.x` |
| 36 | 4 | float | `sphereCenter.y` |
| 40 | 4 | float | `sphereCenter.z` |
| 44 (0x2C) | 4 | float | `sphereRadius` |
| 48 (0x30) | 20 | 5×uint32 | Хвостовые поля: `unk30..unk40` (см. §1.4.2.1) |
**AABB** — axisaligned bounding box в локальных координатах узла.
**Bounding Sphere** — описанная сфера в локальных координатах узла.
#### 1.4.2.1. Точная семантика `triStart/triCount`
В `AniMesh.dll!sub_1000B2C0` слот считается «владельцем» треугольника `triId`, если:
### 3.1. `Res1` (узлы)
```c
triId >= slot.triStart && triId < slot.triStart + slot.triCount
```
Это прямое доказательство, что `slot +0x02` — именно **count диапазона**, а не флаги.
#### 1.4.2.2. Хвост слота (20 байт = 5×uint32)
Последние 20 байт записи слота трактуем как 5 последовательных 32битных значений (littleendian). Их назначение пока не подтверждено; для инструментов рекомендуется сохранять и восстанавливать их «как есть».
- `+48 (0x30)`: `unk30` (uint32)
- `+52 (0x34)`: `unk34` (uint32)
- `+56 (0x38)`: `unk38` (uint32)
- `+60 (0x3C)`: `unk3C` (uint32)
- `+64 (0x40)`: `unk40` (uint32)
Для culling при рендере: AABB/sphere трансформируются матрицей узла и инстанса. При неравномерном scale радиус сферы масштабируется по `max(scaleX, scaleY, scaleZ)` (подтверждено по коду).
---
### 1.4.3. Восстановление счётчиков элементов по размерам ресурсов (практика для инструментов)
Для toolchain надёжнее считать count'ы по размерам ресурсов (а не по дублирующим полям других таблиц). Это полностью совпадает с тем, как рантайм использует fixed stride'ы в `sub_10015FD0`.
Берите **unpacked_size** (или фактический размер распакованного блока) соответствующего ресурса и вычисляйте:
- `node_count` = `size(Res1) / 38`
- `vertex_count` = `size(Res3) / 12`
- `normals_count` = `size(Res4) / 4`
- `uv0_count` = `size(Res5) / 4`
- `index_count` = `size(Res6) / 2`
- `tri_count` = `index_count / 3` (если примитивы — список треугольников)
- `tri_desc_count` = `size(Res7) / 16`
- `batch_count` = `size(Res13) / 20`
- `slot_count` = `(size(Res2) - 0x8C) / 0x44`
- `anim_key_count` = `size(Res8) / 24`
- `anim_map_count` = `size(Res19) / 2`
- `uv1_count` = `size(Res15) / 8` (если Res15 присутствует)
- `tbn_count` = `size(Res16) / 8` (если Res16 присутствует; tangent/bitangent по 4 байта, stride 8)
- `color_count` = `size(Res18) / 4` (если Res18 присутствует)
**Валидация:**
- Любое деление должно быть **без остатка**; иначе ресурс повреждён или stride неверно угадан.
- Если присутствуют Res4/Res5/Res15/Res16/Res18, их count'ы по смыслу должны совпадать с `vertex_count` (или быть ≥ него, если формат допускает хвостовые данные — пока не наблюдалось).
- Для `slot_count` дополнительно проверьте, что `size(Res2) >= 0x8C`.
**Проверка на реальных данных (435 MSH):**
- `Res2.attr1 == (size-140)/68`, `Res2.attr2 == 0`, `Res2.attr3 == 68`;
- `Res7.attr1 == size/16`, `Res7.attr3 == 16`;
- `Res8.attr1 == size/24`, `Res8.attr3 == 4`;
- `Res19.attr1 == size/2`, `Res19.attr3 == 2`;
- для `Res1` почти всегда `attr3 == 38` (один служебный outlier: `MTCHECK.MSH` с `attr3 == 24`).
Эти формулы достаточны, чтобы реализовать распаковщик/просмотрщик геометрии и батчей даже без полного понимания полей заголовка Res2.
## 1.5. Ресурс Res1 — Node Table (38 байт на узел)
Node table — компактная карта слотов по уровням LOD и группам. Каждый узел занимает **38 байт** (19 × `uint16`).
### Адресация слота
Движок вычисляет индекс слова в таблице:
```
word_index = nodeIndex × 19 + lod × 5 + group + 4
slot_index = node_table[word_index] // uint16, 0xFFFF = нет слота
```
Параметры:
- `lod`: 0..2 (три уровня детализации). Значение `1` → подставляется `current_lod` из инстанса.
- `group`: 0..4 (пять групп). На практике чаще всего используется `group = 0`.
### Раскладка записи узла (38 байт)
```
┌───────────────────────────────────────────────────────┐
│ Header: 4 × uint16 (8 байт) │
│ hdr0, hdr1, hdr2, hdr3 │
├───────────────────────────────────────────────────────┤
│ SlotIndex matrix: 3 LOD × 5 groups = 15 × uint16 │
│ LOD 0: group[0..4] │
│ LOD 1: group[0..4] │
│ LOD 2: group[0..4] │
└───────────────────────────────────────────────────────┘
```
| Смещение | Размер | Тип | Описание |
|----------|--------|------------|-----------------------------------------|
| 0 | 8 | uint16[4] | Заголовок узла (`hdr0..hdr3`, см. ниже) |
| 8 | 30 | uint16[15] | Матрица слотов: `slotIndex[lod][group]` |
`slotIndex = 0xFFFF` означает «слот отсутствует» — узел при данном LOD и группе не рисуется.
Подтверждённые семантики полей `hdr*`:
- `hdr1` (`+0x02`) — parent/index-link при построении инстанса (в `sub_1000A460` читается как индекс связанного узла, `0xFFFF` = нет связи).
- `hdr2` (`+0x04`) — `mapStart` для Res19 (`0xFFFF` = нет карты; fallback по `hdr3`).
- `hdr3` (`+0x06`) — `fallbackKeyIndex`/верхняя граница для mapзначений (используется в `sub_10012880`).
`hdr0` (`+0x00`) по коду участвует в битовых проверках (`&0x40`, `byte+1 & 8`) и несёт флаги узла.
**Группы (group 0..4):** в рантайме это ортогональный индекс к LOD (матрица 5×3 на узел). Имена групп в оригинальных ресурсах не подписаны; для 1:1 нужно сохранять группы как «сырой» индекс 0..4 без переинтерпретации.
---
## 1.6. Ресурс Res3 — Vertex Positions
**Формат:** массив `float3` (IEEE 754 singleprecision).
**Stride:** 12 байт.
```c
struct Position {
float x; // +0
float y; // +4
float z; // +8
struct Node38 {
uint16_t hdr0;
uint16_t parent_or_link;
uint16_t anim_map_start;
uint16_t fallback_key;
uint16_t slotIndex[15]; // lod0:g0..g4, lod1:g0..g4, lod2:g0..g4
};
```
Чтение: `pos = *(float3*)(res3_data + 12 * vertexIndex)`.
---
## 1.7. Ресурс Res4 — Packed Normals
**Формат:** 4 байта на вершину.
**Stride:** 4 байта.
Формула slot-выбора:
```c
struct PackedNormal {
int8_t nx; // +0
int8_t ny; // +1
int8_t nz; // +2
int8_t nw; // +3 (назначение не подтверждено: паддинг / знак / индекс)
slot = node.slotIndex[lod * 5 + group]
```
`0xFFFF` означает отсутствие слота.
### 3.2. `Res2` (header + slot records)
```c
struct Slot68 {
uint16_t triStart;
uint16_t triCount;
uint16_t batchStart;
uint16_t batchCount;
float aabbMin[3];
float aabbMax[3];
float sphereCenter[3];
float sphereRadius;
uint32_t opaque[5];
};
```
### Алгоритм декодирования (подтверждено по AniMesh.dll)
`opaque[5]` должны сохраняться 1:1.
> В движке используется делитель **127.0**, а не 128.0 (см. константу `127.0` рядом с `1024.0`/`32767.0`).
### 3.3. `Res3`, `Res4`, `Res5`, `Res6`
```
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)
```
- `Res3`: `float3` позиции (`stride=12`)
- `Res4`: `int8[4]` packed normal (`stride=4`)
- `Res5`: `int16[2]` UV (`stride=4`)
- `Res6`: `uint16` индексы (`stride=2`)
**Множитель:** `1.0 / 127.0 ≈ 0.0078740157`.
**Диапазон входных значений:** 128..+127 → выход ≈ 1.007874..+1.0 → **после клампа** 1.0..+1.0.
**Почему нужен кламп:** значение `-128` при делении на `127.0` даёт модуль чуть больше 1.
**4й байт (nw):** используется ли он как часть нормали, как индекс или просто как выравнивание — не подтверждено. Рекомендация: игнорировать при первичном импорте.
Декодирование:
---
- normal = `clamp(n / 127.0, -1..1)`
- uv = `packed / 1024.0`
## 1.8. Ресурс Res5 — Packed UV0
**Формат:** 4 байта на вершину (два `int16`).
**Stride:** 4 байта.
### 3.4. `Res7` и `Res13`
```c
struct PackedUV {
int16_t u; // +0
int16_t v; // +2
struct TriDesc16 {
uint16_t triFlags;
uint16_t link0;
uint16_t link1;
uint16_t link2;
int16_t nx;
int16_t ny;
int16_t nz;
uint16_t selPacked;
};
struct Batch20 {
uint16_t batchFlags;
uint16_t materialIndex;
uint16_t opaque4;
uint16_t opaque6;
uint16_t indexCount;
uint32_t indexStart;
uint16_t opaque14;
uint32_t baseVertex;
};
```
### Алгоритм декодирования
`selPacked` хранит 3 селектора по 2 бита; значение `3` трактуется как `0xFFFF`.
```
uv.u = (float)u / 1024.0
uv.v = (float)v / 1024.0
```
## 4. Runtime-обход модели
**Множитель:** `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` — triref для связанного обхода |
| `+0x04` | 2 | `uint16` | `linkTri1` — triref для связанного обхода |
| `+0x06` | 2 | `uint16` | `linkTri2` — triref для связанного обхода |
| `+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;
for each node:
slot = resolve_slot(node, lod, group)
if slot == none: continue
if culled(slot.bounds, node_transform): continue
for b in slot.batchRange:
batch = batches[b]
bind_material(batch.materialIndex)
draw_indexed(
baseVertex = batch.baseVertex,
indexStart = batch.indexStart,
indexCount = batch.indexCount
)
```
`linkTri*` передаются в `sub_1000B2C0` и используются для построения соседнего набора треугольников при коллизии/пикинге.
## 5. Критические инварианты
**Важно:** дескрипторы не хранят индексы вершин треугольника. Индексы берутся из Res6 (index buffer) через `indexStart`/`indexCount` соответствующего batch'а.
Обязательно проверять:
Дескрипторы используются при обходе треугольников для коллизии и пикинга. `triStart` из slot table указывает, с какого дескриптора начинать обход для данного слота.
- `Res2.size >= 0x8C`
- `(Res2.size - 0x8C) % 68 == 0`
- `batchStart + batchCount` не выходит за `Res13`
- `triStart + triCount` не выходит за `Res7`
- `indexStart + indexCount` не выходит за `Res6`
- `baseVertex + max(indexSlice) < vertexCount`
- `slotIndex == 0xFFFF` или `< slotCount`
---
## 6. Важные edge-cases
## 1.11. Ресурс Res13 — Batch Table
- Встречается редкий вариант `Res1.attr3 = 24`; для существующих ассетов нужен copy-through.
- Для строгого writer лучше генерировать `Res1` в основном формате `38` байт/узел.
- Неизвестные поля таблиц нельзя нормализовать или обнулять.
**Формат:** массив записей по 20 байт. Batch — минимальная единица отрисовки.
## 7. Правила для writer/editor
| Смещение | Размер | Тип | Описание |
|----------|--------|--------|---------------------------------------------------------|
| 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` — смещение вершинного индекса |
1. Сохранять неизвестные поля и неизвестные `type`-ресурсы.
2. Пересчитывать только явно вычислимые атрибуты (`attr1/attr3` и size-зависимые поля).
3. Не менять порядок/контент opaque-данных без явной цели.
4. Сериализовать little-endian, без внутреннего padding.
### Использование при рендере
## 8. Статус валидации
```
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, дополнительные данные для эффектов/деформаций.
---
- Инварианты формата реализованы в `tools/msh_doc_validator.py`.
- В текущем окружении нет загруженного полного корпуса игровых MSH в `testdata`, поэтому массовый прогон по ассетам здесь не выполнялся.

View File

@@ -6,11 +6,13 @@
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) — контрольные заметки, декодирование и открытые вопросы.
3. [Material (`MAT0`)](material.md) — формат материала и фазовая анимация.
4. [Wear (`WEAR`)](wear.md) — текстовая таблица привязки материалов/lightmap.
5. [Texture (`Texm`)](texture.md) — форматы текстур, mip-chain и `Page`.
6. [FXID](fxid.md) — контейнер эффекта и поток команд.
7. [Render pipeline](render.md) — полный процесс рендера кадра.
8. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру.
9. [3D implementation notes](msh-notes.md) — контрольные заметки и открытые вопросы.
## Связанные спецификации

View File

@@ -0,0 +1,77 @@
# Рендер-паритет (кадровый diff)
Документ описывает процесс проверки соответствия рендера:
`оригинальный движок -> эталонный кадр -> render-demo -> diff-метрики`.
## Цель
- Зафиксировать объективный критерий "паритет достигнут / не достигнут".
- Убрать субъективную визуальную оценку "похоже/не похоже".
- Дать CI-проверку, которая ловит регрессии сразу после коммита.
## Единица проверки
Один тест-кейс = один объект (одна модель) + фиксированная конфигурация:
- архив ресурса;
- имя модели;
- `lod`;
- `group`;
- размер кадра (`width`, `height`);
- угол камеры (`angle`);
- PNG-эталон из оригинального рендера.
## Инварианты детерминизма
Для корректного сравнения кадры должны быть сняты в одинаковых условиях:
- одинаковый FOV и расстояние камеры до объекта;
- одинаковый clear-color/фон;
- одинаковые `lod/group`;
- фиксированный угол (`angle`), без анимации;
- фиксированное разрешение.
## Метрики сравнения
Сравнение выполняется по RGB-каналам:
- `mean_abs`: средняя абсолютная разница канала (0..255);
- `max_abs`: максимальная разница канала;
- `changed_ratio`: доля пикселей, где хотя бы один канал превышает `diff_threshold`.
Кейс считается пройденным, если:
- `mean_abs <= max_mean_abs`;
- `changed_ratio <= max_changed_ratio`.
## Конфигурация кейсов
Файл: `parity/cases.toml`.
- секция `[meta]`: глобальные дефолты;
- `[[case]]`: параметры конкретной модели и путь к эталонному PNG.
Эталонные кадры хранятся в `parity/reference/`.
## Локальный запуск
```bash
cargo run -p render-parity -- \
--manifest parity/cases.toml \
--output-dir target/render-parity/current
```
При расхождении утилита пишет diff-изображение в:
- `target/render-parity/current/diff/<case>.png`
## CI-модель
CI запускает `render-parity` на каждом push/PR:
1. собирает `parkan-render-demo`;
2. прогоняет кейсы из `cases.toml`;
3. при падении публикует текущие кадры и diff как артефакт.
Важно: оригинальный движок в CI обычно не запускается.
Эталонные PNG снимаются офлайн и версионируются в репозитории.

155
docs/specs/render.md Normal file
View File

@@ -0,0 +1,155 @@
# Render pipeline
Документ описывает полный процесс рендера кадра в движке Parkan: Iron Strategy, без привязки к внутренним адресам/именам дизассемблера.
Связанные страницы:
- [MSH core](msh-core.md)
- [MSH animation](msh-animation.md)
- [Material (`MAT0`)](material.md)
- [Wear table (`WEAR`)](wear.md)
- [Texture (`Texm`)](texture.md)
- [FXID](fxid.md)
## 1. Инициализация рендера
На старте движок:
1. Выбирает видеодрайвер (software или аппаратный).
2. Создаёт render backend.
3. Подключает библиотеки ресурсов:
- `Material.lib`
- `Textures.lib`
- `LightMap.lib`
- `palettes.lib`
4. Инициализирует менеджеры:
- material manager
- texture/lightmap cache
- effect manager
5. Загружает базовые world-ресурсы (включая наборы объектов сцены).
## 2. Структура кадра
Кадр выполняется как последовательность:
1. `Simulation update`
2. `Animation sampling`
3. `Visibility / culling`
4. `Material + texture resolve`
5. `Mesh draw`
6. `FX update + draw`
7. `UI/overlay draw`
8. `Present`
## 3. Geometry path
### 3.1. Подготовка инстансов
Для каждого видимого объекта:
1. Вычисляется `world transform`.
2. Выбирается `LOD`.
3. Для каждого узла выбирается slot через `Res1`.
### 3.2. Culling
Сначала отсекаются узлы/слоты по bounds (`AABB/sphere`) из `Res2`.
### 3.3. Батчи
Для каждого прошедшего slot:
1. Берутся батчи из диапазона `Res13`.
2. По `materialIndex` выбирается активный материал.
3. По фазе материала выбирается текстура/lightmap.
4. Выполняется `DrawIndexedPrimitive`:
- индексный диапазон: `indexStart/indexCount`
- базовая вершина: `baseVertex`
- индексы читаются из `Res6`
- вершины/атрибуты читаются из `Res3/Res4/Res5` (+ optional streams)
## 4. Animation path
Для анимированных моделей:
1. Для узла выбирается ключ через `Res19` и fallback-логику.
2. Декодируются `pos + quat` из `Res8`.
3. При необходимости выполняется blending двух сэмплов.
4. Узловая матрица передаётся в geometry path.
## 5. Material path
Material pipeline на кадре:
1. По material handle выбирается запись `MAT0`.
2. По игровому времени выбирается текущая фаза.
3. Применяются коэффициенты фазы (цвет/альфа/параметры).
4. Резолвятся ссылки на texture/lightmap.
5. Невалидные ссылки обрабатываются fallback-стратегией.
Практическая цепочка привязки для большинства `*.msh` ассетов из `*.rlb`:
1. Для модели выбирается одноимённый `WEAR` (`<model_stem>.wea`).
2. Из `WEAR` берётся material-слот (по имени, `legacyId` не участвует в выборе).
3. В `Material.lib` ищется `MAT0` по имени (`DEFAULT`, затем индекс `0` как fallback).
4. Из выбранной material-фазы берётся `textureName`.
5. `Texm` ищется в `Textures.lib` (и/или lightmap-архиве для lightmap-ветки).
## 6. Texture path
При резолве текстуры:
1. Ищется `Texm` entry по имени.
2. Проверяется и декодируется заголовок.
3. При необходимости применяется `mipSkip`.
4. Для indexed-формата подключается палитра.
5. Optional `Page` chunk интерпретируется как atlas-таблица.
6. Объект текстуры кладётся/берётся из cache.
## 7. FX path
Эффекты выполняются параллельно mesh-рендеру:
1. Для активных инстансов FX вычисляется runtime-коэффициент (`time_mode + flags`).
2. Команды FX обновляют внутреннее состояние.
3. Команды emit-этапа формируют примитивы/батчи эффектов.
4. Эффекты рисуются в 3D-кадре с собственным счётчиком батчей.
## 8. Псевдокод кадра
```c
void RenderFrame(Scene* scene, Camera* cam, float dt) {
UpdateGame(scene, dt);
for (Object* obj : scene->objects) {
if (!obj->visible) continue;
UpdateObjectAnimation(obj, scene->time);
BuildObjectNodeTransforms(obj);
}
BeginFrame(cam);
for (Object* obj : scene->objects) {
if (!obj->visible) continue;
RenderObjectMeshes(obj, cam);
}
UpdateAndRenderFx(scene, dt, cam);
RenderUI(scene);
Present();
}
```
## 9. Критичные условия для 1:1
1. Та же политика округления/FP для анимации и FX.
2. Та же логика fallback по материалам и текстурам.
3. Та же очередность стадий кадра.
4. Тот же контракт интерпретации `Res1/Res2/Res13/Res6`.
5. Тот же контракт `FXID` командного потока.
## 10. Статус валидации
- Порядок кадра и подключение `Material.lib / Textures.lib / LightMap.lib` подтверждены текущим runtime-кодом приложения и импортами движковых DLL.
- Детальные инварианты форматов зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`.

View File

@@ -1,123 +1,8 @@
# Runtime pipeline
Документ фиксирует runtime-поведение движка: кто кого вызывает в кадре, как проходят рендер, коллизия и подключение эффектов.
Актуальный документ по полному кадру находится здесь:
---
- [Render pipeline](render.md)
## 1.15. Алгоритм рендера модели (реконструкция)
```
Вход: model, instanceTransform, cameraFrustum
1. Определить current_lod ∈ {0, 1, 2} (по дистанции до камеры / настройкам).
2. Для каждого node (nodeIndex = 0 .. nodeCount1):
a. Вычислить nodeTransform = instanceTransform × nodeLocalTransform
b. slotIndex = nodeTable[nodeIndex].slotMatrix[current_lod][group=0]
если slotIndex == 0xFFFF → пропустить узел
c. slot = slotTable[slotIndex]
d. // Frustum culling:
transformedAABB = transform(slot.aabb, nodeTransform)
если transformedAABB вне cameraFrustum → пропустить
// Альтернативно по сфере:
transformedCenter = nodeTransform × slot.sphereCenter
scaledRadius = slot.sphereRadius × max(scaleX, scaleY, scaleZ)
если сфера вне frustum → пропустить
e. Для i = 0 .. slot.batchCount 1:
batch = batchTable[slot.batchStart + i]
// Фильтрация по batchFlags (если нужна)
// Установить материал:
setMaterial(batch.materialIndex)
// Установить transform:
setWorldMatrix(nodeTransform)
// Нарисовать:
DrawIndexedPrimitive(
baseVertex = batch.baseVertex,
indexStart = batch.indexStart,
indexCount = batch.indexCount,
primitiveType = TRIANGLE_LIST
)
```
---
## 1.16. Алгоритм обхода треугольников (коллизия / пикинг)
```
Вход: model, nodeIndex, lod, group, filterMask, callback
1. slotIndex = nodeTable[nodeIndex].slotMatrix[lod][group]
если slotIndex == 0xFFFF → выход
2. slot = slotTable[slotIndex]
triDescIndex = slot.triStart
3. Для каждого batch в диапазоне [slot.batchStart .. slot.batchStart + slot.batchCount 1]:
batch = batchTable[batchIndex]
triCount = batch.indexCount / 3 // округление: (indexCount + 2) / 3
Для t = 0 .. triCount 1:
triDesc = triDescTable[triDescIndex]
// Фильтрация:
если (triDesc.triFlags & filterMask) → пропустить
// Получить индексы вершин:
idx0 = indexBuffer[batch.indexStart + t*3 + 0] + batch.baseVertex
idx1 = indexBuffer[batch.indexStart + t*3 + 1] + batch.baseVertex
idx2 = indexBuffer[batch.indexStart + t*3 + 2] + batch.baseVertex
// Получить позиции:
p0 = positions[idx0]
p1 = positions[idx1]
p2 = positions[idx2]
callback(triDesc, idx0, idx1, idx2, p0, p1, p2)
triDescIndex += 1
```
---
---
## 3.1. Архитектурный обзор
Подсистема эффектов реализована в `Effect.dll` и интегрирована в рендер через `Terrain.dll`.
### Экспорты Effect.dll
| Функция | Описание |
|----------------------|--------------------------------------------------------|
| `CreateFxManager` | Создать менеджер эффектов (3 параметра: int, int, int) |
| `InitializeSettings` | Инициализировать настройки эффектов |
`CreateFxManager` возвращает объект‑менеджер, который регистрируется в движке и управляет всеми эффектами.
### Телеметрия из Terrain.dll
Terrain.dll содержит отладочную статистику рендера:
```
"Rendered meshes : %d"
"Rendered primitives : %d"
"Rendered faces : %d"
"Rendered particles/batches : %d/%d"
```
Из этого следует:
- Частицы рендерятся **батчами** (группами).
- Статистика частиц отделена от статистики мешей.
- Частицы интегрированы в общий 3Dрендерпайплайн.
Эта страница оставлена как совместимый указатель для старых ссылок.

View File

@@ -1,32 +1,511 @@
# Terrain + map loading
Документ описывает подсистему ландшафта и привязку terrain-данных к миру.
Документ описывает полный runtime-пайплайн загрузки ландшафта и карты (`Terrain.dll` + `ArealMap.dll`) и требования к toolchain для 1:1 совместимости (чтение, конвертация, редактирование, обратная сборка).
Источник реверса:
- `tmp/disassembler1/Terrain.dll.c`
- `tmp/disassembler1/ArealMap.dll.c`
- `tmp/disassembler2/Terrain.dll.asm`
- `tmp/disassembler2/ArealMap.dll.asm`
Связанные спецификации:
- [NRes / RsLi](nres.md)
- [MSH core](msh-core.md)
- [ArealMap](arealmap.md)
---
## 4.1. Обзор
## 1. Назначение подсистем
`Terrain.dll` отвечает за рендер ландшафта (terrain), включая:
### 1.1. `Terrain.dll`
- Рендер мешей ландшафта (`"Rendered meshes"`, `"Rendered primitives"`, `"Rendered faces"`).
- Рендер частиц (`"Rendered particles/batches"`).
- Создание текстур (`"CTexture::CTexture()"` — конструктор текстуры).
- Микротекстуры (`"Unable to find microtexture mapping"`).
Отвечает за:
## 4.2. Текстуры ландшафта
- загрузку и хранение terrain-геометрии из `*.msh` (NRes);
- фильтрацию и выборку треугольников для коллизий/трассировки/рендера;
- рендер terrain-примитивов и связанного shading;
- использование микро-текстурного канала (chunk type 18).
В Terrain.dll присутствует конструктор текстуры `CTexture::CTexture()` со следующими проверками:
Характерные runtime-строки:
- Валидация размера текстуры (`"Unsupported texture size"`).
- Создание D3Dтекстуры (`"Unable to create texture"`).
- `CLandscape::CLandscape()`
- `Unable to find microtexture mapping chunk`
- `Rendering empty primitive!`
- `Rendering empty primitive2!`
Ландшафт использует **микротекстуры** (microtexture mapping chunks) — маленькие повторяющиеся текстуры, тайлящиеся по поверхности.
### 1.2. `ArealMap.dll`
## 4.3. Защита от пустых примитивов
Отвечает за:
Terrain.dll содержит проверки:
- загрузку геометрии ареалов из `*.map` (NRes, chunk type 12);
- построение связей "ареал <-> соседи/подграфы";
- grid-ускорение по ячейкам карты;
- runtime-доступ к `ISystemArealMap` (интерфейс id `770`) и ареалам (id `771`).
- `"Rendering empty primitive!"` — перед первым вызовом отрисовки.
- `"Rendering empty primitive2!"` — перед вторым вызовом отрисовки.
Характерные runtime-строки:
Это подтверждает многопроходный рендер (как минимум 2 прохода для ландшафта).
- `SystemArealMap panic: Cannot load ArealMapGeometry`
- `SystemArealMap panic: Cannot find chunk in resource`
- `SystemArealMap panic: ArealMap Cells are empty`
- `SystemArealMap panic: Incorrect ArealMap`
---
## 2. End-to-End загрузка уровня
### 2.1. Имена файлов уровня
В `CLandscape::CLandscape()` базовое имя уровня `levelBase` разворачивается в:
- `levelBase + ".msh"`: terrain-геометрия;
- `levelBase + ".map"`: геометрия ареалов/навигация;
- `levelBase + "1.wea"` и `levelBase + "2.wea"`: weather/материалы.
### 2.2. Порядок инициализации (высокоуровнево)
1. Получение `3DRender` и `3DSound`.
2. Загрузка `MatManager` (`*.wea`), `LightManager`, `CollManager`, `FxManager`.
3. Создание `SystemArealMap` через `CreateSystemArealMap(..., "<level>.map", ...)`.
4. Открытие terrain-библиотеки `niOpenResFile("<level>.msh")`.
5. Загрузка terrain-chunk-ов (см. §3).
6. Построение runtime-границ, grid-ускорителей и рабочих массивов.
Критичные ошибки на любом шаге приводят к `ngiProcessError`/panic.
---
## 3. Формат terrain `*.msh` (NRes)
### 3.1. Используемые chunk type в `Terrain.dll`
Порядок загрузки в `CLandscape::CLandscape()`:
| Порядок | Type | Обяз. | Использование (подтверждено кодом) |
|---|---:|---|---|
| 1 | 3 | да | поток позиций (`stride = 12`) |
| 2 | 4 | да | поток packed normal (`stride = 4`) |
| 3 | 5 | да | UV-поток (`stride = 4`) |
| 4 | 18 | да | microtexture mapping (`stride = 4`) |
| 5 | 14 | нет | опциональный доп. поток (`stride = 4`, отсутствует на части карт) |
| 6 | 21 | да | таблица terrain-face (по 28 байт) |
| 7 | 2 | да | header + slot-таблицы (используются диапазоны face) |
| 8 | 1 | да | node/grid-таблица (stride 38) |
| 9 | 11 | да | доп. индекс/ускоритель для запросов (cell->list) |
Ключевые проверки:
- отсутствие type `18` вызывает `Unable to find microtexture mapping chunk`;
- отсутствие остальных обязательных чанков вызывает `Unable to open file`.
### 3.2. Node/slot структура для terrain
Terrain-код использует те же stride и адресацию, что и core-описание:
- node-запись: `38` байт;
- slot-запись: `68` байт;
- доступ к первому slot-index: `node + 8`;
- tri-диапазон в slot: `slot + 140` (offset 0 внутри slot), `slot + 142` (offset 2).
Это согласуется с [MSH core](msh-core.md) для `Res1/Res2`:
- `Res1`: `uint16[19]` на node;
- `Res2`: header + slot table (`0x8C + N * 0x44`).
### 3.3. Terrain face record (type 21, 28 bytes)
Подтвержденные поля из runtime-декодирования face:
```c
struct TerrainFace28 {
uint32_t flags; // +0
uint8_t materialId; // +4 (читается как byte)
uint8_t auxByte; // +5
uint16_t unk06; // +6
uint16_t i0; // +8 (индекс вершины)
uint16_t i1; // +10
uint16_t i2; // +12
uint16_t n0; // +14 (сосед, 0xFFFF -> нет)
uint16_t n1; // +16
uint16_t n2; // +18
int16_t nx; // +20 packed normal component
int16_t ny; // +22
int16_t nz; // +24
uint8_t edgeClass; // +26 (три 2-бит значения)
uint8_t unk27; // +27
};
```
`edgeClass` декодируется как:
- `edge0 = byte26 & 0x3`
- `edge1 = (byte26 >> 2) & 0x3`
- `edge2 = (byte26 >> 4) & 0x3`
### 3.4. Маски флагов face
Во многих запросах применяется фильтр:
```c
(faceFlags & requiredMask) == requiredMask &&
(faceFlags | ~forbiddenMask) == ~forbiddenMask
```
Эквивалентно: "все required-биты выставлены, forbidden-биты отсутствуют".
Подтверждено активное использование битов:
- `0x8` (особая обработка в трассировке)
- `0x2000`
- `0x20000`
- `0x100000`
- `0x200000`
Кроме "полной" 32-бит маски, runtime использует компактные маски в API-запросах.
Подтверждённый remap `full -> compactMain16` (функции `sub_10013FC0`, `sub_1004BA00`, `sub_1004BB40`):
| Full bit | Compact bit |
|---:|---:|
| `0x00000001` | `0x0001` |
| `0x00000008` | `0x0002` |
| `0x00000010` | `0x0004` |
| `0x00000020` | `0x0008` |
| `0x00001000` | `0x0010` |
| `0x00004000` | `0x0020` |
| `0x00000002` | `0x0040` |
| `0x00000400` | `0x0080` |
| `0x00000800` | `0x0100` |
| `0x00020000` | `0x0200` |
| `0x00002000` | `0x0400` |
| `0x00000200` | `0x0800` |
| `0x00000004` | `0x1000` |
| `0x00000040` | `0x2000` |
| `0x00200000` | `0x8000` |
Подтверждённый remap `full -> compactMaterial6` (функции `sub_10014090`, `sub_10015540`, `sub_1004BB40`):
| Full bit | Compact bit |
|---:|---:|
| `0x00000100` | `0x01` |
| `0x00008000` | `0x02` |
| `0x00010000` | `0x04` |
| `0x00040000` | `0x08` |
| `0x00080000` | `0x10` |
| `0x00000080` | `0x20` |
Подтверждённый remap `compact -> full` (функция `sub_10015680`):
- `a2[4]`/`a2[5]` (compactMain16 required/forbidden) + `a2[6]`/`a2[7]` (compactMaterial6 required/forbidden)
- разворачиваются в `fullRequired/fullForbidden` в `this[4]/this[5]`.
Для toolchain это означает:
- если редактируется только бинарник `type 21`, достаточно сохранять `flags` как есть;
- если реализуется API-совместимый runtime-слой, нужно поддерживать оба представления (`full` и `compact`) и точный remap выше.
### 3.5. Grid-ускоритель terrain-запросов
Runtime строит grid descriptor с параметрами:
- origin (`baseX/baseY`);
- масштабные коэффициенты (`invSizeX/invSizeY`);
- размеры сетки (`cellsX`, `cellsY`).
Дальше запросы:
1. переводят world AABB в диапазон grid-ячеек (`floor(...)`);
2. берут диапазон face через `Res1/Res2` (slot `triStart/triCount`);
3. дополняют кандидаты из cell-списков (chunk type 11);
4. применяют маски флагов;
5. выполняют геометрию (plane/intersection/point-in-triangle).
### 3.6. Cell-списки по ячейкам (`type 11` и runtime-массивы)
В `CLandscape` после инициализации используются три параллельных массива по ячейкам (`cellsX * cellsY`):
- `this+31588` (`sub_100164B0` ctor): массив записей по `12` байт, каждая запись содержит динамический буфер `8`-байтовых элементов;
- `this+31592` (`sub_100164E0` ctor): массив записей по `12` байт, каждая запись содержит динамический буфер `4`-байтовых элементов;
- `this+31596` (`sub_1001F880` ctor): массив записей по `12` байт для runtime-объектов/агентов (буфер `4`-байтовых идентификаторов/указателей).
Общий header записи списка:
```c
struct CellListHdr {
void* ptr; // +0
int count; // +4
int capacity; // +8
};
```
Подтвержденные element-layout:
- `this+31588`: элемент `8` байт (`uint32_t id`, `uint32_t aux`), добавление через `sub_10012E20` пишет `aux = 0`;
- `this+31592`: элемент `4` байта (`uint32_t id`);
- `this+31596`: элемент `4` байта (runtime object handle/pointer id).
Практический вывод для редактора:
- `type 11` должен считаться источником cell-ускорителя;
- неизвестные/дополнительные поля внутри списков должны сохраняться как есть;
- нельзя "нормализовать" или переупорядочивать списки без полного пересчёта всех зависимых runtime-структур.
---
## 4. Формат `*.map` (ArealMapGeometry, chunk type 12)
### 4.1. Точка входа
`CreateSystemArealMap(..., "<level>.map", ...)` вызывает `sub_1001E0D0`:
1. `niOpenResFile("<level>.map")`;
2. поиск chunk type `12`;
3. чтение chunk-данных;
4. разбор `ArealMapGeometry`.
При ошибках выдаются panic-строки `SystemArealMap panic: ...`.
### 4.2. Верхний уровень chunk 12
Используются:
- `entry.attr1` (из каталога NRes) как `areal_count`;
- `entry[+0x0C]` как размер payload chunk для контроля полного разбора.
Данные chunk:
1. `areal_count` переменных записей ареалов;
2. секция grid-ячеек (`cellsX/cellsY` + списки попаданий).
### 4.3. Переменная запись ареала
Полностью подтверждённые элементы layout:
```c
// record = начало записи ареала
float anchor_x = *(float*)(record + 0);
float anchor_y = *(float*)(record + 4);
float anchor_z = *(float*)(record + 8);
float reserved_12 = *(float*)(record + 12); // в retail-данных всегда 0
float area_metric = *(float*)(record + 16); // предрасчитанная площадь ареала
float normal_x = *(float*)(record + 20);
float normal_y = *(float*)(record + 24);
float normal_z = *(float*)(record + 28); // unit vector (|n| ~= 1)
uint32_t logic_flag = *(uint32_t*)(record + 32); // активно используется в runtime
uint32_t reserved_36 = *(uint32_t*)(record + 36); // в retail-данных всегда 0
uint32_t class_id = *(uint32_t*)(record + 40); // runtime-class/type id ареала
uint32_t reserved_44 = *(uint32_t*)(record + 44); // в retail-данных всегда 0
uint32_t vertex_count = *(uint32_t*)(record + 48);
uint32_t poly_count = *(uint32_t*)(record + 52);
float* vertices = (float*)(record + 56); // float3[vertex_count]
// сразу после vertices:
// EdgeLink8[vertex_count + 3*poly_count]
// где EdgeLink8 = { int32_t area_ref; int32_t edge_ref; }
// первые vertex_count записей используются как per-edge соседство границы ареала.
EdgeLink8* links = (EdgeLink8*)(record + 56 + 12 * vertex_count);
uint8_t* p = (uint8_t*)(links + (vertex_count + 3 * poly_count));
for (i=0; i<poly_count; i++) {
uint32_t n = *(uint32_t*)p;
p += 4 * (3*n + 1);
}
// p -> начало следующей записи ареала
```
То есть для toolchain:
- поля `+0/+4/+8`, `+16`, `+20..+28`, `+32`, `+40`, `+48`, `+52` являются runtime-значимыми;
- для `links[0..vertex_count-1]` подтверждена интерпретация как `(area_ref, edge_ref)`:
- `area_ref == -1 && edge_ref == -1` = нет соседа;
- иначе `area_ref` указывает на индекс ареала, `edge_ref` — на индекс ребра в целевом ареале;
- при редактировании безопасно работать через parser+writer этой формулы;
- неизвестные байты внутри записи должны сохраняться без изменений.
Дополнительно по runtime-поведению:
- `anchor_x/anchor_y` валидируются на попадание внутрь полигона; при промахе движок делает случайный re-seed позиции (см. §4.5);
- `logic_flag` по смещению `+32` используется как gating-условие в логике `SystemArealMap`.
### 4.4. Секция grid-ячеек в chunk 12
После массива ареалов идёт:
```c
uint32_t cellsX;
uint32_t cellsY;
for (x in 0..cellsX-1) {
for (y in 0..cellsY-1) {
uint16_t hitCount;
uint16_t areaIds[hitCount];
}
}
```
Runtime упаковывает метаданные ячейки в `uint32`:
- high 10 bits: `hitCount` (`value >> 22`);
- low 22 bits: `startIndex` (1-based индекс в общем `uint16`-пуле areaIds).
Контроль целостности:
- после разбора `ptr_end - chunk_begin` должен строго совпасть с `entry[+0x0C]`;
- иначе `SystemArealMap panic: Incorrect ArealMap`.
### 4.5. Нормализация геометрии при загрузке
Если опорная точка ареала не попадает внутрь его полигона:
- до 100 попыток случайного сдвига в радиусе ~30;
- затем до 200 попыток в радиусе ~100.
Это runtime-correction; для 1:1-офлайн инструментов лучше генерировать валидные данные, чтобы не зависеть от недетерминизма `rand()`.
---
## 5. `BuildDat.lst` и объектные категории ареалов
`ArealMap.dll` инициализирует 12 категорий и читает `BuildDat.lst`.
Хардкод-категории (имя -> mask):
| Имя | Маска |
|---|---:|
| `Bunker_Small` | `0x80010000` |
| `Bunker_Medium` | `0x80020000` |
| `Bunker_Large` | `0x80040000` |
| `Generator` | `0x80000002` |
| `Mine` | `0x80000004` |
| `Storage` | `0x80000008` |
| `Plant` | `0x80000010` |
| `Hangar` | `0x80000040` |
| `MainTeleport` | `0x80000200` |
| `Institute` | `0x80000400` |
| `Tower_Medium` | `0x80100000` |
| `Tower_Large` | `0x80200000` |
Файл `BuildDat.lst` парсится секционно; при сбое формата используется panic `BuildDat.lst is corrupted`.
---
## 6. Требования к toolchain (конвертер/ридер/редактор)
### 6.1. Общие принципы 1:1
1. Никаких "переупорядочиваний по вкусу": сохранять порядок chunk-ов, если не требуется явная нормализация.
2. Все неизвестные поля сохранять побайтно.
3. При roundtrip обеспечивать byte-identical для неизмененных сущностей.
4. Валидации должны повторять runtime-ожидания (размеры, count-формулы, обязательность chunk-ов).
### 6.2. Для terrain `*.msh`
Обязательные проверки:
- наличие chunk types `1,2,3,4,5,11,18,21`;
- type `14` опционален;
- для `type 2`: `size >= 0x8C`, `(size - 0x8C) % 68 == 0`, `attr1 == (size - 0x8C) / 68`;
- `type21_size % 28 == 0`;
- индексы `i0/i1/i2` в `TerrainFace28` не выходят за `vertex_count` (type 3);
- `slot.triStart + slot.triCount` не выходит за `face_count`.
Сериализация:
- `flags`, соседи, `edgeClass`, material байты в `TerrainFace28` сохранять как есть;
- содержимое `type 11`-derived cell-списков (`id`, `aux`) сохранять без "починки";
- для packed normal не делать "улучшений" нормализации, если цель 1:1.
### 6.3. Для `*.map` (chunk 12)
Обязательные проверки:
- chunk type `12` существует;
- `areal_count > 0`;
- `cellsX > 0 && cellsY > 0`;
- `|normal_x,normal_y,normal_z| ~= 1` для каждого ареала;
- `links[0..vertex_count-1]` валидны (`-1/-1` или корректные `(area_ref, edge_ref)`);
- полный consumed-bytes строго равен `entry[+0x0C]`.
При редактировании:
- перестраивать только то, что действительно изменено;
- пересчитывать cell-списки и packed `cellMeta` синхронно;
- сохранять неизвестные части записи ареала без изменений.
### 6.4. Рекомендуемая архитектура редактора
1. `Parser`:
- NRes-слой;
- `TerrainMsh`-слой;
- `ArealMapChunk12`-слой.
2. `Model`:
- явные известные поля;
- `raw_unknown` для непросаженных блоков.
3. `Writer`:
- стабильная сериализация;
- проверка контрольных инвариантов перед записью.
4. `Verifier`:
- roundtrip hash/byte-compare;
- runtime-совместимые asserts.
---
## 7. Практический чеклист "движок 1:1"
Для runtime-совместимого движка нужно реализовать:
1. NRes API-уровень (`niOpenResFile`, `niOpenResInMem`, поиск chunk по type, получение data/attrs).
2. `CLandscape` пайплайн загрузки `*.msh` + менеджеров + `CreateSystemArealMap`.
3. Terrain face decode (28-byte запись), mask-фильтр, spatial grid queries.
4. Загрузчик `ArealMapGeometry` (chunk 12) с той же валидацией и packed-cell логикой.
5. Пост-обработку ареалов (пересвязка, корректировки опорных точек).
6. Поддержку `BuildDat.lst` для объектных категорий/схем.
---
## 8. Нерасшифрованные зоны (важно для редакторов)
Ниже поля, которые пока нельзя безопасно "пересобирать по смыслу":
- семантика `class_id` (`record + 40`) на уровне геймдизайна/скриптов (числовое поле подтверждено, но человекочитаемая таблица соответствий не восстановлена полностью);
- ветки формата для `poly_count > 0` (в retail `tmp/gamedata` это всегда `0`, поэтому поведение этих веток подтверждено только по коду, без живых образцов);
- человекочитаемая семантика части битов `TerrainFace28.flags` (при этом remap и бинарные значения подтверждены);
- семантика поля `aux` во `8`-байтовом элементе cell-списка (`this+31588`, второй `uint32_t`), которое в известных runtime-путях инициализируется нулем.
Правило до полного реверса: `preserve-as-is`.
---
## 9. Эмпирическая верификация (retail `tmp/gamedata`)
Для массовой проверки спецификации добавлен валидатор:
- `tools/terrain_map_doc_validator.py`
Запуск:
```bash
python3 tools/terrain_map_doc_validator.py \
--maps-root tmp/gamedata/DATA/MAPS \
--report-json tmp/terrain_map_doc_validator.report.json
```
Проверенные инварианты (на 33 картах, 2026-02-12):
- `Land.msh`:
- порядок chunk-ов всегда `[1,2,3,4,5,18,14,11,21]`;
- `type11` первые dword всегда `[5767168, 4718593]`;
- `type21` индексы вершин/соседей валидны;
- `type2` slot-таблица валидна по формуле `0x8C + 68*N`.
- `Land.map`:
- всегда один chunk `type 12`;
- `cellsX == cellsY == 128` на всех картах;
- `poly_count == 0` для всех `34662` записей ареалов в retail-наборе;
- `record+12`, `record+36`, `record+44` всегда `0`;
- `area_metric` (`record+16`) стабильно коррелирует с площадью XY-полигона (макс. абсолютное отклонение `51.39`, макс. относительное `14.73%`, `18` кейсов > `5%`);
- `normal` в `record+20..28` всегда unit (диапазон длины `0.9999998758..1.0000001194`);
- link-таблицы `EdgeLink8` проходят строгую валидацию ссылочной целостности.
Сводный результат текущего набора данных:
- `issues_total = 0`, `errors_total = 0`, `warnings_total = 0`.

139
docs/specs/texture.md Normal file
View File

@@ -0,0 +1,139 @@
# Texture (`Texm`)
`Texm` — основной формат текстур движка.
Связанные страницы:
- [Material (`MAT0`)](material.md)
- [Wear table (`WEAR`)](wear.md)
- [Render pipeline](render.md)
## 1. Контейнер
- Тип ресурса: `0x6D786554` (`Texm`).
- Используется в `Textures.lib`, `LightMap.lib` и других `NRes` архивах.
## 2. Заголовок
```c
struct TexmHeader32 {
uint32_t magic; // 'Texm'
uint32_t width;
uint32_t height;
uint32_t mipCount;
uint32_t flags4;
uint32_t flags5;
uint32_t unk6;
uint32_t format;
};
```
## 3. Поддерживаемые форматы
Базовые форматы:
- `0` (8-bit indexed + palette)
- `565`
- `4444`
- `888`
- `8888`
Дополнительные ветки загрузки поддерживают также `556` и `88`.
## 4. Layout payload
1. `TexmHeader32` (32 байта)
2. palette `1024` байта, если `format == 0`
3. mip-chain пикселей
4. optional `Page` chunk
Расчёт ядра:
```c
bytesPerPixel =
(format == 0) ? 1 :
(format == 565 || format == 556 || format == 4444 || format == 88) ? 2 :
4;
pixelCount = sum(max(1, width>>i) * max(1, height>>i), i=0..mipCount-1);
sizeCore = 32 + (format==0 ? 1024 : 0) + bytesPerPixel * pixelCount;
```
## 4.1. Декодирование в RGBA8 (runtime/инструменты)
Для CPU-пути (preview, валидация, оффлайн-конвертация) используется декодирование:
- `0` (`Indexed8`): `index -> palette[index]` (`RGBA` из палитры 256×4).
- `565`: `R5 G6 B5`, `A=255`.
- `556`: `R5 G5 B6`, `A=255`.
- `4444`: `A4 R4 G4 B4` (с расширением 4-битных каналов в 8-битные).
- `88`: `L8 A8` (`R=G=B=L`).
- `888`: `R8 G8 B8` + padding/служебный байт, `A=255`.
- `8888`: `A8 R8 G8 B8`.
Это декодирование соответствует текущему test/demo pipeline проекта.
## 5. `Page` chunk
```c
struct PageChunk {
uint32_t magic; // 'Page'
uint32_t rectCount;
Rect16 rects[rectCount];
};
struct Rect16 {
int16_t x;
int16_t w;
int16_t y;
int16_t h;
};
```
`Page` задаёт atlas-прямоугольники для выборки под-областей текстуры.
## 6. Mip-skip политика
Загрузчик может пропускать первые mip-уровни в зависимости от:
- `flags5`,
- размеров текстуры,
- количества mip.
После `mipSkip`:
- уменьшаются `width/height/mipCount`;
- сдвигается начало пиксельных данных;
- `Page`-координаты пересчитываются в соответствии с новым базовым уровнем.
## 7. Палитры
Для части текстур движок связывает палитру по суффиксу имени.
Практический формат:
- буква `A..Z` + вариант `""` или `0..9`
- всего `26 * 11 = 286` возможных слотов палитр.
Невалидные суффиксы нужно считать ошибкой входных данных в инструментах.
## 8. Кэширование
Движок ведёт отдельные кэши:
- общий texture cache;
- lightmap cache.
Для обычных текстур используется отложенный сбор неиспользуемых слотов (по времени нулевого refcount).
## 9. Правила writer/editor
1. Не нормализовать `flags4/flags5/unk6`.
2. Сохранять payload без лишних хвостовых байт.
3. Если есть `Page`, его размер должен быть ровно `8 + rectCount * 8`.
4. Проверять `width > 0`, `height > 0`, `mipCount > 0`.
## 10. Статус валидации
- Инварианты `Texm` реализованы в `tools/msh_doc_validator.py`.
- В текущем окружении нет полного игрового набора текстур в `testdata`, поэтому массовая перепроверка не запускалась.

82
docs/specs/wear.md Normal file
View File

@@ -0,0 +1,82 @@
# Wear table (`WEAR`)
`WEAR` — текстовый ресурс, который связывает слоты wear с именами материалов и lightmap.
Связанные страницы:
- [Material (`MAT0`)](material.md)
- [Texture (`Texm`)](texture.md)
## 1. Контейнер
- Тип ресурса: `0x52414557` (`WEAR`).
- Обычно хранится как `*.wea` внутри world/mission архивов.
## 2. Формат текста
```text
<wearCount:int>
<legacyId:int> <materialName>
... (wearCount строк)
[пустая строка]
[LIGHTMAPS
<lightmapCount:int>
<legacyId:int> <lightmapName>
... (lightmapCount строк)]
```
`legacyId` читается, но логика выбора работает по имени.
## 3. Совместимость парсинга
В движке используются два режима чтения (`из файла` и `из буфера`), у которых различается обработка блока `LIGHTMAPS`.
Практическое правило для полного совпадения:
- если присутствует блок `LIGHTMAPS`, перед строкой `LIGHTMAPS` должна быть пустая строка-разделитель.
## 4. Runtime-ограничения
- Число wear-таблиц в менеджере ограничено: максимум `70`.
- Для `wearCount <= 0` ресурс считается некорректным.
- Для `LIGHTMAPS` блока `lightmapCount <= 0` — также ошибка формата.
## 5. Поведение резолва
### 5.1. Материал
Для каждого wear-слота:
1. Ищется материал по имени.
2. Если не найден — используется fallback (`DEFAULT`, затем индекс 0).
### 5.2. Lightmap
Для каждого lightmap-слота:
1. Ищется текстура lightmap по имени.
2. Если не найдено — слот получает `-1`.
## 6. Handle-кодирование
Движок кодирует ссылку на material-slot как:
```c
handle = (tableIndex << 16) | wearIndex
```
- `tableIndex` — номер wear-таблицы.
- `wearIndex` — индекс строки внутри таблицы.
## 7. Правила writer/editor
1. Сохранять порядок строк.
2. Не переставлять и не нормализовать `legacyId`.
3. Для совместимости buffer-парсинга сохранять пустую строку перед `LIGHTMAPS`.
4. Проверять, что число строк соответствует `wearCount`/`lightmapCount`.
## 8. Статус валидации
- Поведение `WEAR` согласовано с текущей спецификацией материалов/текстур и runtime-пайплайном.
- Массовый прогон по полному игровому набору в этом окружении не выполнялся из-за отсутствия корпуса данных в `testdata`.

View File

@@ -29,13 +29,18 @@ nav:
- Behavior system: specs/behavior.md
- Control system: specs/control.md
- FXID: specs/fxid.md
- Materials + Texm: specs/materials-texm.md
- Material (MAT0): specs/material.md
- Wear (WEAR): specs/wear.md
- Texture (Texm): specs/texture.md
- Materials index: specs/materials-texm.md
- Missions: specs/missions.md
- MSH animation: specs/msh-animation.md
- MSH core: specs/msh-core.md
- Network system: specs/network.md
- NRes / RsLi: specs/nres.md
- Runtime pipeline: specs/runtime-pipeline.md
- Render pipeline: specs/render.md
- Render parity: specs/render-parity.md
- Runtime pointer: specs/runtime-pipeline.md
- Sound system: specs/sound.md
- Terrain + map loading: specs/terrain-map-loading.md
- UI system: specs/ui.md

20
parity/README.md Normal file
View File

@@ -0,0 +1,20 @@
# Render Parity Dataset
This folder stores parity-test input for `crates/render-parity`.
- `cases.toml`: list of deterministic render cases.
- `reference/*.png`: baseline frames captured from the original renderer.
Expected workflow:
1. Capture baseline PNG frames from original game/editor for each case.
2. Add entries to `cases.toml`.
3. Run:
```bash
cargo run -p render-parity -- \
--manifest parity/cases.toml \
--output-dir target/render-parity/current
```
On failure, diff images are saved to `target/render-parity/current/diff`.

27
parity/cases.toml Normal file
View File

@@ -0,0 +1,27 @@
[meta]
# Global defaults for all cases.
width = 1280
height = 720
lod = 0
group = 0
angle = 0.0
# Per-pixel change threshold for the "changed pixel ratio" metric.
diff_threshold = 8
# Allowed thresholds (case fails if any limit is exceeded).
max_mean_abs = 2.0
max_changed_ratio = 0.010
# Add one block per model.
#
# [[case]]
# id = "animals_a_l_01"
# archive = "../testdata/Parkan - Iron Strategy/animals.rlb"
# model = "A_L_01.msh"
# reference = "reference/animals_a_l_01.png"
# lod = 0
# group = 0
# angle = 0.0
# max_mean_abs = 2.0
# max_changed_ratio = 0.010

View File

262
tools/fxid_abs100_audit.py Normal file
View File

@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""
Deterministic audit for FXID "absolute parity" checklist.
What this script produces:
1) strict parsing stats across all FXID payloads in NRes archives,
2) opcode histogram and rare-branch counters (op6, op1 tail usage),
3) reference vectors for RNG core (sub_10002220 semantics).
"""
from __future__ import annotations
import argparse
import json
import struct
from collections import Counter
from pathlib import Path
from typing import Any
import archive_roundtrip_validator as arv
TYPE_FXID = 0x44495846
FX_CMD_SIZE = {1: 224, 2: 148, 3: 200, 4: 204, 5: 112, 6: 4, 7: 208, 8: 248, 9: 208, 10: 208}
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 _cstr32(raw: bytes) -> str:
return raw.split(b"\x00", 1)[0].decode("latin1", errors="replace")
def _rng_step_sub_10002220(state32: int) -> tuple[int, int]:
"""
sub_10002220 semantics in 32-bit packed state form:
lo = state[15:0], hi = state[31:16]
new_lo = hi ^ (lo << 1)
new_hi = (hi >> 1) ^ new_lo
return new_hi (u16), update state=(new_hi<<16)|new_lo
"""
lo = state32 & 0xFFFF
hi = (state32 >> 16) & 0xFFFF
new_lo = (hi ^ ((lo << 1) & 0xFFFF)) & 0xFFFF
new_hi = ((hi >> 1) ^ new_lo) & 0xFFFF
return ((new_hi << 16) | new_lo), new_hi
def _rng_vectors() -> dict[str, Any]:
seeds = [0x00000000, 0x00000001, 0x12345678, 0x89ABCDEF, 0xFFFFFFFF]
out: list[dict[str, Any]] = []
for seed in seeds:
state = seed
outputs: list[int] = []
states: list[int] = []
for _ in range(16):
state, value = _rng_step_sub_10002220(state)
outputs.append(value)
states.append(state)
out.append(
{
"seed_hex": f"0x{seed:08X}",
"outputs_u16_hex": [f"0x{x:04X}" for x in outputs],
"states_u32_hex": [f"0x{x:08X}" for x in states],
}
)
return {"generator": "sub_10002220", "vectors": out}
def run_audit(root: Path) -> dict[str, Any]:
counters: Counter[str] = Counter()
opcode_hist: Counter[int] = Counter()
issues: list[dict[str, Any]] = []
op1_tail6_samples: list[dict[str, Any]] = []
op1_optref_samples: list[dict[str, Any]] = []
for item in arv.scan_archives(root):
if item["type"] != "nres":
continue
archive_path = root / item["relative_path"]
counters["archives_total"] += 1
data = archive_path.read_bytes()
try:
parsed = arv.parse_nres(data, source=str(archive_path))
except Exception as exc: # pylint: disable=broad-except
issues.append(
{
"severity": "error",
"archive": str(archive_path),
"entry": None,
"message": f"cannot parse NRes: {exc}",
}
)
continue
for entry in parsed["entries"]:
if int(entry["type_id"]) != TYPE_FXID:
continue
counters["fxid_total"] += 1
payload = _entry_payload(data, entry)
entry_name = str(entry["name"])
if len(payload) < 60:
issues.append(
{
"severity": "error",
"archive": str(archive_path),
"entry": entry_name,
"message": f"payload too small: {len(payload)}",
}
)
continue
cmd_count = struct.unpack_from("<I", payload, 0)[0]
ptr = 0x3C
ok = True
for idx in range(cmd_count):
if ptr + 4 > len(payload):
issues.append(
{
"severity": "error",
"archive": str(archive_path),
"entry": entry_name,
"message": f"command {idx}: missing header at offset={ptr}",
}
)
ok = False
break
word = struct.unpack_from("<I", payload, ptr)[0]
opcode = word & 0xFF
size = FX_CMD_SIZE.get(opcode)
if size is None:
issues.append(
{
"severity": "error",
"archive": str(archive_path),
"entry": entry_name,
"message": f"command {idx}: unknown opcode={opcode} at offset={ptr}",
}
)
ok = False
break
if ptr + size > len(payload):
issues.append(
{
"severity": "error",
"archive": str(archive_path),
"entry": entry_name,
"message": f"command {idx}: truncated end={ptr + size}, payload={len(payload)}",
}
)
ok = False
break
opcode_hist[opcode] += 1
if opcode == 6:
counters["op6_commands"] += 1
if opcode == 1:
tail6 = payload[ptr + 136 : ptr + 160]
if any(tail6):
counters["op1_tail6_nonzero"] += 1
if len(op1_tail6_samples) < 16:
dwords = list(struct.unpack("<6I", tail6))
op1_tail6_samples.append(
{
"archive": str(archive_path),
"entry": entry_name,
"cmd_index": idx,
"tail6_u32_hex": [f"0x{x:08X}" for x in dwords],
}
)
archive_s = _cstr32(payload[ptr + 160 : ptr + 192])
name_s = _cstr32(payload[ptr + 192 : ptr + 224])
if archive_s or name_s:
counters["op1_optref_nonempty"] += 1
if len(op1_optref_samples) < 16:
op1_optref_samples.append(
{
"archive": str(archive_path),
"entry": entry_name,
"cmd_index": idx,
"opt_archive": archive_s,
"opt_name": name_s,
}
)
ptr += size
if ok and ptr != len(payload):
issues.append(
{
"severity": "error",
"archive": str(archive_path),
"entry": entry_name,
"message": f"tail bytes after command stream: parsed_end={ptr}, payload={len(payload)}",
}
)
ok = False
if ok:
counters["fxid_ok"] += 1
return {
"input_root": str(root),
"summary": {
"archives_total": counters["archives_total"],
"fxid_total": counters["fxid_total"],
"fxid_ok": counters["fxid_ok"],
"issues_total": len(issues),
"op6_commands": counters["op6_commands"],
"op1_tail6_nonzero": counters["op1_tail6_nonzero"],
"op1_optref_nonempty": counters["op1_optref_nonempty"],
},
"opcode_histogram": {str(k): opcode_hist[k] for k in sorted(opcode_hist)},
"op1_tail6_samples": op1_tail6_samples,
"op1_optref_samples": op1_optref_samples,
"rng_reference": _rng_vectors(),
"rng_states_fx_path": [
{"state": "dword_10023688", "seed_init": "sub_10002660", "used_by": ["sub_10001720", "sub_10001A40"]},
{"state": "dword_100238C0", "seed_init": "sub_10003A50", "used_by": ["sub_10002BE0"]},
{"state": "dword_10024110", "seed_init": "sub_10009180", "used_by": ["sub_10008120", "sub_10007D10"]},
{"state": "dword_10024810", "seed_init": "sub_1000D370", "used_by": ["sub_1000BF30", "sub_1000C1A0"]},
{"state": "dword_10024A48", "seed_init": "sub_1000F420", "used_by": ["sub_1000EC50"]},
{"state": "dword_10024C80", "seed_init": "sub_10010370", "used_by": ["sub_1000F6E0"]},
{"state": "dword_100250F0", "seed_init": "sub_10012C70", "used_by": ["sub_10011230", "sub_100115C0"]},
],
"issues": issues,
}
def main() -> int:
parser = argparse.ArgumentParser(description="FXID absolute parity audit.")
parser.add_argument("--input", required=True, help="Root directory with game/test archives.")
parser.add_argument("--report", required=True, help="Output JSON report path.")
args = parser.parse_args()
root = Path(args.input).resolve()
report_path = Path(args.report).resolve()
payload = run_audit(root)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
summary = payload["summary"]
print(f"Input root : {root}")
print(f"NRes archives : {summary['archives_total']}")
print(f"FXID payloads : {summary['fxid_ok']}/{summary['fxid_total']} valid")
print(f"Issues : {summary['issues_total']}")
print(f"Opcode6 commands : {summary['op6_commands']}")
print(f"Op1 tail6 nonzero : {summary['op1_tail6_nonzero']}")
print(f"Op1 optref non-empty : {summary['op1_optref_nonempty']}")
print(f"Report : {report_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,809 @@
#!/usr/bin/env python3
"""
Validate terrain/map documentation assumptions against real game data.
Targets:
- tmp/gamedata/DATA/MAPS/**/Land.msh
- tmp/gamedata/DATA/MAPS/**/Land.map
"""
from __future__ import annotations
import argparse
import json
import math
import struct
from collections import Counter, defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import archive_roundtrip_validator as arv
MAGIC_NRES = b"NRes"
REQUIRED_MSH_TYPES = (1, 2, 3, 4, 5, 11, 18, 21)
OPTIONAL_MSH_TYPES = (14,)
EXPECTED_MSH_ORDER = (1, 2, 3, 4, 5, 18, 14, 11, 21)
MSH_STRIDES = {
1: 38,
3: 12,
4: 4,
5: 4,
11: 4,
14: 4,
18: 4,
21: 28,
}
SLOT_TABLE_OFFSET = 0x8C
@dataclass
class ValidationIssue:
severity: str # error | warning
category: str
resource: str
message: str
class TerrainMapDocValidator:
def __init__(self) -> None:
self.issues: list[ValidationIssue] = []
self.stats: dict[str, Any] = {
"maps_total": 0,
"msh_total": 0,
"map_total": 0,
"msh_type_orders": Counter(),
"msh_attr_triplets": defaultdict(Counter), # type_id -> Counter[(a1,a2,a3)]
"msh_type11_header_words": Counter(),
"msh_type21_flags_top": Counter(),
"map_logic_flags": Counter(),
"map_class_ids": Counter(), # record +40
"map_poly_count": Counter(),
"map_vertex_count_min": None,
"map_vertex_count_max": None,
"map_cell_dims": Counter(),
"map_reserved_u12": Counter(),
"map_reserved_u36": Counter(),
"map_reserved_u44": Counter(),
"map_area_delta_abs_max": 0.0,
"map_area_delta_rel_max": 0.0,
"map_area_rel_gt_05_count": 0,
"map_normal_len_min": None,
"map_normal_len_max": None,
"map_records_total": 0,
}
def add_issue(self, severity: str, category: str, resource: Path, message: str) -> None:
self.issues.append(
ValidationIssue(
severity=severity,
category=category,
resource=str(resource),
message=message,
)
)
def _entry_payload(self, blob: bytes, entry: dict[str, Any]) -> bytes:
start = int(entry["data_offset"])
end = start + int(entry["size"])
return blob[start:end]
def _entry_by_type(self, entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]:
by_type: dict[int, list[dict[str, Any]]] = {}
for item in entries:
by_type.setdefault(int(item["type_id"]), []).append(item)
return by_type
def _expect_single_type(
self,
*,
by_type: dict[int, list[dict[str, Any]]],
type_id: int,
label: str,
resource: Path,
required: bool,
) -> dict[str, Any] | None:
rows = by_type.get(type_id, [])
if not rows:
if required:
self.add_issue(
"error",
"msh-chunk",
resource,
f"missing required chunk type={type_id} ({label})",
)
return None
if len(rows) > 1:
self.add_issue(
"warning",
"msh-chunk",
resource,
f"multiple chunks type={type_id} ({label}); using first",
)
return rows[0]
def _check_stride(
self,
*,
resource: Path,
entry: dict[str, Any],
stride: int,
label: str,
) -> int:
size = int(entry["size"])
attr1 = int(entry["attr1"])
attr2 = int(entry["attr2"])
attr3 = int(entry["attr3"])
self.stats["msh_attr_triplets"][int(entry["type_id"])][(attr1, attr2, attr3)] += 1
if size % stride != 0:
self.add_issue(
"error",
"msh-stride",
resource,
f"{label}: size={size} is not divisible by stride={stride}",
)
return -1
count = size // stride
if attr1 != count:
self.add_issue(
"error",
"msh-attr",
resource,
f"{label}: attr1={attr1} != size/stride={count}",
)
if attr3 != stride:
self.add_issue(
"error",
"msh-attr",
resource,
f"{label}: attr3={attr3} != {stride}",
)
if attr2 != 0 and int(entry["type_id"]) not in (1,):
# type 1 has non-zero attr2 in real assets, others are expected zero.
self.add_issue(
"warning",
"msh-attr",
resource,
f"{label}: attr2={attr2} (expected 0 for this chunk type)",
)
return count
def validate_msh(self, path: Path) -> None:
self.stats["msh_total"] += 1
blob = path.read_bytes()
if blob[:4] != MAGIC_NRES:
self.add_issue("error", "msh-container", path, "file is not NRes")
return
try:
parsed = arv.parse_nres(blob, source=str(path))
except Exception as exc: # pylint: disable=broad-except
self.add_issue("error", "msh-container", path, f"failed to parse NRes: {exc}")
return
for issue in parsed.get("issues", []):
self.add_issue("warning", "msh-nres", path, issue)
entries = parsed["entries"]
types_order = tuple(int(item["type_id"]) for item in entries)
self.stats["msh_type_orders"][types_order] += 1
if types_order != EXPECTED_MSH_ORDER:
self.add_issue(
"warning",
"msh-order",
path,
f"unexpected chunk order {types_order}, expected {EXPECTED_MSH_ORDER}",
)
by_type = self._entry_by_type(entries)
chunks: dict[int, dict[str, Any]] = {}
for type_id in REQUIRED_MSH_TYPES:
chunk = self._expect_single_type(
by_type=by_type,
type_id=type_id,
label=f"type{type_id}",
resource=path,
required=True,
)
if chunk:
chunks[type_id] = chunk
for type_id in OPTIONAL_MSH_TYPES:
chunk = self._expect_single_type(
by_type=by_type,
type_id=type_id,
label=f"type{type_id}",
resource=path,
required=False,
)
if chunk:
chunks[type_id] = chunk
for type_id, stride in MSH_STRIDES.items():
chunk = chunks.get(type_id)
if not chunk:
continue
self._check_stride(resource=path, entry=chunk, stride=stride, label=f"type{type_id}")
# type 2 includes 0x8C-byte header + 68-byte slot table entries.
type2 = chunks.get(2)
if type2:
size = int(type2["size"])
attr1 = int(type2["attr1"])
attr2 = int(type2["attr2"])
attr3 = int(type2["attr3"])
self.stats["msh_attr_triplets"][2][(attr1, attr2, attr3)] += 1
if attr3 != 68:
self.add_issue(
"error",
"msh-attr",
path,
f"type2: attr3={attr3} != 68",
)
if attr2 != 0:
self.add_issue(
"warning",
"msh-attr",
path,
f"type2: attr2={attr2} (expected 0)",
)
if size < SLOT_TABLE_OFFSET:
self.add_issue(
"error",
"msh-size",
path,
f"type2: size={size} < header_size={SLOT_TABLE_OFFSET}",
)
elif (size - SLOT_TABLE_OFFSET) % 68 != 0:
self.add_issue(
"error",
"msh-size",
path,
f"type2: (size - 0x8C) is not divisible by 68 (size={size})",
)
else:
slots_by_size = (size - SLOT_TABLE_OFFSET) // 68
if attr1 != slots_by_size:
self.add_issue(
"error",
"msh-attr",
path,
f"type2: attr1={attr1} != (size-0x8C)/68={slots_by_size}",
)
verts = chunks.get(3)
face = chunks.get(21)
slots = chunks.get(2)
nodes = chunks.get(1)
type11 = chunks.get(11)
if verts and face:
vcount = int(verts["attr1"])
face_payload = self._entry_payload(blob, face)
fcount = int(face["attr1"])
if len(face_payload) >= 28:
for idx in range(fcount):
off = idx * 28
if off + 28 > len(face_payload):
self.add_issue(
"error",
"msh-face",
path,
f"type21 truncated at face {idx}",
)
break
flags = struct.unpack_from("<I", face_payload, off)[0]
self.stats["msh_type21_flags_top"][flags] += 1
i0, i1, i2 = struct.unpack_from("<HHH", face_payload, off + 8)
for name, value in (("i0", i0), ("i1", i1), ("i2", i2)):
if value >= vcount:
self.add_issue(
"error",
"msh-face-index",
path,
f"type21[{idx}].{name}={value} out of range vertex_count={vcount}",
)
n0, n1, n2 = struct.unpack_from("<HHH", face_payload, off + 14)
for name, value in (("n0", n0), ("n1", n1), ("n2", n2)):
if value != 0xFFFF and value >= fcount:
self.add_issue(
"error",
"msh-face-neighbour",
path,
f"type21[{idx}].{name}={value} out of range face_count={fcount}",
)
if slots and face:
slot_count = int(slots["attr1"])
face_count = int(face["attr1"])
slot_payload = self._entry_payload(blob, slots)
need = SLOT_TABLE_OFFSET + slot_count * 68
if len(slot_payload) < need:
self.add_issue(
"error",
"msh-slot",
path,
f"type2 payload too short: size={len(slot_payload)}, need_at_least={need}",
)
else:
if len(slot_payload) != need:
self.add_issue(
"warning",
"msh-slot",
path,
f"type2 payload has trailing bytes: size={len(slot_payload)}, expected={need}",
)
for idx in range(slot_count):
off = SLOT_TABLE_OFFSET + idx * 68
tri_start, tri_count = struct.unpack_from("<HH", slot_payload, off)
if tri_start + tri_count > face_count:
self.add_issue(
"error",
"msh-slot-range",
path,
f"type2 slot[{idx}] range [{tri_start}, {tri_start + tri_count}) exceeds face_count={face_count}",
)
if nodes and slots:
node_payload = self._entry_payload(blob, nodes)
slot_count = int(slots["attr1"])
node_count = int(nodes["attr1"])
for node_idx in range(node_count):
off = node_idx * 38
if off + 38 > len(node_payload):
self.add_issue(
"error",
"msh-node",
path,
f"type1 truncated at node {node_idx}",
)
break
for j in range(19):
slot_id = struct.unpack_from("<H", node_payload, off + j * 2)[0]
if slot_id != 0xFFFF and slot_id >= slot_count:
self.add_issue(
"error",
"msh-node-slot",
path,
f"type1 node[{node_idx}] slot[{j}]={slot_id} out of range slot_count={slot_count}",
)
if type11:
payload = self._entry_payload(blob, type11)
if len(payload) >= 8:
w0, w1 = struct.unpack_from("<II", payload, 0)
self.stats["msh_type11_header_words"][(w0, w1)] += 1
else:
self.add_issue(
"error",
"msh-type11",
path,
f"type11 payload too short: {len(payload)}",
)
def _update_minmax(self, key_min: str, key_max: str, value: float) -> None:
if self.stats[key_min] is None or value < self.stats[key_min]:
self.stats[key_min] = value
if self.stats[key_max] is None or value > self.stats[key_max]:
self.stats[key_max] = value
def validate_map(self, path: Path) -> None:
self.stats["map_total"] += 1
blob = path.read_bytes()
if blob[:4] != MAGIC_NRES:
self.add_issue("error", "map-container", path, "file is not NRes")
return
try:
parsed = arv.parse_nres(blob, source=str(path))
except Exception as exc: # pylint: disable=broad-except
self.add_issue("error", "map-container", path, f"failed to parse NRes: {exc}")
return
for issue in parsed.get("issues", []):
self.add_issue("warning", "map-nres", path, issue)
entries = parsed["entries"]
if len(entries) != 1 or int(entries[0]["type_id"]) != 12:
self.add_issue(
"error",
"map-chunk",
path,
f"expected single chunk type=12, got {[int(e['type_id']) for e in entries]}",
)
return
entry = entries[0]
areal_count = int(entry["attr1"])
if areal_count <= 0:
self.add_issue("error", "map-areal", path, f"invalid areal_count={areal_count}")
return
payload = self._entry_payload(blob, entry)
ptr = 0
records: list[dict[str, Any]] = []
for idx in range(areal_count):
if ptr + 56 > len(payload):
self.add_issue(
"error",
"map-record",
path,
f"truncated areal header at index={idx}, ptr={ptr}, size={len(payload)}",
)
return
anchor_x, anchor_y, anchor_z = struct.unpack_from("<fff", payload, ptr)
u12 = struct.unpack_from("<I", payload, ptr + 12)[0]
area_f = struct.unpack_from("<f", payload, ptr + 16)[0]
nx, ny, nz = struct.unpack_from("<fff", payload, ptr + 20)
logic_flag = struct.unpack_from("<I", payload, ptr + 32)[0]
u36 = struct.unpack_from("<I", payload, ptr + 36)[0]
class_id = struct.unpack_from("<I", payload, ptr + 40)[0]
u44 = struct.unpack_from("<I", payload, ptr + 44)[0]
vertex_count, poly_count = struct.unpack_from("<II", payload, ptr + 48)
self.stats["map_records_total"] += 1
self.stats["map_logic_flags"][logic_flag] += 1
self.stats["map_class_ids"][class_id] += 1
self.stats["map_poly_count"][poly_count] += 1
self.stats["map_reserved_u12"][u12] += 1
self.stats["map_reserved_u36"][u36] += 1
self.stats["map_reserved_u44"][u44] += 1
self._update_minmax("map_vertex_count_min", "map_vertex_count_max", float(vertex_count))
normal_len = math.sqrt(nx * nx + ny * ny + nz * nz)
self._update_minmax("map_normal_len_min", "map_normal_len_max", normal_len)
if abs(normal_len - 1.0) > 1e-3:
self.add_issue(
"warning",
"map-normal",
path,
f"record[{idx}] normal length={normal_len:.6f} (expected ~1.0)",
)
vertices_off = ptr + 56
vertices_size = 12 * vertex_count
if vertices_off + vertices_size > len(payload):
self.add_issue(
"error",
"map-vertices",
path,
f"record[{idx}] vertices out of bounds",
)
return
vertices: list[tuple[float, float, float]] = []
for i in range(vertex_count):
vertices.append(struct.unpack_from("<fff", payload, vertices_off + i * 12))
if vertex_count >= 3:
# signed shoelace area in XY.
shoelace = 0.0
for i in range(vertex_count):
x1, y1, _ = vertices[i]
x2, y2, _ = vertices[(i + 1) % vertex_count]
shoelace += x1 * y2 - x2 * y1
area_xy = abs(shoelace) * 0.5
delta = abs(area_xy - area_f)
if delta > self.stats["map_area_delta_abs_max"]:
self.stats["map_area_delta_abs_max"] = delta
rel_delta = delta / max(1.0, area_xy)
if rel_delta > self.stats["map_area_delta_rel_max"]:
self.stats["map_area_delta_rel_max"] = rel_delta
if rel_delta > 0.05:
self.stats["map_area_rel_gt_05_count"] += 1
links_off = vertices_off + vertices_size
link_count = vertex_count + 3 * poly_count
links_size = 8 * link_count
if links_off + links_size > len(payload):
self.add_issue(
"error",
"map-links",
path,
f"record[{idx}] link table out of bounds",
)
return
edge_links: list[tuple[int, int]] = []
for i in range(vertex_count):
area_ref, edge_ref = struct.unpack_from("<ii", payload, links_off + i * 8)
edge_links.append((area_ref, edge_ref))
poly_links_off = links_off + 8 * vertex_count
poly_links: list[tuple[int, int]] = []
for i in range(3 * poly_count):
area_ref, edge_ref = struct.unpack_from("<ii", payload, poly_links_off + i * 8)
poly_links.append((area_ref, edge_ref))
p = links_off + links_size
for poly_idx in range(poly_count):
if p + 4 > len(payload):
self.add_issue(
"error",
"map-poly",
path,
f"record[{idx}] poly header truncated at poly_idx={poly_idx}",
)
return
n = struct.unpack_from("<I", payload, p)[0]
poly_size = 4 * (3 * n + 1)
if p + poly_size > len(payload):
self.add_issue(
"error",
"map-poly",
path,
f"record[{idx}] poly data out of bounds at poly_idx={poly_idx}",
)
return
p += poly_size
records.append(
{
"index": idx,
"anchor": (anchor_x, anchor_y, anchor_z),
"logic": logic_flag,
"class_id": class_id,
"vertex_count": vertex_count,
"poly_count": poly_count,
"edge_links": edge_links,
"poly_links": poly_links,
}
)
ptr = p
vertex_counts = [int(item["vertex_count"]) for item in records]
for rec in records:
idx = int(rec["index"])
for link_idx, (area_ref, edge_ref) in enumerate(rec["edge_links"]):
if area_ref == -1:
if edge_ref != -1:
self.add_issue(
"warning",
"map-link",
path,
f"record[{idx}] edge_link[{link_idx}] has area_ref=-1 but edge_ref={edge_ref}",
)
continue
if area_ref < 0 or area_ref >= areal_count:
self.add_issue(
"error",
"map-link",
path,
f"record[{idx}] edge_link[{link_idx}] area_ref={area_ref} out of range",
)
continue
dst_vcount = vertex_counts[area_ref]
if edge_ref < 0 or edge_ref >= dst_vcount:
self.add_issue(
"error",
"map-link",
path,
f"record[{idx}] edge_link[{link_idx}] edge_ref={edge_ref} out of range dst_vertex_count={dst_vcount}",
)
for link_idx, (area_ref, edge_ref) in enumerate(rec["poly_links"]):
if area_ref == -1:
if edge_ref != -1:
self.add_issue(
"warning",
"map-poly-link",
path,
f"record[{idx}] poly_link[{link_idx}] has area_ref=-1 but edge_ref={edge_ref}",
)
continue
if area_ref < 0 or area_ref >= areal_count:
self.add_issue(
"error",
"map-poly-link",
path,
f"record[{idx}] poly_link[{link_idx}] area_ref={area_ref} out of range",
)
if ptr + 8 > len(payload):
self.add_issue(
"error",
"map-cells",
path,
f"missing cells header at ptr={ptr}, size={len(payload)}",
)
return
cells_x, cells_y = struct.unpack_from("<II", payload, ptr)
self.stats["map_cell_dims"][(cells_x, cells_y)] += 1
ptr += 8
if cells_x <= 0 or cells_y <= 0:
self.add_issue(
"error",
"map-cells",
path,
f"invalid cells dimensions {cells_x}x{cells_y}",
)
return
for x in range(cells_x):
for y in range(cells_y):
if ptr + 2 > len(payload):
self.add_issue(
"error",
"map-cells",
path,
f"truncated hitCount at cell ({x},{y})",
)
return
hit_count = struct.unpack_from("<H", payload, ptr)[0]
ptr += 2
need = 2 * hit_count
if ptr + need > len(payload):
self.add_issue(
"error",
"map-cells",
path,
f"truncated areaIds at cell ({x},{y}), hitCount={hit_count}",
)
return
for i in range(hit_count):
area_id = struct.unpack_from("<H", payload, ptr + 2 * i)[0]
if area_id >= areal_count:
self.add_issue(
"error",
"map-cells",
path,
f"cell ({x},{y}) has area_id={area_id} out of range areal_count={areal_count}",
)
ptr += need
if ptr != len(payload):
self.add_issue(
"error",
"map-size",
path,
f"payload tail mismatch: consumed={ptr}, payload_size={len(payload)}",
)
def validate(self, maps_root: Path) -> None:
msh_paths = sorted(maps_root.rglob("Land.msh"))
map_paths = sorted(maps_root.rglob("Land.map"))
msh_by_dir = {path.parent: path for path in msh_paths}
map_by_dir = {path.parent: path for path in map_paths}
all_dirs = sorted(set(msh_by_dir) | set(map_by_dir))
self.stats["maps_total"] = len(all_dirs)
for folder in all_dirs:
msh_path = msh_by_dir.get(folder)
map_path = map_by_dir.get(folder)
if msh_path is None:
self.add_issue("error", "pairing", folder, "missing Land.msh")
continue
if map_path is None:
self.add_issue("error", "pairing", folder, "missing Land.map")
continue
self.validate_msh(msh_path)
self.validate_map(map_path)
def build_report(self) -> dict[str, Any]:
errors = [i for i in self.issues if i.severity == "error"]
warnings = [i for i in self.issues if i.severity == "warning"]
# Convert counters/defaultdicts to JSON-friendly dicts.
msh_orders = {
str(list(order)): count
for order, count in self.stats["msh_type_orders"].most_common()
}
msh_attrs = {
str(type_id): {str(list(k)): v for k, v in counter.most_common()}
for type_id, counter in self.stats["msh_attr_triplets"].items()
}
type11_hdr = {
str(list(key)): value
for key, value in self.stats["msh_type11_header_words"].most_common()
}
type21_flags = {
f"0x{key:08X}": value
for key, value in self.stats["msh_type21_flags_top"].most_common(32)
}
return {
"summary": {
"maps_total": self.stats["maps_total"],
"msh_total": self.stats["msh_total"],
"map_total": self.stats["map_total"],
"issues_total": len(self.issues),
"errors_total": len(errors),
"warnings_total": len(warnings),
},
"stats": {
"msh_type_orders": msh_orders,
"msh_attr_triplets": msh_attrs,
"msh_type11_header_words": type11_hdr,
"msh_type21_flags_top": type21_flags,
"map_logic_flags": dict(self.stats["map_logic_flags"]),
"map_class_ids": dict(self.stats["map_class_ids"]),
"map_poly_count": dict(self.stats["map_poly_count"]),
"map_vertex_count_min": self.stats["map_vertex_count_min"],
"map_vertex_count_max": self.stats["map_vertex_count_max"],
"map_cell_dims": {str(list(k)): v for k, v in self.stats["map_cell_dims"].items()},
"map_reserved_u12": dict(self.stats["map_reserved_u12"]),
"map_reserved_u36": dict(self.stats["map_reserved_u36"]),
"map_reserved_u44": dict(self.stats["map_reserved_u44"]),
"map_area_delta_abs_max": self.stats["map_area_delta_abs_max"],
"map_area_delta_rel_max": self.stats["map_area_delta_rel_max"],
"map_area_rel_gt_05_count": self.stats["map_area_rel_gt_05_count"],
"map_normal_len_min": self.stats["map_normal_len_min"],
"map_normal_len_max": self.stats["map_normal_len_max"],
"map_records_total": self.stats["map_records_total"],
},
"issues": [
{
"severity": item.severity,
"category": item.category,
"resource": item.resource,
"message": item.message,
}
for item in self.issues
],
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Validate terrain/map doc assumptions")
parser.add_argument(
"--maps-root",
type=Path,
default=Path("tmp/gamedata/DATA/MAPS"),
help="Root directory containing MAPS/**/Land.msh and Land.map",
)
parser.add_argument(
"--report-json",
type=Path,
default=None,
help="Optional path to save full JSON report",
)
parser.add_argument(
"--fail-on-warning",
action="store_true",
help="Return non-zero exit code on warnings too",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
validator = TerrainMapDocValidator()
validator.validate(args.maps_root)
report = validator.build_report()
print(
json.dumps(
report["summary"],
indent=2,
ensure_ascii=False,
)
)
if args.report_json:
args.report_json.parent.mkdir(parents=True, exist_ok=True)
with args.report_json.open("w", encoding="utf-8") as handle:
json.dump(report, handle, indent=2, ensure_ascii=False)
handle.write("\n")
print(f"report written: {args.report_json}")
has_errors = report["summary"]["errors_total"] > 0
has_warnings = report["summary"]["warnings_total"] > 0
if has_errors:
return 1
if args.fail_on_warning and has_warnings:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,679 @@
#!/usr/bin/env python3
"""
Software 3D renderer for terrain Land.msh + Land.map overlay.
Output format: binary PPM (P6), dependency-free.
"""
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 _downsample_faces(
faces: list[tuple[int, int, int]],
max_faces: int,
) -> list[tuple[int, int, int]]:
if max_faces <= 0 or len(faces) <= max_faces:
return faces
step = len(faces) / max_faces
out: list[tuple[int, int, int]] = []
pos = 0.0
while len(out) < max_faces and int(pos) < len(faces):
out.append(faces[int(pos)])
pos += step
return out
def load_terrain_msh(
path: Path,
*,
max_faces: int,
) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]], dict[str, int]]:
blob = path.read_bytes()
parsed = _parse_nres(blob, str(path))
by_type = _by_type(parsed["entries"])
res3 = _get_single(by_type, 3, "positions")
res21 = _get_single(by_type, 21, "terrain faces")
pos_blob = _entry_payload(blob, res3)
if len(pos_blob) % 12 != 0:
raise RuntimeError(f"{path}: type 3 payload size is not divisible by 12")
vertex_count = len(pos_blob) // 12
positions = [struct.unpack_from("<3f", pos_blob, i * 12) for i in range(vertex_count)]
face_blob = _entry_payload(blob, res21)
if len(face_blob) % 28 != 0:
raise RuntimeError(f"{path}: type 21 payload size is not divisible by 28")
all_faces: list[tuple[int, int, int]] = []
raw_face_count = len(face_blob) // 28
dropped = 0
for i in range(raw_face_count):
off = i * 28
i0, i1, i2 = struct.unpack_from("<HHH", face_blob, off + 8)
if i0 >= vertex_count or i1 >= vertex_count or i2 >= vertex_count:
dropped += 1
continue
all_faces.append((i0, i1, i2))
faces = _downsample_faces(all_faces, max_faces)
meta = {
"vertex_count": vertex_count,
"face_count_raw": raw_face_count,
"face_count_valid": len(all_faces),
"face_count_rendered": len(faces),
"face_dropped_invalid": dropped,
}
return positions, faces, meta
def load_areal_map(path: Path) -> tuple[list[dict[str, Any]], dict[str, int]]:
blob = path.read_bytes()
parsed = _parse_nres(blob, str(path))
by_type = _by_type(parsed["entries"])
chunk = _get_single(by_type, 12, "ArealMapGeometry")
payload = _entry_payload(blob, chunk)
areal_count = int(chunk["attr1"])
ptr = 0
areals: list[dict[str, Any]] = []
for idx in range(areal_count):
if ptr + 56 > len(payload):
raise RuntimeError(f"{path}: truncated areal header at index={idx}")
class_id = struct.unpack_from("<I", payload, ptr + 40)[0]
vertex_count, poly_count = struct.unpack_from("<II", payload, ptr + 48)
verts_off = ptr + 56
verts_size = 12 * vertex_count
if verts_off + verts_size > len(payload):
raise RuntimeError(f"{path}: areal[{idx}] vertices out of bounds")
verts = [struct.unpack_from("<3f", payload, verts_off + 12 * i) for i in range(vertex_count)]
links_off = verts_off + verts_size
links_size = 8 * (vertex_count + 3 * poly_count)
p = links_off + links_size
for _ in range(poly_count):
if p + 4 > len(payload):
raise RuntimeError(f"{path}: areal[{idx}] poly header out of bounds")
n = struct.unpack_from("<I", payload, p)[0]
p += 4 * (3 * n + 1)
if p > len(payload):
raise RuntimeError(f"{path}: areal[{idx}] poly data out of bounds")
areals.append(
{
"index": idx,
"class_id": class_id,
"vertices": verts,
}
)
ptr = p
if ptr + 8 > len(payload):
raise RuntimeError(f"{path}: missing cells section")
cells_x, cells_y = struct.unpack_from("<II", payload, ptr)
ptr += 8
for _x in range(cells_x):
for _y in range(cells_y):
if ptr + 2 > len(payload):
raise RuntimeError(f"{path}: cells section truncated")
hit_count = struct.unpack_from("<H", payload, ptr)[0]
ptr += 2 + 2 * hit_count
if ptr > len(payload):
raise RuntimeError(f"{path}: cells section out of bounds")
if ptr != len(payload):
raise RuntimeError(f"{path}: trailing bytes in chunk12 parse ({len(payload) - ptr})")
meta = {
"areal_count": areal_count,
"cells_x": cells_x,
"cells_y": cells_y,
}
return areals, meta
def _color_for_class(class_id: int) -> tuple[int, int, int]:
x = (class_id * 1103515245 + 12345) & 0x7FFFFFFF
r = 60 + (x & 0x7F)
g = 60 + ((x >> 7) & 0x7F)
b = 60 + ((x >> 14) & 0x7F)
return r, g, b
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 _write_obj(
path: Path,
terrain_positions: list[tuple[float, float, float]],
terrain_faces: list[tuple[int, int, int]],
areals: list[dict[str, Any]],
*,
include_areals: bool,
) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="\n") as out:
out.write("# Exported by terrain_map_preview_renderer.py\n")
out.write("o terrain\n")
for x, y, z in terrain_positions:
out.write(f"v {x:.9g} {y:.9g} {z:.9g}\n")
for i0, i1, i2 in terrain_faces:
# OBJ indices are 1-based.
out.write(f"f {i0 + 1} {i1 + 1} {i2 + 1}\n")
if include_areals and areals:
base = len(terrain_positions)
area_vertex_counts: list[int] = []
out.write("o areal_edges\n")
for area in areals:
verts = area["vertices"]
area_vertex_counts.append(len(verts))
for x, y, z in verts:
out.write(f"v {x:.9g} {y:.9g} {z:.9g}\n")
ptr = base
for area_idx, area in enumerate(areals):
cnt = area_vertex_counts[area_idx]
if cnt < 2:
ptr += cnt
continue
# closed polyline.
line = [str(ptr + i + 1) for i in range(cnt)]
line.append(str(ptr + 1))
out.write("l " + " ".join(line) + "\n")
ptr += cnt
def _render_scene(
terrain_positions: list[tuple[float, float, float]],
terrain_faces: list[tuple[int, int, int]],
areals: list[dict[str, Any]],
*,
width: int,
height: int,
yaw_deg: float,
pitch_deg: float,
wireframe: bool,
areal_overlay: bool,
) -> bytearray:
all_positions = list(terrain_positions)
if areal_overlay:
for area in areals:
all_positions.extend(area["vertices"])
if not all_positions:
raise RuntimeError("scene is empty")
xs = [p[0] for p in all_positions]
ys = [p[1] for p in all_positions]
zs = [p[2] for p in all_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.96
# Terrain transform cache.
vx: list[float] = []
vy: list[float] = []
vz: list[float] = []
sx: list[float] = []
sy: list[float] = []
for x, y, z in terrain_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)
def project_point(x: float, y: float, z: float) -> tuple[float, float, float]:
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
px = width * 0.5 + (x1 / z2) * scale
py = height * 0.5 - (y2 / z2) * scale
return px, py, z2
rgb = bytearray([14, 16, 20] * (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 terrain_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
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 + 185 * intensity)
color = (min(255, shade + 6), min(255, shade + 14), min(255, shade + 28))
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]
inv_area = 1.0 / area
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
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]
def draw_line(
xa: float,
ya: float,
xb: float,
yb: float,
color: tuple[int, int, int],
) -> 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] = color[0]
rgb[p + 1] = color[1]
rgb[p + 2] = color[2]
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
if wireframe:
wf = (225, 232, 246)
for i0, i1, i2 in terrain_faces:
draw_line(sx[i0], sy[i0], sx[i1], sy[i1], wf)
draw_line(sx[i1], sy[i1], sx[i2], sy[i2], wf)
draw_line(sx[i2], sy[i2], sx[i0], sy[i0], wf)
if areal_overlay:
for area in areals:
verts = area["vertices"]
if len(verts) < 2:
continue
color = _color_for_class(int(area["class_id"]))
projected = [project_point(x, y, z + 0.35) for x, y, z in verts]
for i in range(len(projected)):
x0, y0, _ = projected[i]
x1, y1, _ = projected[(i + 1) % len(projected)]
draw_line(x0, y0, x1, y1, color)
return rgb
def cmd_render(args: argparse.Namespace) -> int:
msh_path = Path(args.land_msh).resolve()
map_path = Path(args.land_map).resolve() if args.land_map else None
output_path = Path(args.output).resolve()
positions, faces, terrain_meta = load_terrain_msh(msh_path, max_faces=int(args.max_faces))
areals: list[dict[str, Any]] = []
map_meta: dict[str, int] = {"areal_count": 0, "cells_x": 0, "cells_y": 0}
if map_path:
areals, map_meta = load_areal_map(map_path)
rgb = _render_scene(
positions,
faces,
areals,
width=int(args.width),
height=int(args.height),
yaw_deg=float(args.yaw),
pitch_deg=float(args.pitch),
wireframe=bool(args.wireframe),
areal_overlay=bool(args.overlay_areals),
)
_write_ppm(output_path, int(args.width), int(args.height), rgb)
print(f"Rendered terrain : {msh_path}")
if map_path:
print(f"Areal overlay : {map_path}")
print(f"Output : {output_path}")
print(
"Terrain geometry : "
f"vertices={terrain_meta['vertex_count']}, "
f"faces={terrain_meta['face_count_rendered']}/{terrain_meta['face_count_valid']} "
f"(raw={terrain_meta['face_count_raw']}, dropped={terrain_meta['face_dropped_invalid']})"
)
if map_path:
print(
"Areal map : "
f"areals={map_meta['areal_count']}, cells={map_meta['cells_x']}x{map_meta['cells_y']}"
)
return 0
def cmd_export_obj(args: argparse.Namespace) -> int:
msh_path = Path(args.land_msh).resolve()
map_path = Path(args.land_map).resolve() if args.land_map else None
output_path = Path(args.output).resolve()
positions, faces, terrain_meta = load_terrain_msh(msh_path, max_faces=int(args.max_faces))
areals: list[dict[str, Any]] = []
if map_path and bool(args.include_areals):
areals, _ = load_areal_map(map_path)
_write_obj(
output_path,
positions,
faces,
areals,
include_areals=bool(args.include_areals),
)
areal_vertices = sum(len(a["vertices"]) for a in areals)
print(f"Terrain source : {msh_path}")
if map_path:
print(f"Areal source : {map_path}")
print(f"OBJ output : {output_path}")
print(
"Terrain geometry : "
f"vertices={terrain_meta['vertex_count']}, "
f"faces={terrain_meta['face_count_rendered']}/{terrain_meta['face_count_valid']}"
)
if bool(args.include_areals):
print(f"Areal edges : areals={len(areals)}, extra_vertices={areal_vertices}")
return 0
def cmd_render_turntable(args: argparse.Namespace) -> int:
msh_path = Path(args.land_msh).resolve()
map_path = Path(args.land_map).resolve() if args.land_map else None
output_dir = Path(args.output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
frames = int(args.frames)
if frames <= 0:
raise RuntimeError("--frames must be > 0")
positions, faces, terrain_meta = load_terrain_msh(msh_path, max_faces=int(args.max_faces))
areals: list[dict[str, Any]] = []
if map_path:
areals, _ = load_areal_map(map_path)
yaw_start = float(args.yaw_start)
yaw_end = float(args.yaw_end)
if frames == 1:
yaws = [yaw_start]
else:
step = (yaw_end - yaw_start) / (frames - 1)
yaws = [yaw_start + i * step for i in range(frames)]
prefix = str(args.prefix)
for i, yaw in enumerate(yaws):
rgb = _render_scene(
positions,
faces,
areals,
width=int(args.width),
height=int(args.height),
yaw_deg=yaw,
pitch_deg=float(args.pitch),
wireframe=bool(args.wireframe),
areal_overlay=bool(args.overlay_areals),
)
out = output_dir / f"{prefix}_{i:03d}.ppm"
_write_ppm(out, int(args.width), int(args.height), rgb)
print(f"Turntable source : {msh_path}")
if map_path:
print(f"Areal source : {map_path}")
print(f"Output dir : {output_dir}")
print(f"Frames : {frames} ({yaws[0]:.3f} -> {yaws[-1]:.3f} yaw)")
print(
"Terrain geometry : "
f"vertices={terrain_meta['vertex_count']}, faces={terrain_meta['face_count_rendered']}"
)
return 0
def cmd_render_batch(args: argparse.Namespace) -> int:
maps_root = Path(args.maps_root).resolve()
output_dir = Path(args.output_dir).resolve()
msh_paths = sorted(maps_root.rglob("Land.msh"))
if not msh_paths:
raise RuntimeError(f"no Land.msh files under {maps_root}")
rendered = 0
skipped = 0
for msh_path in msh_paths:
map_path = msh_path.with_name("Land.map")
if not map_path.exists():
skipped += 1
continue
rel = msh_path.parent.relative_to(maps_root)
out = output_dir / f"{rel.as_posix().replace('/', '__')}.ppm"
cmd_render(
argparse.Namespace(
land_msh=str(msh_path),
land_map=str(map_path),
output=str(out),
max_faces=args.max_faces,
width=args.width,
height=args.height,
yaw=args.yaw,
pitch=args.pitch,
wireframe=args.wireframe,
overlay_areals=args.overlay_areals,
)
)
rendered += 1
print(f"Batch summary: rendered={rendered}, skipped_no_map={skipped}, output_dir={output_dir}")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Software 3D terrain renderer (Land.msh + optional Land.map overlay)."
)
sub = parser.add_subparsers(dest="command", required=True)
render = sub.add_parser("render", help="Render one terrain map to PPM.")
render.add_argument("--land-msh", required=True, help="Path to Land.msh")
render.add_argument("--land-map", help="Path to Land.map (optional)")
render.add_argument("--output", required=True, help="Output .ppm path")
render.add_argument("--max-faces", type=int, default=220000, help="Face limit (default: 220000)")
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=38.0, help="Yaw angle in degrees (default: 38)")
render.add_argument("--pitch", type=float, default=26.0, help="Pitch angle in degrees (default: 26)")
render.add_argument("--wireframe", action="store_true", help="Draw terrain wireframe overlay")
render.add_argument(
"--overlay-areals",
action="store_true",
help="Draw ArealMap polygon overlay",
)
render.set_defaults(func=cmd_render)
export_obj = sub.add_parser("export-obj", help="Export terrain (and optional areal edges) to OBJ.")
export_obj.add_argument("--land-msh", required=True, help="Path to Land.msh")
export_obj.add_argument("--land-map", help="Path to Land.map (optional)")
export_obj.add_argument("--output", required=True, help="Output .obj path")
export_obj.add_argument("--max-faces", type=int, default=0, help="Face limit (0 = all)")
export_obj.add_argument(
"--include-areals",
action="store_true",
help="Export areal polygons as OBJ polyline object",
)
export_obj.set_defaults(func=cmd_export_obj)
turn = sub.add_parser("render-turntable", help="Render turntable frame sequence to PPM.")
turn.add_argument("--land-msh", required=True, help="Path to Land.msh")
turn.add_argument("--land-map", help="Path to Land.map (optional)")
turn.add_argument("--output-dir", required=True, help="Output directory for frames")
turn.add_argument("--prefix", default="frame", help="Frame filename prefix (default: frame)")
turn.add_argument("--frames", type=int, default=36, help="Frame count (default: 36)")
turn.add_argument("--yaw-start", type=float, default=0.0, help="Start yaw in degrees (default: 0)")
turn.add_argument("--yaw-end", type=float, default=360.0, help="End yaw in degrees (default: 360)")
turn.add_argument("--pitch", type=float, default=26.0, help="Pitch angle in degrees (default: 26)")
turn.add_argument("--max-faces", type=int, default=160000, help="Face limit (default: 160000)")
turn.add_argument("--width", type=int, default=960, help="Image width (default: 960)")
turn.add_argument("--height", type=int, default=540, help="Image height (default: 540)")
turn.add_argument("--wireframe", action="store_true", help="Draw terrain wireframe overlay")
turn.add_argument(
"--overlay-areals",
action="store_true",
help="Draw ArealMap polygon overlay",
)
turn.set_defaults(func=cmd_render_turntable)
batch = sub.add_parser("render-batch", help="Render all MAPS/**/Land.msh under root.")
batch.add_argument(
"--maps-root",
default="tmp/gamedata/DATA/MAPS",
help="Root directory with MAPS subfolders (default: tmp/gamedata/DATA/MAPS)",
)
batch.add_argument("--output-dir", required=True, help="Directory for output PPM files")
batch.add_argument("--max-faces", type=int, default=90000, help="Face limit per map (default: 90000)")
batch.add_argument("--width", type=int, default=960, help="Image width (default: 960)")
batch.add_argument("--height", type=int, default=540, help="Image height (default: 540)")
batch.add_argument("--yaw", type=float, default=38.0, help="Yaw angle in degrees (default: 38)")
batch.add_argument("--pitch", type=float, default=26.0, help="Pitch angle in degrees (default: 26)")
batch.add_argument("--wireframe", action="store_true", help="Draw terrain wireframe overlay")
batch.add_argument(
"--overlay-areals",
action="store_true",
help="Draw ArealMap polygon overlay",
)
batch.set_defaults(func=cmd_render_batch)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
return int(args.func(args))
if __name__ == "__main__":
raise SystemExit(main())