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.
This commit is contained in:
7
crates/msh-core/Cargo.toml
Normal file
7
crates/msh-core/Cargo.toml
Normal 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
14
crates/msh-core/README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# msh-core
|
||||||
|
|
||||||
|
Парсер core-части формата `MSH`.
|
||||||
|
|
||||||
|
Покрывает:
|
||||||
|
|
||||||
|
- `Res1`, `Res2`, `Res3`, `Res6`, `Res13` (обязательные);
|
||||||
|
- `Res4`, `Res5`, `Res10` (опциональные);
|
||||||
|
- slot lookup по `node/lod/group`.
|
||||||
|
|
||||||
|
Тесты:
|
||||||
|
|
||||||
|
- прогон по всем `.msh` в `testdata`;
|
||||||
|
- синтетическая минимальная модель.
|
||||||
74
crates/msh-core/src/error.rs
Normal file
74
crates/msh-core/src/error.rs
Normal 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
392
crates/msh-core/src/lib.rs
Normal 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;
|
||||||
296
crates/msh-core/src/tests.rs
Normal file
296
crates/msh-core/src/tests.rs
Normal 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));
|
||||||
|
}
|
||||||
8
crates/render-core/Cargo.toml
Normal file
8
crates/render-core/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "render-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
msh-core = { path = "../msh-core" }
|
||||||
|
nres = { path = "../nres" }
|
||||||
14
crates/render-core/README.md
Normal file
14
crates/render-core/README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# render-core
|
||||||
|
|
||||||
|
CPU-подготовка draw-данных для моделей `MSH`.
|
||||||
|
|
||||||
|
Покрывает:
|
||||||
|
|
||||||
|
- обход `node -> slot -> batch`;
|
||||||
|
- раскрытие индексов в triangle-list (`Vec<[f32;3]>`);
|
||||||
|
- расчёт bounds по вершинам.
|
||||||
|
|
||||||
|
Тесты:
|
||||||
|
|
||||||
|
- построение рендер-сеток на реальных `.msh` из `testdata`;
|
||||||
|
- unit-test bounds.
|
||||||
84
crates/render-core/src/lib.rs
Normal file
84
crates/render-core/src/lib.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
use msh_core::Model;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RenderMesh {
|
||||||
|
pub vertices: Vec<[f32; 3]>,
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
vertices.push(*pos);
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
101
crates/render-core/src/tests.rs
Normal file
101
crates/render-core/src/tests.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
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(&mesh.vertices).is_some() {
|
||||||
|
bounds_non_empty += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
}
|
||||||
20
crates/render-demo/Cargo.toml
Normal file
20
crates/render-demo/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "render-demo"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
demo = ["dep:sdl2", "dep:glow"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
msh-core = { path = "../msh-core" }
|
||||||
|
nres = { path = "../nres" }
|
||||||
|
render-core = { path = "../render-core" }
|
||||||
|
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] }
|
||||||
|
glow = { version = "0.16", optional = true }
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "parkan-render-demo"
|
||||||
|
path = "src/main.rs"
|
||||||
|
required-features = ["demo"]
|
||||||
30
crates/render-demo/README.md
Normal file
30
crates/render-demo/README.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# render-demo
|
||||||
|
|
||||||
|
Тестовый рендерер Parkan-моделей на Rust (`SDL2 + OpenGL ES 2.0`).
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
- Проверить, что `nres + msh-core + render-core` дают рабочий draw-path на реальных ассетах.
|
||||||
|
- Служить минимальным 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`).
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Рендер только геометрии (без материалов/текстур/FX).
|
||||||
|
- Вывод через `glDrawArrays(GL_TRIANGLES)` из расширенного triangle-list.
|
||||||
4
crates/render-demo/build.rs
Normal file
4
crates/render-demo/build.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fn main() {
|
||||||
|
#[cfg(windows)]
|
||||||
|
println!("cargo:rustc-link-lib=advapi32");
|
||||||
|
}
|
||||||
113
crates/render-demo/src/lib.rs
Normal file
113
crates/render-demo/src/lib.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
use msh_core::{parse_model_payload, Model};
|
||||||
|
use nres::Archive;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
Nres(nres::error::Error),
|
||||||
|
Msh(msh_core::error::Error),
|
||||||
|
NoMshEntries,
|
||||||
|
ModelNotFound(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = core::result::Result<T, Error>;
|
||||||
|
|
||||||
|
pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result<Model> {
|
||||||
|
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 payload = archive.read(target_id)?;
|
||||||
|
Ok(parse_model_payload(payload.as_slice())?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
|
}
|
||||||
357
crates/render-demo/src/main.rs
Normal file
357
crates/render-demo/src/main.rs
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
use glow::HasContext as _;
|
||||||
|
use render_core::{build_render_mesh, compute_bounds};
|
||||||
|
use render_demo::load_model_from_archive;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
struct Args {
|
||||||
|
archive: PathBuf,
|
||||||
|
model: Option<String>,
|
||||||
|
lod: usize,
|
||||||
|
group: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> Result<Args, String> {
|
||||||
|
let mut archive = None;
|
||||||
|
let mut model = None;
|
||||||
|
let mut lod = 0usize;
|
||||||
|
let mut group = 0usize;
|
||||||
|
|
||||||
|
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"))?;
|
||||||
|
}
|
||||||
|
"--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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_help() {
|
||||||
|
eprintln!("parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N]");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args = match parse_args() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("{err}");
|
||||||
|
print_help();
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let model = match load_model_from_archive(&args.archive, args.model.as_deref()) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("failed to load model: {err:?}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mesh = build_render_mesh(&model, args.lod, args.group);
|
||||||
|
if mesh.vertices.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"model has no renderable triangles for lod={} group={}",
|
||||||
|
args.lod, args.group
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
let Some((bounds_min, bounds_max)) = compute_bounds(&mesh.vertices) else {
|
||||||
|
eprintln!("failed to compute mesh bounds");
|
||||||
|
std::process::exit(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
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().expect("failed to init SDL2");
|
||||||
|
let video = sdl.video().expect("failed to init SDL2 video");
|
||||||
|
|
||||||
|
{
|
||||||
|
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 window = video
|
||||||
|
.window("Parkan Render Demo (SDL2 + OpenGL ES 2.0)", 1280, 720)
|
||||||
|
.opengl()
|
||||||
|
.resizable()
|
||||||
|
.build()
|
||||||
|
.expect("failed to create window");
|
||||||
|
|
||||||
|
let gl_ctx = window
|
||||||
|
.gl_create_context()
|
||||||
|
.expect("failed to create OpenGL context");
|
||||||
|
window
|
||||||
|
.gl_make_current(&gl_ctx)
|
||||||
|
.expect("failed to make GL context current");
|
||||||
|
let _ = video.gl_set_swap_interval(1);
|
||||||
|
|
||||||
|
let mut vertices_flat = Vec::with_capacity(mesh.vertices.len() * 3);
|
||||||
|
for pos in &mesh.vertices {
|
||||||
|
vertices_flat.extend_from_slice(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
let gl = unsafe {
|
||||||
|
glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _)
|
||||||
|
};
|
||||||
|
|
||||||
|
let program = unsafe { create_program(&gl).expect("failed to create shader program") };
|
||||||
|
let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") };
|
||||||
|
let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") };
|
||||||
|
let a_pos = a_pos.expect("shader attribute a_pos is missing");
|
||||||
|
|
||||||
|
let vbo = unsafe { gl.create_buffer().expect("failed to create VBO") };
|
||||||
|
unsafe {
|
||||||
|
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
||||||
|
gl.buffer_data_u8_slice(
|
||||||
|
glow::ARRAY_BUFFER,
|
||||||
|
cast_slice_u8(&vertices_flat),
|
||||||
|
glow::STATIC_DRAW,
|
||||||
|
);
|
||||||
|
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut events = sdl.event_pump().expect("failed to get SDL event pump");
|
||||||
|
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 elapsed = start.elapsed().as_secs_f32();
|
||||||
|
let (w, h) = window.size();
|
||||||
|
let aspect = (w as f32 / (h.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(elapsed * 0.35);
|
||||||
|
let model_m = mat4_mul(&rot, ¢er_shift);
|
||||||
|
let vp = mat4_mul(&view, &model_m);
|
||||||
|
let mvp = mat4_mul(&proj, &vp);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
gl.viewport(0, 0, w as i32, h 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.as_ref(), false, &mvp);
|
||||||
|
|
||||||
|
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, 12, 0);
|
||||||
|
gl.draw_arrays(
|
||||||
|
glow::TRIANGLES,
|
||||||
|
0,
|
||||||
|
i32::try_from(mesh.vertices.len()).unwrap_or(i32::MAX),
|
||||||
|
);
|
||||||
|
gl.disable_vertex_attrib_array(a_pos);
|
||||||
|
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
||||||
|
gl.use_program(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.gl_swap_window();
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
gl.delete_buffer(vbo);
|
||||||
|
gl.delete_program(program);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn create_program(gl: &glow::Context) -> Result<glow::NativeProgram, String> {
|
||||||
|
let vs_src = r#"
|
||||||
|
attribute vec3 a_pos;
|
||||||
|
uniform mat4 u_mvp;
|
||||||
|
void main() {
|
||||||
|
gl_Position = u_mvp * vec4(a_pos, 1.0);
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let fs_src = r#"
|
||||||
|
precision mediump float;
|
||||||
|
void main() {
|
||||||
|
gl_FragColor = vec4(0.85, 0.90, 1.00, 1.0);
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
7
crates/texm/Cargo.toml
Normal file
7
crates/texm/Cargo.toml
Normal 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
15
crates/texm/README.md
Normal 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).
|
||||||
61
crates/texm/src/error.rs
Normal file
61
crates/texm/src/error.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
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::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 {}
|
||||||
258
crates/texm/src/lib.rs
Normal file
258
crates/texm/src/lib.rs
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
150
crates/texm/src/tests.rs
Normal file
150
crates/texm/src/tests.rs
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
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_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
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,89 +1,20 @@
|
|||||||
# FXID
|
# FXID
|
||||||
|
|
||||||
Документ фиксирует спецификацию ресурса эффекта `FXID` на уровне, достаточном для:
|
`FXID` — бинарный формат эффекта в движке Parkan: Iron Strategy.
|
||||||
|
Эта страница задаёт контракт формата и исполнения на уровне, достаточном для 1:1 порта рендера/симуляции эффектов и для lossless-инструментов.
|
||||||
- 1:1 загрузки и исполнения в совместимом runtime;
|
|
||||||
- построения валидатора payload;
|
|
||||||
- создания lossless-конвертера (`binary -> IR -> binary`);
|
|
||||||
- создания редактора с безопасным редактированием полей.
|
|
||||||
|
|
||||||
Связанный контейнер: [NRes / RsLi](nres.md).
|
Связанный контейнер: [NRes / RsLi](nres.md).
|
||||||
|
|
||||||
---
|
## 1. Контейнер
|
||||||
|
|
||||||
## 1. Источники и статус восстановления
|
- Тип ресурса в `NRes`: `0x44495846` (`FXID`).
|
||||||
|
- Значения `attr1/attr2/attr3` в типовых игровых данных стабильны, но при редактуре их нужно сохранять как есть.
|
||||||
|
|
||||||
Спецификация восстановлена по:
|
## 2. Бинарный формат
|
||||||
|
|
||||||
- `tmp/disassembler1/Effect.dll.c`;
|
|
||||||
- `tmp/disassembler2/Effect.dll.asm`;
|
|
||||||
- интеграционным вызовам из `tmp/disassembler1/Terrain.dll.c`;
|
|
||||||
- проверке реальных архивов `testdata/nres`.
|
|
||||||
|
|
||||||
Ключевые функции:
|
|
||||||
|
|
||||||
- parser FXID: `Effect.dll!sub_10007650`;
|
|
||||||
- runtime loop: `sub_10003D30(case 28)`, `sub_10006170`, `sub_10008120`, `sub_10007D10`;
|
|
||||||
- alpha/time: `sub_10005C60`;
|
|
||||||
- exports: `CreateFxManager`, `InitializeSettings`.
|
|
||||||
|
|
||||||
Проверка по данным:
|
|
||||||
|
|
||||||
- `923/923` FXID payload валидны в `testdata/nres`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Контейнер и runtime API
|
|
||||||
|
|
||||||
### 2.1. NRes entry
|
|
||||||
|
|
||||||
FXID хранится как NRes-entry:
|
|
||||||
|
|
||||||
- `type_id = 0x44495846` (`"FXID"`).
|
|
||||||
|
|
||||||
Наблюдение по датасету (923 эффекта):
|
|
||||||
|
|
||||||
- `attr1 = 0`, `attr2 = 0`, `attr3 = 1`.
|
|
||||||
|
|
||||||
### 2.2. Export API `Effect.dll`
|
|
||||||
|
|
||||||
Экспортируются:
|
|
||||||
|
|
||||||
- `CreateFxManager(int a1, int a2, int owner)`;
|
|
||||||
- `InitializeSettings()`.
|
|
||||||
|
|
||||||
`CreateFxManager` создаёт manager-объект (`0xB8` байт), инициализирует через `sub_10003AE0`, возвращает интерфейсный указатель (`base + 4`).
|
|
||||||
|
|
||||||
### 2.3. Интерфейс менеджера
|
|
||||||
|
|
||||||
Рабочая vtable (`off_1001E478`):
|
|
||||||
|
|
||||||
| Смещение | Функция | Назначение |
|
|
||||||
|---|---|---|
|
|
||||||
| +0x08 | `sub_10003D30` | Event dispatcher (`4/20/23/24/28`) |
|
|
||||||
| +0x10 | `sub_10004320` | Открыть/закэшировать FX resource |
|
|
||||||
| +0x14 | `sub_10004590` | Создать runtime instance |
|
|
||||||
| +0x18 | `sub_10004780` | Удалить instance |
|
|
||||||
| +0x1C | `sub_100047B0` | Установить time/interp mode |
|
|
||||||
| +0x20 | `sub_100047D0` | Установить scale |
|
|
||||||
| +0x24 | `sub_10004830` | Установить позицию |
|
|
||||||
| +0x28 | `sub_10004930` | Установить matrix transform |
|
|
||||||
| +0x2C | `sub_10004B00` | Restart/retime |
|
|
||||||
| +0x38 | `sub_10004BA0` | Duration modifier |
|
|
||||||
| +0x3C | `sub_10004BD0` | Start/Enable |
|
|
||||||
| +0x40 | `sub_10004C10` | Stop/Disable |
|
|
||||||
| +0x44 | `sub_10004C50` | Bind emitter/context |
|
|
||||||
| +0x48 | `sub_10004D50` | Сброс frame flags |
|
|
||||||
|
|
||||||
`Terrain.dll` использует `QueryInterface(id=19)` для получения рабочего интерфейса.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Бинарный формат FXID payload
|
|
||||||
|
|
||||||
Все значения little-endian.
|
Все значения little-endian.
|
||||||
|
|
||||||
### 3.1. Header (60 байт, `0x3C`)
|
### 2.1. Заголовок (60 байт)
|
||||||
|
|
||||||
```c
|
```c
|
||||||
struct FxHeader60 {
|
struct FxHeader60 {
|
||||||
@@ -105,94 +36,26 @@ struct FxHeader60 {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Командный поток начинается строго с `offset = 0x3C`.
|
Поток команд начинается строго с `offset = 0x3C`.
|
||||||
|
|
||||||
### 3.2. Header-поля (подтвержденная семантика)
|
### 2.2. Команда
|
||||||
|
|
||||||
- `cmd_count`: число команд (engine итерирует ровно столько шагов).
|
|
||||||
- `time_mode`: базовый режим вычисления alpha/time (`sub_10005C60`).
|
|
||||||
- `duration_sec`: в runtime -> `duration_ms = duration_sec * 1000`.
|
|
||||||
- `phase_jitter`: используется при `flags & 0x1`.
|
|
||||||
- `flags`: runtime-gating/alpha/visibility (см. ниже).
|
|
||||||
- `settings_id`: в `sub_1000EC40` используется `settings_id & 0xFF`.
|
|
||||||
- `rand_shift_*`: используется при `flags & 0x8`.
|
|
||||||
- `pivot_*`: используется в ветках `sub_10007D10`.
|
|
||||||
- `scale_*`: копируется в runtime scale и влияет на матрицы.
|
|
||||||
|
|
||||||
### 3.3. `flags` (битовая карта)
|
|
||||||
|
|
||||||
| Бит | Маска | Наблюдаемое поведение |
|
|
||||||
|---|---:|---|
|
|
||||||
| 0 | `0x0001` | Random phase jitter (`phase_jitter`) |
|
|
||||||
| 3 | `0x0008` | Random positional shift (`rand_shift_*`) |
|
|
||||||
| 4 | `0x0010` | Visibility/occlusion ветки |
|
|
||||||
| 5 | `0x0020` | Triangular remap в `sub_10005C60` |
|
|
||||||
| 6 | `0x0040` | Инверсия начального active-state |
|
|
||||||
| 7 | `0x0080` | Day/night filter (ветка A) |
|
|
||||||
| 8 | `0x0100` | Day/night filter (ветка B, инверсия) |
|
|
||||||
| 9 | `0x0200` | Alpha *= normalized lifetime |
|
|
||||||
| 10 | `0x0400` | Установка manager bit1 (`+0xA0`) |
|
|
||||||
| 11 | `0x0800` | Изменение gating в `sub_10007D10` |
|
|
||||||
| 12 | `0x1000` | Установка manager-state bit `0x10` |
|
|
||||||
|
|
||||||
Нерасшифрованные биты должны сохраняться 1:1.
|
|
||||||
|
|
||||||
### 3.4. `time_mode` (`0..17`)
|
|
||||||
|
|
||||||
Обозначения (`sub_10005C60`):
|
|
||||||
|
|
||||||
- `t0 = instance.start_ms`, `t1 = instance.end_ms`;
|
|
||||||
- `tn = (now_ms - t0) / (t1 - t0)`;
|
|
||||||
- `prev = instance.cached_alpha` (`v4+52` в дизассембле).
|
|
||||||
|
|
||||||
Режимы:
|
|
||||||
|
|
||||||
- `0`: constant (`instance.alpha_const`, поле `v4+40`);
|
|
||||||
- `1`: `tn`;
|
|
||||||
- `2`: `fract(tn)`;
|
|
||||||
- `3`: `1 - tn`;
|
|
||||||
- `4`: external value из queue/world API (manager `+36`, id из `this+104[a2]`);
|
|
||||||
- `5`: `|param33.xyz| / |param17.vecA.xyz|`;
|
|
||||||
- `6`: `param33.x / param17.vecA.x`;
|
|
||||||
- `7`: `param33.y / param17.vecA.y`;
|
|
||||||
- `8`: `param33.z / param17.vecA.z`;
|
|
||||||
- `9`: `|param36.xyz| / |param17.vecB.xyz|`;
|
|
||||||
- `10`: `param36.x / param17.vecB.x`;
|
|
||||||
- `11`: `param36.y / param17.vecB.y`;
|
|
||||||
- `12`: `param36.z / param17.vecB.z`;
|
|
||||||
- `13`: `1 - external_resource_value`;
|
|
||||||
- `14`: `1 - queue_param(49)`;
|
|
||||||
- `15`: `max(norm(param33/vecA), norm(param36/vecB))`;
|
|
||||||
- `16`: external (`mode 4`) с нижним clamp к `prev` (`0` не зажимается);
|
|
||||||
- `17`: external (`mode 4`) с верхним clamp к `prev` (`1` не зажимается).
|
|
||||||
|
|
||||||
Post-обработка после mode:
|
|
||||||
|
|
||||||
- если `flags & 0x200`: `alpha *= tn`;
|
|
||||||
- если `flags & 0x20`: triangular remap (`alpha = (alpha < 0.5 ? alpha : 1-alpha) * 2`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Командный поток
|
|
||||||
|
|
||||||
### 4.1. Общий формат команды
|
|
||||||
|
|
||||||
Каждая команда:
|
Каждая команда:
|
||||||
|
|
||||||
- `uint32 cmd_word`;
|
1. `uint32 cmd_word`
|
||||||
- далее body фиксированного размера по opcode.
|
2. body фиксированного размера, зависящего от `opcode`
|
||||||
|
|
||||||
`cmd_word`:
|
Поля `cmd_word`:
|
||||||
|
|
||||||
- `opcode = cmd_word & 0xFF`;
|
- `opcode = cmd_word & 0xFF`
|
||||||
- `enabled = (cmd_word >> 8) & 1`;
|
- `enabled = (cmd_word >> 8) & 1`
|
||||||
- `bits 9..31` в датасете нулевые, но их надо сохранять 1:1.
|
- `bits 9..31` нужно сохранять 1:1
|
||||||
|
|
||||||
Выравнивания между командами нет.
|
Выравнивания между командами нет.
|
||||||
|
|
||||||
### 4.2. Размеры
|
### 2.3. Размеры команд
|
||||||
|
|
||||||
| Opcode | Размер записи |
|
| Opcode | Размер |
|
||||||
|---:|---:|
|
|---:|---:|
|
||||||
| 1 | 224 |
|
| 1 | 224 |
|
||||||
| 2 | 148 |
|
| 2 | 148 |
|
||||||
@@ -205,630 +68,121 @@ Post-обработка после mode:
|
|||||||
| 9 | 208 |
|
| 9 | 208 |
|
||||||
| 10 | 208 |
|
| 10 | 208 |
|
||||||
|
|
||||||
### 4.3. Opcode -> runtime-класс (vtable)
|
## 3. Смысл заголовка
|
||||||
|
|
||||||
| Opcode | `new(size)` | vtable |
|
- `cmd_count`: число команд в потоке.
|
||||||
|---:|---:|---|
|
- `time_mode`: способ вычисления текущего коэффициента эффекта.
|
||||||
| 1 | `0xF0` | `off_1001E78C` |
|
- `duration_sec`: длительность (в рантайме переводится в миллисекунды).
|
||||||
| 2 | `0xA0` | `off_1001F048` |
|
- `phase_jitter`: амплитуда случайного фазового сдвига.
|
||||||
| 3 | `0xFC` | `off_1001E770` |
|
- `flags`: флаги поведения (видимость, альфа-модификаторы, режимы гейтинга).
|
||||||
| 4 | `0x104` | `off_1001E754` |
|
- `settings_id`: индекс профиля/настроек эффекта.
|
||||||
| 5 | `0x54` | `off_1001E360` |
|
- `rand_shift_*`: случайный пространственный сдвиг.
|
||||||
| 6 | `0x1C` | `off_1001E738` |
|
- `pivot_*`: локальная опора.
|
||||||
| 7 | `0x48` | `off_1001E228` |
|
- `scale_*`: базовый масштаб инстанса эффекта.
|
||||||
| 8 | `0xAC` | `off_1001E71C` |
|
|
||||||
| 9 | `0x100` | `off_1001E700` |
|
|
||||||
| 10 | `0x48` | `off_1001E24C` |
|
|
||||||
|
|
||||||
### 4.4. Общий вызовной контракт команды
|
## 4. Флаги заголовка
|
||||||
|
|
||||||
После создания команды (`sub_10007650`):
|
Практически важные биты:
|
||||||
|
|
||||||
1. `cmd->enabled = cmd_word.bit8`.
|
- `0x0001`: случайный сдвиг фазы
|
||||||
2. `cmd->Init(fx_queue, fx_instance)` (`vfunc +4`).
|
- `0x0008`: случайный пространственный сдвиг (`rand_shift_*`)
|
||||||
3. команда добавляется в список инстанса.
|
- `0x0010`: ветки видимости/окклюзии
|
||||||
|
- `0x0020`: треугольный ремап альфы
|
||||||
|
- `0x0040`: инверсия исходного active-state
|
||||||
|
- `0x0080`, `0x0100`: фильтрация по времени суток
|
||||||
|
- `0x0200`: умножение альфы на нормализованное время жизни
|
||||||
|
- `0x0400`, `0x1000`: дополнительные биты состояния менеджера эффекта
|
||||||
|
- `0x0800`: дополнительный гейтинг
|
||||||
|
|
||||||
В runtime cycle:
|
Неизвестные биты должны сохраняться без изменений.
|
||||||
|
|
||||||
- `vfunc +8`: update/compute (bool);
|
## 5. `time_mode` (0..17)
|
||||||
- `vfunc +12`: emission/render callback;
|
|
||||||
- `vfunc +20`: toggle active;
|
|
||||||
- `vfunc +16`/`+24`: служебные функции (зависят от opcode).
|
|
||||||
|
|
||||||
---
|
База:
|
||||||
|
|
||||||
## 5. Загрузка FXID (engine-accurate)
|
- `tn = (now - start) / (end - start)`
|
||||||
|
- `prev = предыдущая вычисленная альфа`
|
||||||
|
|
||||||
`sub_10007650`:
|
Поддерживаемые семейства режимов:
|
||||||
|
|
||||||
```c
|
- константный режим;
|
||||||
void FxLoad(FxInstance* fx, uint8_t* payload) {
|
- линейный (`tn`), обратный (`1-tn`), циклический (`fract(tn)`);
|
||||||
FxHeader60* h = (FxHeader60*)payload;
|
- режимы от внешних параметров мира/очереди;
|
||||||
|
- режимы на основе норм векторов состояния;
|
||||||
|
- режимы с ограничением вниз/вверх относительно `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;
|
- при `flags & 0x0200` применяется `alpha *= tn`;
|
||||||
for (uint32_t i = 0; i < h->cmd_count; ++i) {
|
- при `flags & 0x0020` применяется triangular remap.
|
||||||
uint32_t w = *(uint32_t*)ptr;
|
|
||||||
uint8_t op = (uint8_t)(w & 0xFF);
|
|
||||||
|
|
||||||
Command* cmd = CreateByOpcode(op, ptr); // может вернуть null
|
## 6. Resource-ссылки внутри команд
|
||||||
if (cmd) {
|
|
||||||
cmd->enabled = (w >> 8) & 1;
|
|
||||||
|
|
||||||
if (h->flags & 0x400) fx->manager_flags |= 0x0100;
|
Для opcode `2/3/4/5/7/8/9/10` используется ссылка:
|
||||||
if ((h->flags & 0x400) || cmd->enabled) fx->manager_flags |= 0x0010;
|
|
||||||
|
|
||||||
cmd->Init(fx->queue, fx);
|
|
||||||
fx->commands.push_back(cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
ptr += size_by_opcode(op); // без bounds checks в оригинале
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Критичные edge-case оригинала:
|
|
||||||
|
|
||||||
- bounds checks отсутствуют;
|
|
||||||
- при unknown opcode `ptr` не двигается (`advance = 0`);
|
|
||||||
- при `new == null` команда пропускается, но `ptr` двигается.
|
|
||||||
|
|
||||||
Фактический `advance` в `sub_10007650` задан hardcoded в DWORD:
|
|
||||||
|
|
||||||
- `op1:+56`, `op2:+37`, `op3:+50`, `op4:+51`, `op5:+28`,
|
|
||||||
- `op6:+1`, `op7:+52`, `op8:+62`, `op9:+52`, `op10:+52`,
|
|
||||||
- `default:+0`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Runtime lifecycle
|
|
||||||
|
|
||||||
- `sub_10007470`: ctor instance.
|
|
||||||
- `sub_10003D30(case 28)`: per-frame update manager.
|
|
||||||
- `sub_10006170`: gate + alpha/time + command updates.
|
|
||||||
- `sub_10008120` / `sub_10007D10`: update/render branches.
|
|
||||||
- Start/Stop: `sub_10004BD0` / `sub_10004C10`.
|
|
||||||
|
|
||||||
Event-codes `sub_10003D30`:
|
|
||||||
|
|
||||||
- `4`: bootstrap/time init;
|
|
||||||
- `20`: range-removal + index repair;
|
|
||||||
- `23`: set manager bit0;
|
|
||||||
- `24`: clear manager bit0;
|
|
||||||
- `28`: main tick.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Общий тип `ResourceRef64`
|
|
||||||
|
|
||||||
Для opcode `2/3/4/5/7/8/9/10` присутствует ссылка вида:
|
|
||||||
|
|
||||||
```c
|
```c
|
||||||
struct ResourceRef64 {
|
struct ResourceRef64 {
|
||||||
char archive[32]; // null-terminated ASCII, case-insensitive compare
|
char archive[32];
|
||||||
char name[32]; // null-terminated ASCII
|
char name[32];
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Поведение loader'а:
|
Контракт:
|
||||||
|
|
||||||
- оба имени обязаны быть непустыми;
|
- строки ASCII, нуль-терминированные;
|
||||||
- кэширование по `(_strcmpi archive, _strcmpi name)`;
|
- сравнение имён регистронезависимое;
|
||||||
- загрузка/резолв через manager resource API.
|
- обычно:
|
||||||
|
- `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`;
|
## 9. Writer и редактор
|
||||||
- update: `sub_1000F6E0`;
|
|
||||||
- emit: `nullsub_2`;
|
|
||||||
- toggle: `sub_1000F490`.
|
|
||||||
|
|
||||||
```c
|
Для lossless-совместимости:
|
||||||
struct FxCmd01 {
|
|
||||||
uint32_t word; // +0
|
|
||||||
uint32_t mode; // +4 (enum, см. ниже)
|
|
||||||
float t_start; // +8
|
|
||||||
float t_end; // +12
|
|
||||||
|
|
||||||
float p0_min[3]; // +16..24
|
- сохранять все неизвестные поля/биты;
|
||||||
float p0_max[3]; // +28..36
|
- не менять фиксированные размеры команд;
|
||||||
|
|
||||||
float p1_min[3]; // +40..48
|
|
||||||
float p1_max[3]; // +52..60
|
|
||||||
|
|
||||||
float q0_min[4]; // +64..76
|
|
||||||
float q0_max[4]; // +80..92
|
|
||||||
|
|
||||||
float q0_rand_span[4]; // +96..108 (все 4 читаются в sub_1000F6E0)
|
|
||||||
|
|
||||||
float scalar_min; // +112
|
|
||||||
float scalar_max; // +116
|
|
||||||
float scalar_rand_amp; // +120
|
|
||||||
|
|
||||||
float color_rgb[3]; // +124..132 (вызов manager+16)
|
|
||||||
|
|
||||||
float opaque_tail6[6]; // +136..156 (сохранять 1:1; в датасете почти всегда 0)
|
|
||||||
|
|
||||||
char opt_archive[32]; // +160..191 (редко, напр. "material.lib")
|
|
||||||
char opt_name[32]; // +192..223 (редко, напр. "light_w")
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Замечания по полям op1:
|
|
||||||
|
|
||||||
- `+108` не резерв: участвует в random-выборке как 4-я компонента блока `+96..108`;
|
|
||||||
- `+136..156` не читается vtable-методами класса `off_1001E78C` в `Effect.dll` (init/update/toggle/accessor), но должно сохраняться 1:1;
|
|
||||||
- редкий кейс с ненулевыми `+136..156` и строками `+160/+192` зафиксирован в `effects.rlb:r_lightray_w`.
|
|
||||||
|
|
||||||
`mode` (`+4`) -> параметры вызова manager (`sub_1000F4B0`):
|
|
||||||
|
|
||||||
- `1 -> create_kind=1, flags=0x80000000`;
|
|
||||||
- `2/5 -> create_kind=1, flags=0x00000000`;
|
|
||||||
- `3 -> create_kind=3, flags=0x00000000`;
|
|
||||||
- `4 -> create_kind=4, flags=0x00000000`;
|
|
||||||
- `6 -> create_kind=1, flags=0xA0000000`;
|
|
||||||
- `7 -> create_kind=1, flags=0x20000000`.
|
|
||||||
|
|
||||||
### 8.2. Opcode 2 (`off_1001F048`, size=148)
|
|
||||||
|
|
||||||
Основные методы:
|
|
||||||
|
|
||||||
- init: `sub_10012D10`;
|
|
||||||
- update: `sub_10012EB0`;
|
|
||||||
- emit: `nullsub_2`;
|
|
||||||
- toggle: `sub_10013170`.
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct FxCmd02 {
|
|
||||||
uint32_t word; // +0
|
|
||||||
uint32_t mode; // +4 (0..3; влияет на sub_100065A0 mapping)
|
|
||||||
float t_start; // +8
|
|
||||||
float t_end; // +12
|
|
||||||
|
|
||||||
float a_min[3]; // +16..24
|
|
||||||
float a_max[3]; // +28..36
|
|
||||||
|
|
||||||
float b_min[3]; // +40..48
|
|
||||||
float b_max[3]; // +52..60
|
|
||||||
|
|
||||||
float c0_base; // +64
|
|
||||||
float c1_base; // +68
|
|
||||||
float c2_base; // +72
|
|
||||||
float c2_max; // +76
|
|
||||||
|
|
||||||
uint32_t param_910; // +80 (передаётся в manager cmd=910)
|
|
||||||
|
|
||||||
ResourceRef64 ref; // +84..147 (обычно sounds.lib + wav)
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
`mode` -> внутренний map в `sub_100065A0`:
|
|
||||||
|
|
||||||
- `0 -> 0`, `1 -> 512`, `2 -> 2`, `3 -> 514`.
|
|
||||||
|
|
||||||
### 8.3. Opcode 3 (`off_1001E770`, size=200)
|
|
||||||
|
|
||||||
Методы:
|
|
||||||
|
|
||||||
- init: `sub_100103B0`;
|
|
||||||
- update: `sub_100105F0`;
|
|
||||||
- emit: `sub_100106C0`.
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct FxCmd03 {
|
|
||||||
uint32_t word; // +0
|
|
||||||
uint32_t mode; // +4
|
|
||||||
|
|
||||||
float alpha_source; // +8 (>=0: norm time, <0: global time)
|
|
||||||
float alpha_pow_a; // +12
|
|
||||||
float alpha_pow_b; // +16
|
|
||||||
|
|
||||||
float out_min; // +20
|
|
||||||
float out_max; // +24
|
|
||||||
float out_pow; // +28
|
|
||||||
|
|
||||||
float active_t0; // +32
|
|
||||||
float active_t1; // +36
|
|
||||||
|
|
||||||
float v0_min[3]; // +40..48
|
|
||||||
float v0_max[3]; // +52..60
|
|
||||||
|
|
||||||
float pow0[3]; // +64..72
|
|
||||||
|
|
||||||
float v1_min[3]; // +76..84
|
|
||||||
float v1_max[3]; // +88..96
|
|
||||||
|
|
||||||
float v2_min[3]; // +100..108
|
|
||||||
float v2_max[3]; // +112..120
|
|
||||||
|
|
||||||
float pow1[3]; // +124..132
|
|
||||||
|
|
||||||
ResourceRef64 ref; // +136..199
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.4. Opcode 4 (`off_1001E754`, size=204)
|
|
||||||
|
|
||||||
Layout как opcode 3 + последний коэффициент:
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct FxCmd04 {
|
|
||||||
FxCmd03 base; // +0..199
|
|
||||||
float dist_norm_inv_base; // +200 (используется в sub_100108C0/100109B0)
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
`sub_100108C0`: `obj->inv = 1.0 / raw[200]`.
|
|
||||||
|
|
||||||
### 8.5. Opcode 5 (`off_1001E360`, size=112)
|
|
||||||
|
|
||||||
Методы:
|
|
||||||
|
|
||||||
- init: `sub_100028A0`;
|
|
||||||
- update: `sub_10002A20`;
|
|
||||||
- emit: `sub_10002BE0`;
|
|
||||||
- context update: `sub_10003070`.
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct FxCmd05 {
|
|
||||||
uint32_t word; // +0
|
|
||||||
uint32_t mode; // +4 (в данных обычно 1)
|
|
||||||
uint32_t unused_08; // +8 (в текущем коде opcode5 не читается)
|
|
||||||
uint32_t unused_0C; // +12 (в текущем коде opcode5 не читается)
|
|
||||||
|
|
||||||
float active_t0; // +16
|
|
||||||
uint32_t max_segments; // +20
|
|
||||||
float active_t1_min; // +24
|
|
||||||
float active_t1_max; // +28
|
|
||||||
|
|
||||||
float step_norm; // +32
|
|
||||||
float segment_len; // +36
|
|
||||||
float alpha_source; // +40 (>=0 norm, <0 random)
|
|
||||||
float alpha_pow; // +44
|
|
||||||
|
|
||||||
ResourceRef64 ref; // +48..111
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.6. Opcode 6 (`off_1001E738`, size=4)
|
|
||||||
|
|
||||||
Только `cmd_word`:
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct FxCmd06 {
|
|
||||||
uint32_t word; // +0
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
`init/update/emit` фактически no-op (`sub_100030B0` возвращает `0`).
|
|
||||||
|
|
||||||
### 8.7. Opcode 7 (`off_1001E228`, size=208)
|
|
||||||
|
|
||||||
Методы:
|
|
||||||
|
|
||||||
- init: `sub_10001720`;
|
|
||||||
- update: `sub_10001230`;
|
|
||||||
- emit: `sub_10001300`;
|
|
||||||
- element accessor: `sub_10002780`.
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct FxCmd07 {
|
|
||||||
uint32_t word; // +0
|
|
||||||
uint32_t mode; // +4
|
|
||||||
|
|
||||||
float eval_min; // +8
|
|
||||||
float eval_max; // +12
|
|
||||||
float eval_pow; // +16
|
|
||||||
|
|
||||||
float active_t0; // +20
|
|
||||||
float active_t1; // +24
|
|
||||||
|
|
||||||
float phase_span; // +28
|
|
||||||
float phase_rate; // +32
|
|
||||||
|
|
||||||
uint32_t count_a; // +36
|
|
||||||
uint32_t count_b; // +40
|
|
||||||
|
|
||||||
float set0_min[3]; // +44..52
|
|
||||||
float set0_max[3]; // +56..64
|
|
||||||
float set0_rand[3]; // +68..76
|
|
||||||
float set0_pow[3]; // +80..88
|
|
||||||
|
|
||||||
float set1_min[3]; // +92..100
|
|
||||||
float set1_max[3]; // +104..112
|
|
||||||
float set1_rand[3]; // +116..124
|
|
||||||
float set1_pow[3]; // +128..136
|
|
||||||
|
|
||||||
float gravity_or_drag_k; // +140
|
|
||||||
|
|
||||||
ResourceRef64 ref; // +144..207
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.8. Opcode 8 (`off_1001E71C`, size=248)
|
|
||||||
|
|
||||||
Методы:
|
|
||||||
|
|
||||||
- init: `sub_10011230`;
|
|
||||||
- update: `sub_100115C0`;
|
|
||||||
- emit: `sub_10012030`.
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct FxCmd08 {
|
|
||||||
uint32_t word; // +0
|
|
||||||
uint32_t mode; // +4
|
|
||||||
|
|
||||||
float eval_t0; // +8
|
|
||||||
float eval_t1; // +12
|
|
||||||
|
|
||||||
float gate_t0; // +16
|
|
||||||
float gate_t1; // +20
|
|
||||||
|
|
||||||
float period_min; // +24
|
|
||||||
float period_max; // +28
|
|
||||||
float phase_pow; // +32
|
|
||||||
|
|
||||||
uint32_t slots; // +36
|
|
||||||
|
|
||||||
float set0_min[3]; // +40..48
|
|
||||||
float set0_max[3]; // +52..60
|
|
||||||
float set0_rand[3]; // +64..72
|
|
||||||
|
|
||||||
float set1_min[3]; // +76..84
|
|
||||||
float set1_max[3]; // +88..96
|
|
||||||
float set1_rand[3]; // +100..108
|
|
||||||
|
|
||||||
float set2_rand[3]; // +112..120
|
|
||||||
float set2_pow[3]; // +124..132
|
|
||||||
|
|
||||||
float rmax_set0[3]; // +136..144 (bound/radius calc)
|
|
||||||
float rmax_set1[3]; // +148..156 (bound/radius calc)
|
|
||||||
float rmax_set2[3]; // +160..168 (bound/radius calc)
|
|
||||||
|
|
||||||
float render_pow[3]; // +172..180
|
|
||||||
|
|
||||||
ResourceRef64 ref; // +184..247
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.9. Opcode 9 (`off_1001E700`, size=208)
|
|
||||||
|
|
||||||
Layout как opcode 3 с двумя final-полями:
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct FxCmd09 {
|
|
||||||
FxCmd03 base; // +0..199
|
|
||||||
uint32_t render_kind; // +200 (0/1/2 -> 3/5/6 in sub_100138C0)
|
|
||||||
uint32_t render_flag; // +204 (0 -> добавляет bit 0x08000000)
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Методы:
|
|
||||||
|
|
||||||
- init/update как у opcode 3 (`sub_100103B0`, `sub_100105F0`);
|
|
||||||
- emit: `sub_100138C0` -> формирует код рендера и вызывает `sub_100106C0`.
|
|
||||||
|
|
||||||
### 8.10. Opcode 10 (`off_1001E24C`, size=208)
|
|
||||||
|
|
||||||
Body-layout совпадает с opcode 7 (`FxCmd07`), но другой runtime класс.
|
|
||||||
|
|
||||||
- init: `sub_10001A40`;
|
|
||||||
- update: `sub_10001230`;
|
|
||||||
- emit: `sub_10001300`;
|
|
||||||
- element accessor: `sub_10002830`.
|
|
||||||
|
|
||||||
Наблюдение по данным:
|
|
||||||
|
|
||||||
- `mode` (`+4`) встречается как `16` или `32`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Runtime-специфика по opcode (важные отличия)
|
|
||||||
|
|
||||||
### 9.1. Opcode 1
|
|
||||||
|
|
||||||
- создаёт handle через manager (`vfunc +48`);
|
|
||||||
- задаёт флаги handle (`vfunc +52`);
|
|
||||||
- в update пушит:
|
|
||||||
- позиционный вектор 1 (`vfunc +32`),
|
|
||||||
- позиционный вектор 2 (`vfunc +36`),
|
|
||||||
- 4-компонентный параметр (`vfunc +12`),
|
|
||||||
- scalar+rgb (`vfunc +16`).
|
|
||||||
|
|
||||||
### 9.2. Opcode 2
|
|
||||||
|
|
||||||
- `ResourceRef64` резолвится через `sub_100065A0` (режим-зависимая загрузка, в данных обычно `sounds.lib`/`wav`);
|
|
||||||
- использует manager-команду id `910`.
|
|
||||||
|
|
||||||
### 9.3. Opcode 3/4/9
|
|
||||||
|
|
||||||
- общий core-emitter в `sub_100106C0`;
|
|
||||||
- opcode 4 добавляет нормализацию по `raw+200`;
|
|
||||||
- opcode 9 добавляет переключение render-кода (`raw+200/+204`).
|
|
||||||
|
|
||||||
### 9.4. Opcode 5
|
|
||||||
|
|
||||||
- держит массив внутренних сегментов (`332` байта/элемент, ctor `sub_100099F0`);
|
|
||||||
- context-matrix приходит через `vfunc +24` (`sub_10003070`).
|
|
||||||
|
|
||||||
### 9.5. Opcode 7/10
|
|
||||||
|
|
||||||
- общий update/render (`sub_10001230`, `sub_10001300`);
|
|
||||||
- разные внутренние element-форматы:
|
|
||||||
- opcode 7: `204` байта/элемент (`sub_100092D0`),
|
|
||||||
- opcode 10: `492` байта/элемент (`sub_1000BB40`).
|
|
||||||
|
|
||||||
### 9.6. Opcode 8
|
|
||||||
|
|
||||||
- самый тяжёлый спавнер, хранит ring/slot-структуры;
|
|
||||||
- emit фаза (`sub_10012030`) использует `mode`, `render_pow`, per-slot transforms.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Спецификация инструментов
|
|
||||||
|
|
||||||
### 10.1. Reader (strict)
|
|
||||||
|
|
||||||
Алгоритм:
|
|
||||||
|
|
||||||
1. `len(payload) >= 60`;
|
|
||||||
2. читаем `cmd_count`;
|
|
||||||
3. `ptr = 0x3C`;
|
|
||||||
4. цикл `cmd_count`:
|
|
||||||
- `ptr + 4 <= len`;
|
|
||||||
- `opcode in 1..10`;
|
|
||||||
- `ptr + size(opcode) <= len`;
|
|
||||||
- `ptr += size(opcode)`;
|
|
||||||
5. strict-tail: `ptr == len(payload)`.
|
|
||||||
|
|
||||||
### 10.2. Reader (engine-compatible)
|
|
||||||
|
|
||||||
Legacy-режим (опасный, только при необходимости byte-совместимости):
|
|
||||||
|
|
||||||
- без bounds-check;
|
|
||||||
- tolerant к unknown opcode как в оригинале.
|
|
||||||
|
|
||||||
### 10.3. Writer (canonical)
|
|
||||||
|
|
||||||
1. записать `FxHeader60`;
|
|
||||||
2. `cmd_count = commands.len()`;
|
|
||||||
3. команды сериализуются как `cmd_word + fixed-body`;
|
|
||||||
4. размер payload: `0x3C + sum(size(op_i))`;
|
|
||||||
5. без хвостовых байт.
|
|
||||||
|
|
||||||
### 10.4. Editor (lossless)
|
|
||||||
|
|
||||||
Правила:
|
|
||||||
|
|
||||||
- все поля little-endian;
|
|
||||||
- не менять fixed size команды;
|
|
||||||
- не добавлять padding;
|
- не добавлять padding;
|
||||||
- сохранять неизвестные биты (`cmd_word`, `header.flags`) copy-through;
|
- пересчитывать только `cmd_count` и размеры контейнера;
|
||||||
- для частично-известных полей поддерживать режим `opaque`.
|
- сохранять порядок команд.
|
||||||
|
|
||||||
### 10.5. IR/JSON (рекомендуемая форма)
|
## 10. Что требуется для 1:1 переноса
|
||||||
|
|
||||||
```json
|
1. Полная поддержка opcode `1..10`.
|
||||||
{
|
2. Точный контракт вычисления `time_mode` и `flags`.
|
||||||
"header": {
|
3. Точное поведение `ResourceRef64`.
|
||||||
"time_mode": 1,
|
4. Повторяемый RNG и одинаковая политика плавающей точки.
|
||||||
"duration_sec": 2.5,
|
|
||||||
"phase_jitter": 0.2,
|
|
||||||
"flags": 22,
|
|
||||||
"settings_id": 785,
|
|
||||||
"rand_shift": [0.0, 0.0, 0.0],
|
|
||||||
"pivot": [0.0, 0.0, 0.0],
|
|
||||||
"scale": [1.0, 1.0, 1.0]
|
|
||||||
},
|
|
||||||
"commands": [
|
|
||||||
{
|
|
||||||
"opcode": 8,
|
|
||||||
"word_raw": 264,
|
|
||||||
"enabled": 1,
|
|
||||||
"fields": {
|
|
||||||
"mode": 1065353216,
|
|
||||||
"eval_t0": 0.0,
|
|
||||||
"eval_t1": 1.0,
|
|
||||||
"resource": {"archive": "material.lib", "name": "fire_smoke"}
|
|
||||||
},
|
|
||||||
"opaque_extra_hex": "..."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
## 11. Статус валидации
|
||||||
|
|
||||||
## 11. Проверка на реальных данных
|
- Формальные инварианты FXID зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`.
|
||||||
|
- В текущем рабочем окружении нет полного набора игровых архивов (`testdata` без payload), поэтому массовая повторная проверка корпуса здесь не выполнялась.
|
||||||
`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`).
|
|
||||||
|
|||||||
130
docs/specs/material.md
Normal file
130
docs/specs/material.md
Normal 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`).
|
||||||
|
- В этом окружении нет полного игрового корпуса, поэтому статистика по всем материалам не пересчитывалась.
|
||||||
@@ -1,874 +1,8 @@
|
|||||||
# Materials, WEAR, MAT0 и Texm
|
# Materials, WEAR, Texm
|
||||||
|
|
||||||
Документ описывает материальную подсистему движка (World3D/Ngi32) на уровне, достаточном для:
|
Старая объединённая страница разбита по объектам.
|
||||||
|
|
||||||
- реализации runtime 1:1;
|
- [Material (`MAT0`)](material.md)
|
||||||
- создания инструментов чтения/валидации;
|
- [Wear table (`WEAR`)](wear.md)
|
||||||
- создания инструментов конвертации и редактирования с lossless round-trip.
|
- [Texture (`Texm`)](texture.md)
|
||||||
|
- [Render pipeline](render.md)
|
||||||
Источник: дизассемблированные `tmp/disassembler1/*.c` и `tmp/disassembler2/*.asm`, плюс проверка на `tmp/gamedata`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Идентификаторы и сущности
|
|
||||||
|
|
||||||
| Сущность | ID (LE uint32) | ASCII | Где используется |
|
|
||||||
|---|---:|---|---|
|
|
||||||
| Material resource | `0x3054414D` | `MAT0` | `Material.lib` |
|
|
||||||
| Wear resource | `0x52414557` | `WEAR` | `.wea` записи в world/mission `.rlb` |
|
|
||||||
| Texture resource | `0x6D786554` | `Texm` | `Textures.lib`, `lightmap.lib`, другие `.lib/.rlb` |
|
|
||||||
| Atlas tail chunk | `0x65676150` | `Page` | хвост payload `Texm` |
|
|
||||||
|
|
||||||
Дополнительно: палитры загружаются отдельным путём (через `SetPalettesLib` + `sub_10002B40`) и не являются `Texm`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Архитектура подсистемы
|
|
||||||
|
|
||||||
### 2.1 Экспортируемые точки входа (World3D)
|
|
||||||
|
|
||||||
- `LoadMatManager`
|
|
||||||
- `SetPalettesLib`
|
|
||||||
- `SetTexturesLib`
|
|
||||||
- `SetMaterialLib`
|
|
||||||
- `SetLightMapLib`
|
|
||||||
- `SetGameTime`
|
|
||||||
- `UnloadAllTextures`
|
|
||||||
|
|
||||||
`Set*Lib` просто копируют строки путей в глобальные буферы; валидации пути нет.
|
|
||||||
|
|
||||||
### 2.2 Дефолтные библиотеки (из `iron3d.dll`)
|
|
||||||
|
|
||||||
- `Textures.lib`
|
|
||||||
- `Material.lib`
|
|
||||||
- `LightMap.lib`
|
|
||||||
- `palettes.lib` (строка собирается как `'p' + "alettes.lib"`)
|
|
||||||
|
|
||||||
### 2.3 Ключевые runtime-хранилища
|
|
||||||
|
|
||||||
1. Менеджер материалов (`LoadMatManager`) — объект `0x470` байт.
|
|
||||||
2. Кэш текстурных объектов.
|
|
||||||
3. Кэш lightmap-объектов.
|
|
||||||
4. Банк загруженных палитр.
|
|
||||||
5. Глобальный пул определений материалов (`MAT0`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Layout `MatManager` (0x470)
|
|
||||||
|
|
||||||
Объект содержит 70 таблиц wear/lightmaps (не 140).
|
|
||||||
|
|
||||||
```c
|
|
||||||
// int-индексы относительно this (DWORD*), размер 284 DWORD = 0x470
|
|
||||||
// [0] vtable
|
|
||||||
// [1] callback iface
|
|
||||||
// [2] callback data
|
|
||||||
// [3..72] wearTablePtrs[70] // ptr на массив по 8 байт
|
|
||||||
// [73..142] wearCounts[70]
|
|
||||||
// [143] tableCount
|
|
||||||
// [144..213] lightmapTablePtrs[70] // ptr на массив по 4 байта
|
|
||||||
// [214..283] lightmapCounts[70]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.1 Vtable методов (`off_100209E4`)
|
|
||||||
|
|
||||||
| Индекс | Функция | Назначение |
|
|
||||||
|---:|---|---|
|
|
||||||
| 0 | `loc_10002CE0` | служебный/RTTI-заглушка |
|
|
||||||
| 1 | `sub_10002D10` | деструктор + освобождение таблиц |
|
|
||||||
| 2 | `PreLoadAllTextures` | экспорт, но фактически `retn 4` (заглушка) |
|
|
||||||
| 3 | `sub_100031F0` | получить материал-фазу по `gameTime` |
|
|
||||||
| 4 | `sub_10003AE0` | сбросить startTime записи wear к `SetGameTime()` |
|
|
||||||
| 5 | `sub_10003680` | получить материал-фазу по нормализованному `t` |
|
|
||||||
| 6 | `sub_10003B10` | загрузить wear/lightmaps (файл/ресурс) |
|
|
||||||
| 7 | `sub_10003F80` | загрузить wear/lightmaps из буфера |
|
|
||||||
| 8 | `sub_100031A0` | получить указатель на lightmap texture object |
|
|
||||||
| 9 | `sub_10003AB0` | получить runtime-метаданные материала |
|
|
||||||
| 10 | `sub_100031D0` | получить `wearCount` для таблицы |
|
|
||||||
|
|
||||||
### 3.2 Кодирование material-handle
|
|
||||||
|
|
||||||
`uint32 handle = (tableIndex << 16) | wearIndex`.
|
|
||||||
|
|
||||||
- `HIWORD(handle)` -> индекс таблицы `0..69`
|
|
||||||
- `LOWORD(handle)` -> индекс материала в wear-таблице
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Глобальные кэши и их ёмкость
|
|
||||||
|
|
||||||
Ёмкости подтверждены границами циклов/адресов в дизассемблере.
|
|
||||||
|
|
||||||
### 4.1 Кэш текстур (`dword_1014E910`...)
|
|
||||||
|
|
||||||
- Размер слота: `5 DWORD` (20 байт)
|
|
||||||
- Ёмкость: `777`
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct TextureSlot {
|
|
||||||
int32_t resIndex; // +0 индекс записи в NRes (не hash), -1 = свободно
|
|
||||||
void* textureObject; // +4
|
|
||||||
int32_t refCount; // +8
|
|
||||||
uint32_t lastZeroRefTime;// +12 время, когда refCount стал 0
|
|
||||||
uint32_t loadFlags; // +16 флаги загрузки
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
`lastZeroRefTime` реально используется: texture-слоты с `refCount==0` освобождаются отложенно периодическим GC.
|
|
||||||
|
|
||||||
### 4.2 Кэш lightmaps (`dword_10029C98`...)
|
|
||||||
|
|
||||||
- Тот же layout `5 DWORD`
|
|
||||||
- Ёмкость: `100`
|
|
||||||
|
|
||||||
Для lightmap-слотов аналогичного периодического GC по `lastZeroRefTime` в `World3D` не наблюдается.
|
|
||||||
|
|
||||||
### 4.3 Пул материалов (`dword_100669F0`...)
|
|
||||||
|
|
||||||
- Шаг: `92 DWORD` (`368` байт)
|
|
||||||
- Ёмкость: `700`
|
|
||||||
|
|
||||||
Фиксированные поля на шаг `i*92`:
|
|
||||||
|
|
||||||
| DWORD offset | Byte offset | Поле |
|
|
||||||
|---:|---:|---|
|
|
||||||
| 0 | 0 | `nameResIndex` (`MAT0` entry index), `-1` = free |
|
|
||||||
| 1 | 4 | `refCount` |
|
|
||||||
| 2 | 8 | `phaseCount` |
|
|
||||||
| 3 | 12 | `phaseArrayPtr` (`phaseCount * 76`) |
|
|
||||||
| 4 | 16 | `animBlockCount` (`< 20`) |
|
|
||||||
| 5..84 | 20..339 | `animBlocks[20]` по 16 байт |
|
|
||||||
| 85 | 340 | metaA (`dword_10066B44`) |
|
|
||||||
| 86 | 344 | metaB (`dword_10066B48`) |
|
|
||||||
| 87 | 348 | metaC (`dword_10066B4C`) |
|
|
||||||
| 88 | 352 | metaD (`dword_10066B50`) |
|
|
||||||
| 89 | 356 | flagA (`dword_10066B54`) |
|
|
||||||
| 90 | 360 | nibbleMode (`dword_10066B58`) |
|
|
||||||
| 91 | 364 | flagB (`dword_10066B5C`) |
|
|
||||||
|
|
||||||
### 4.4 Банк палитр
|
|
||||||
|
|
||||||
- `dword_1013DA58[]`
|
|
||||||
- Загружается до `286` элементов (26 букв * 11 вариантов)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Загрузка палитр (`sub_10002B40`)
|
|
||||||
|
|
||||||
### 5.1 Генерация имён
|
|
||||||
|
|
||||||
Движок перебирает:
|
|
||||||
|
|
||||||
- буквы `'A'..'Z'`
|
|
||||||
- суффиксы: `""`, `"0"`, `"1"`, ..., `"9"`
|
|
||||||
|
|
||||||
И формирует имя:
|
|
||||||
|
|
||||||
- `<Letter><Suffix>.PAL`
|
|
||||||
- примеры: `A.PAL`, `A0.PAL`, ..., `Z9.PAL`
|
|
||||||
|
|
||||||
### 5.2 Индекс палитры
|
|
||||||
|
|
||||||
`paletteIndex = letterIndex * 11 + variantIndex`
|
|
||||||
|
|
||||||
- `letterIndex = 0..25`
|
|
||||||
- `variantIndex = 0..10` (`""`=0, `"0"`=1, ..., `"9"`=10)
|
|
||||||
|
|
||||||
### 5.3 Поведение
|
|
||||||
|
|
||||||
- Если запись не найдена: `paletteSlots[idx] = 0`
|
|
||||||
- Если найдена: payload отдаётся в рендер (`render->method+60`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Формат `MAT0` (`Material.lib`)
|
|
||||||
|
|
||||||
### 6.1 Атрибуты NRes entry
|
|
||||||
|
|
||||||
`sub_10004310` использует:
|
|
||||||
|
|
||||||
- `entry.type` = `MAT0`
|
|
||||||
- `entry.attr1` (bitfield runtime-флагов)
|
|
||||||
- `entry.attr2` (версия/вариант заголовка payload)
|
|
||||||
- `entry.attr3` не используется в runtime-парсере
|
|
||||||
|
|
||||||
Маппинг `attr1`:
|
|
||||||
|
|
||||||
- bit0 (`0x01`) -> добавить флаг `0x200000` в загрузку текстур фазы
|
|
||||||
- bit1 (`0x02`) -> `flagA=1`; при некоторых HW-условиях дополнительно OR `0x80000`
|
|
||||||
- bits2..5 -> `nibbleMode = (attr1 >> 2) & 0xF`
|
|
||||||
- bit6 (`0x40`) -> `flagB=1`
|
|
||||||
|
|
||||||
### 6.2 Payload layout
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct Mat0Payload {
|
|
||||||
uint16_t phaseCount;
|
|
||||||
uint16_t animBlockCount; // должно быть < 20, иначе "Too many animations for material."
|
|
||||||
|
|
||||||
// Если attr2 >= 2:
|
|
||||||
uint8_t metaA8;
|
|
||||||
uint8_t metaB8;
|
|
||||||
// Если attr2 >= 3:
|
|
||||||
uint32_t metaC32;
|
|
||||||
// Если attr2 >= 4:
|
|
||||||
uint32_t metaD32;
|
|
||||||
|
|
||||||
PhaseRecordByte34 phases[phaseCount];
|
|
||||||
AnimBlockRaw anim[animBlockCount];
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Если `attr2 < 2`, runtime-значения по умолчанию:
|
|
||||||
|
|
||||||
- `metaA = 255`
|
|
||||||
- `metaB = 255`
|
|
||||||
- `metaC = 1.0f` (`0x3F800000`)
|
|
||||||
- `metaD = 0`
|
|
||||||
|
|
||||||
### 6.3 `PhaseRecordByte34` -> runtime `76 bytes`
|
|
||||||
|
|
||||||
Сырые 34 байта:
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct PhaseRecordByte34 {
|
|
||||||
uint8_t p[18]; // параметры
|
|
||||||
char textureName[16];// если textureName[0]==0, текстуры нет
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Преобразование в runtime-структуру (точный порядок):
|
|
||||||
|
|
||||||
| Из `p[i]` | В offset runtime | Преобразование |
|
|
||||||
|---:|---:|---|
|
|
||||||
| `p[0]` | `+16` | `p[0] / 255.0f` |
|
|
||||||
| `p[1]` | `+20` | `p[1] / 255.0f` |
|
|
||||||
| `p[2]` | `+24` | `p[2] / 255.0f` |
|
|
||||||
| `p[3]` | `+28` | `p[3] * 0.01f` |
|
|
||||||
| `p[4]` | `+0` | `p[4] / 255.0f` |
|
|
||||||
| `p[5]` | `+4` | `p[5] / 255.0f` |
|
|
||||||
| `p[6]` | `+8` | `p[6] / 255.0f` |
|
|
||||||
| `p[7]` | `+12` | `p[7] / 255.0f` |
|
|
||||||
| `p[8]` | `+32` | `p[8] / 255.0f` |
|
|
||||||
| `p[9]` | `+36` | `p[9] / 255.0f` |
|
|
||||||
| `p[10]` | `+40` | `p[10] / 255.0f` |
|
|
||||||
| `p[11]` | `+44` | `p[11] / 255.0f` |
|
|
||||||
| `p[12]` | `+48` | `p[12] / 255.0f` |
|
|
||||||
| `p[13]` | `+52` | `p[13] / 255.0f` |
|
|
||||||
| `p[14]` | `+56` | `p[14] / 255.0f` |
|
|
||||||
| `p[15]` | `+60` | `p[15] / 255.0f` |
|
|
||||||
| `p[16]` | `+64` | `uint32 = p[16]` |
|
|
||||||
| `p[17]` | `+72` | `int32 = p[17]` |
|
|
||||||
|
|
||||||
Текстура:
|
|
||||||
|
|
||||||
- `textureName[0] == 0` -> `runtime[+68] = -1` и `runtime[+72] = -1`
|
|
||||||
- иначе `runtime[+68] = LoadTexture(textureName, flags)`
|
|
||||||
|
|
||||||
### 6.4 Runtime-запись фазы (76 байт)
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct MaterialPhase76 {
|
|
||||||
float f0; // +0
|
|
||||||
float f1; // +4
|
|
||||||
float f2; // +8
|
|
||||||
float f3; // +12
|
|
||||||
float f4; // +16
|
|
||||||
float f5; // +20
|
|
||||||
float f6; // +24
|
|
||||||
float f7; // +28
|
|
||||||
float f8; // +32
|
|
||||||
float f9; // +36
|
|
||||||
float f10; // +40
|
|
||||||
float f11; // +44
|
|
||||||
float f12; // +48
|
|
||||||
float f13; // +52
|
|
||||||
float f14; // +56
|
|
||||||
float f15; // +60
|
|
||||||
uint32_t u16; // +64
|
|
||||||
int32_t texSlot; // +68 (индекс в texture cache, либо -1)
|
|
||||||
int32_t i18; // +72
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.5 Анимационные блоки (`animBlockCount`, максимум 19)
|
|
||||||
|
|
||||||
Каждый блок в payload:
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct AnimBlockRaw {
|
|
||||||
uint32_t headerRaw; // mode = headerRaw & 7; interpMask = headerRaw >> 3
|
|
||||||
uint16_t keyCount;
|
|
||||||
struct KeyRaw {
|
|
||||||
uint16_t k0;
|
|
||||||
uint16_t k1;
|
|
||||||
uint16_t k2;
|
|
||||||
} keys[keyCount];
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Runtime-представление блока = 16 байт:
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct AnimBlockRuntime {
|
|
||||||
uint32_t mode; // headerRaw & 7
|
|
||||||
uint32_t interpMask;// headerRaw >> 3
|
|
||||||
int32_t keyCount;
|
|
||||||
void* keysPtr; // массив keyCount * 8
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Ключи в runtime занимают 8 байт/ключ (с расширением `k0` до `uint32`).
|
|
||||||
|
|
||||||
`k2` в `sub_100031F0/sub_10003680` не используется.
|
|
||||||
Поле нужно сохранять lossless, т.к. оно присутствует в бинарном формате.
|
|
||||||
|
|
||||||
### 6.6 Поиск и fallback
|
|
||||||
|
|
||||||
При `LoadMaterial(name)`:
|
|
||||||
|
|
||||||
- сначала точный поиск в `Material.lib`;
|
|
||||||
- при промахе лог: `"Material %s not found."`;
|
|
||||||
- fallback на `DEFAULT`;
|
|
||||||
- если и `DEFAULT` не найден, берётся индекс `0`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Выбор текущей material-фазы
|
|
||||||
|
|
||||||
### 7.1 Интерполяция (`sub_10003030`)
|
|
||||||
|
|
||||||
Интерполируются только следующие поля (по `interpMask`):
|
|
||||||
|
|
||||||
- bit `0x02`: `+4,+8,+12`
|
|
||||||
- bit `0x01`: `+20,+24,+28`
|
|
||||||
- bit `0x04`: `+36,+40,+44`
|
|
||||||
- bit `0x08`: `+52,+56,+60`
|
|
||||||
- bit `0x10`: `+32`
|
|
||||||
|
|
||||||
Не интерполируются и копируются из «текущей» фазы:
|
|
||||||
|
|
||||||
- `+0,+16,+48,+64,+68,+72`
|
|
||||||
|
|
||||||
### 7.2 Выбор по времени (`sub_100031F0`)
|
|
||||||
|
|
||||||
Вход:
|
|
||||||
|
|
||||||
- `handle` (`tableIndex|wearIndex`)
|
|
||||||
- `animBlockIndex`
|
|
||||||
- глобальное время `SetGameTime()` (`dword_10032A38`)
|
|
||||||
|
|
||||||
Для каждой wear-записи хранится `startTime` (второй DWORD пары `8-byte`).
|
|
||||||
|
|
||||||
Режимы `mode = headerRaw & 7`:
|
|
||||||
|
|
||||||
- `0`: loop
|
|
||||||
- `1`: ping-pong
|
|
||||||
- `2`: one-shot clamp
|
|
||||||
- `3`: random (`rand() % cycleLength`)
|
|
||||||
|
|
||||||
Важные детали 1:1:
|
|
||||||
|
|
||||||
- деление/остаток по циклу реализованы через unsigned `div` (`edx=0` перед делением);
|
|
||||||
- в `mode=3` вычисленное `rand() % cycleLength` записывается прямо в `startTime` записи (не в локальную переменную).
|
|
||||||
- при `gameTime < startTime` применяется unsigned-wrap семантика (важно для точного воспроизведения edge-case).
|
|
||||||
|
|
||||||
После выбора сегмента интерполяции `sub_10003030` строит scratch-материал (`unk_1013B300`), который возвращается через out-параметр.
|
|
||||||
|
|
||||||
### 7.3 Выбор по нормализованному `t` (`sub_10003680`)
|
|
||||||
|
|
||||||
Аналогично `sub_100031F0`, но time берётся как `t * cycleLength`.
|
|
||||||
|
|
||||||
Перед вычислением времени применяется runtime-нормализация:
|
|
||||||
|
|
||||||
- если `t < 0.0` или `t > 1.0`, используется `t = 0.5`.
|
|
||||||
|
|
||||||
### 7.4 Сброс времени записи
|
|
||||||
|
|
||||||
`sub_10003AE0` обновляет `startTime` конкретной wear-записи значением текущего `SetGameTime()`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Формат `WEAR` (текст)
|
|
||||||
|
|
||||||
`WEAR` хранится как текст в NRes entry типа `WEAR` (`0x52414557`), обычно имя `*.wea`.
|
|
||||||
|
|
||||||
### 8.1 Грамматика
|
|
||||||
|
|
||||||
```text
|
|
||||||
<wearCount:int>\n
|
|
||||||
<legacyId:int> <materialName>\n // повторить wearCount раз
|
|
||||||
|
|
||||||
[\n] // для buffer-парсера с LIGHTMAPS фактически обязательна пустая строка
|
|
||||||
[LIGHTMAPS\n
|
|
||||||
<lightmapCount:int>\n
|
|
||||||
<legacyId:int> <lightmapName>\n // повторить lightmapCount раз]
|
|
||||||
```
|
|
||||||
|
|
||||||
- `<legacyId>` читается, но как ключ не используется.
|
|
||||||
- Идентификатором реально является имя (`materialName` / `lightmapName`).
|
|
||||||
|
|
||||||
### 8.2 Парсеры
|
|
||||||
|
|
||||||
1. `sub_10003B10`: файл/ресурсный режим.
|
|
||||||
2. `sub_10003F80`: парсер из строкового буфера.
|
|
||||||
|
|
||||||
Различие важно для совместимости:
|
|
||||||
|
|
||||||
- `sub_10003B10` после `LIGHTMAPS` сразу читает `lightmapCount` через `fscanf`.
|
|
||||||
- `sub_10003F80` после детекта `LIGHTMAPS` делает два последовательных skip до `\n`; поэтому при наличии блока `LIGHTMAPS` нужен пустой разделитель перед строкой `LIGHTMAPS`, иначе парсинг может съехать.
|
|
||||||
|
|
||||||
### 8.3 Поведение и ошибки
|
|
||||||
|
|
||||||
- `wearCount <= 0` (в текстовом файловом режиме) -> `"Illegal wear length."`
|
|
||||||
- при невозможности открыть wear-файл/entry -> `"Wear <%s> doesn't exist."`
|
|
||||||
- если найден блок `LIGHTMAPS` и `lightmapCount <= 0` -> `"Illegal lightmaps length."`
|
|
||||||
- отсутствующий материал -> `"Material %s not found."` + fallback `DEFAULT`
|
|
||||||
- отсутствующая lightmap -> `"LightMap %s not found."` и slot `-1`
|
|
||||||
- в buffer-режиме неверная структура вокруг `LIGHTMAPS` может дать некорректный `lightmapCount` и каскадные ошибки чтения.
|
|
||||||
|
|
||||||
### 8.4 Ограничения runtime
|
|
||||||
|
|
||||||
- Таблиц в `MatManager`: максимум 70 (физический layout).
|
|
||||||
- Жёсткой проверки на overflow таблиц в `sub_10003B10/sub_10003F80` нет.
|
|
||||||
|
|
||||||
Инструментам нужно явно валидировать `tableCount < 70`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Загрузка texture/lightmap по имени
|
|
||||||
|
|
||||||
Общие функции:
|
|
||||||
|
|
||||||
- `sub_10004B10` — texture (`Textures.lib`)
|
|
||||||
- `sub_10004CB0` — lightmap (`LightMap.lib`)
|
|
||||||
|
|
||||||
### 9.1 Валидация имени
|
|
||||||
|
|
||||||
Алгоритм требует наличие `'.'` в позиции `0..16`.
|
|
||||||
|
|
||||||
Иначе:
|
|
||||||
|
|
||||||
- `"Bad texture name."`
|
|
||||||
- возврат `-1`
|
|
||||||
|
|
||||||
### 9.2 Palette index из суффикса
|
|
||||||
|
|
||||||
После точки разбирается:
|
|
||||||
|
|
||||||
- `L = toupper(name[dot+1])`
|
|
||||||
- `D = name[dot+2]` (опционально)
|
|
||||||
- `idx = (L - 'A') * 11 + (D ? (D - '0' + 1) : 0)`
|
|
||||||
|
|
||||||
Если `idx < 0`, палитра не подставляется (`0`).
|
|
||||||
Верхняя граница `idx` в runtime не проверяется.
|
|
||||||
|
|
||||||
Практически в стоковых ассетах имена часто вида `NAME.0`; это даёт `idx < 0`, т.е. без палитровой привязки.
|
|
||||||
Для невалидных суффиксов это потенциально даёт OOB-чтение палитрового массива.
|
|
||||||
|
|
||||||
### 9.3 Кэширование
|
|
||||||
|
|
||||||
- Дедупликация по `resIndex`.
|
|
||||||
- При повторном запросе увеличивается `refCount`, `lastZeroRefTime` сбрасывается в `0`.
|
|
||||||
- При освобождении материала `refCount` texture/lightmap уменьшается.
|
|
||||||
- texture: при `refCount -> 0` запоминается `lastZeroRefTime`; периодический sweep (примерно раз в 20 секунд) удаляет слот, если прошло больше `~60` секунд.
|
|
||||||
- lightmap: явного аналогичного sweep-пути нет; освобождение в основном происходит при teardown таблиц (`MatManager` dtor).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Формат `Texm`
|
|
||||||
|
|
||||||
### 10.1 Заголовок 32 байта
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct TexmHeader32 {
|
|
||||||
uint32_t magic; // 'Texm' = 0x6D786554
|
|
||||||
uint32_t width;
|
|
||||||
uint32_t height;
|
|
||||||
uint32_t mipCount;
|
|
||||||
uint32_t flags4;
|
|
||||||
uint32_t flags5;
|
|
||||||
uint32_t unk6;
|
|
||||||
uint32_t format;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10.2 Поддерживаемые `format`
|
|
||||||
|
|
||||||
Подтверждённые в данных:
|
|
||||||
|
|
||||||
- `0` (палитровый 8-bit)
|
|
||||||
- `565`
|
|
||||||
- `4444`
|
|
||||||
- `888`
|
|
||||||
- `8888`
|
|
||||||
|
|
||||||
Поддерживается loader-ветками Ngi32 (может встречаться в runtime-генерации):
|
|
||||||
|
|
||||||
- `556`
|
|
||||||
- `88`
|
|
||||||
|
|
||||||
### 10.3 Layout payload
|
|
||||||
|
|
||||||
1. `TexmHeader32`
|
|
||||||
2. если `format == 0`: palette table `256 * 4 = 1024` байта
|
|
||||||
3. mip-chain пикселей
|
|
||||||
4. опциональный `Page` chunk
|
|
||||||
|
|
||||||
Расчёт:
|
|
||||||
|
|
||||||
```c
|
|
||||||
bytesPerPixel =
|
|
||||||
(format == 0) ? 1 :
|
|
||||||
(format == 565 || format == 556 || format == 4444 || format == 88) ? 2 :
|
|
||||||
4;
|
|
||||||
|
|
||||||
pixelCount = sum_{i=0..mipCount-1}(max(1, width>>i) * max(1, height>>i));
|
|
||||||
sizeCore = 32 + (format == 0 ? 1024 : 0) + bytesPerPixel * pixelCount;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10.4 `Page` chunk
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct PageChunk {
|
|
||||||
uint32_t magic; // 'Page'
|
|
||||||
uint32_t rectCount;
|
|
||||||
struct Rect16 {
|
|
||||||
int16_t x;
|
|
||||||
int16_t w;
|
|
||||||
int16_t y;
|
|
||||||
int16_t h;
|
|
||||||
} rects[rectCount];
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Runtime конвертирует `Rect16` в:
|
|
||||||
|
|
||||||
- пиксельные прямоугольники;
|
|
||||||
- UV-границы с учётом возможного `mipSkip`.
|
|
||||||
|
|
||||||
Формулы (`s = mipSkip`):
|
|
||||||
|
|
||||||
- `x0 = x << s`, `x1 = (x + w) << s`
|
|
||||||
- `y0 = y << s`, `y1 = (y + h) << s`
|
|
||||||
- `u0 = x / (width << s)`, `du = w / (width << s)`
|
|
||||||
- `v0 = y / (height << s)`, `dv = h / (height << s)`
|
|
||||||
|
|
||||||
Также всегда добавляется базовый rect `[0]` на всю текстуру: пиксели `(0,0,width,height)`, UV `(0,0,1,1)`.
|
|
||||||
|
|
||||||
### 10.5 Loader-поведение (`sub_1000FB30`)
|
|
||||||
|
|
||||||
- Читает header в внутренние поля (`+56..+84`) напрямую:
|
|
||||||
- `+56 magic`, `+60 width`, `+64 height`, `+68 mipCount`,
|
|
||||||
- `+72 flags4`, `+76 flags5`, `+80 unk6`, `+84 format`.
|
|
||||||
- Для `format==0` считывает palette и переставляет каналы в runtime-таблицу.
|
|
||||||
- Считает `sizeCore`, находит tail.
|
|
||||||
- `Page` разбирается только если включён флаг загрузки `0x400000` и tail содержит `Page`.
|
|
||||||
- Может уменьшать стартовый mip (`sub_1000F580`) в зависимости от размеров/формата/флагов.
|
|
||||||
- При `DisableMipmap == 0` и допустимых условиях может строить mips в runtime.
|
|
||||||
|
|
||||||
### 10.6 Политика `mipSkip` (`sub_1000F580`)
|
|
||||||
|
|
||||||
`mipSkip` зависит от `flags5 & 0x72000000`, `width`, `height`, `mipCount`:
|
|
||||||
|
|
||||||
- если `mipCount <= 1` -> `0`
|
|
||||||
- если `flags5Mask == 0x02000000` -> `2` при `mipCount > 2`, иначе `1`
|
|
||||||
- если `flags5Mask == 0x10000000` -> `1`
|
|
||||||
- если `flags5Mask == 0x20000000`:
|
|
||||||
- `1`, если `width >= 256` или `height >= 256`
|
|
||||||
- иначе `0`
|
|
||||||
- если `flags5Mask == 0x40000000`:
|
|
||||||
- если `width > 128` и `height > 128`: `2` при `mipCount > 2`, иначе `1`
|
|
||||||
- если `width == 128` или `height == 128`: `1`
|
|
||||||
- иначе `0`
|
|
||||||
- иначе `0`
|
|
||||||
|
|
||||||
Применение в loader:
|
|
||||||
|
|
||||||
- `mipCount -= mipSkip`
|
|
||||||
- `width >>= mipSkip`, `height >>= mipSkip`
|
|
||||||
- `pixelDataOffset += bytesPerPixel * origWidth * origHeight` для `mipSkip==1`
|
|
||||||
- `pixelDataOffset += bytesPerPixel * origWidth * origHeight * 1.25` для `mipSkip==2` (первые два уровня)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Флаги профиля/рендера (Ngi32)
|
|
||||||
|
|
||||||
Ключ реестра: `HKCU\Software\Nikita\NgiTool`.
|
|
||||||
|
|
||||||
Подтверждённые значения:
|
|
||||||
|
|
||||||
- `Disable MultiTexturing`
|
|
||||||
- `DisableMipmap`
|
|
||||||
- `Force 16-bit textures`
|
|
||||||
- `UseFirstCard`
|
|
||||||
- `DisableD3DCalls`
|
|
||||||
- `DisableDSound`
|
|
||||||
- `ForceCpu`
|
|
||||||
|
|
||||||
Они напрямую влияют на выбор texture format path, mip handling и fallback-ветки.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Спецификация для toolchain (read/edit/write)
|
|
||||||
|
|
||||||
### 12.1 Каноническая модель данных
|
|
||||||
|
|
||||||
1. `MAT0`:
|
|
||||||
- хранить исходные `attr1/attr2/attr3`;
|
|
||||||
- хранить сырой payload + декодированную структуру;
|
|
||||||
- при записи сохранять порядок/размеры секций точно.
|
|
||||||
|
|
||||||
2. `WEAR`:
|
|
||||||
- хранить строки wear/lightmaps как текст;
|
|
||||||
- сохранять порядок строк;
|
|
||||||
- допускать отсутствие блока `LIGHTMAPS`.
|
|
||||||
- если нужен полный runtime-parity с buffer-парсером (`sub_10003F80`) и есть `LIGHTMAPS`, сохранять пустую строку-разделитель перед строкой `LIGHTMAPS`.
|
|
||||||
|
|
||||||
3. `Texm`:
|
|
||||||
- хранить header поля как есть (`flags4/flags5/unk6` не нормализовать);
|
|
||||||
- хранить palette (если есть), mip data, `Page`.
|
|
||||||
|
|
||||||
### 12.2 Правила lossless записи
|
|
||||||
|
|
||||||
- Не менять значения `flags4/flags5/unk6` без явной причины.
|
|
||||||
- Не менять `NRes` entry attrs, если цель — бинарный round-trip.
|
|
||||||
- Для `MAT0`:
|
|
||||||
- `animBlockCount < 20`.
|
|
||||||
- `phaseCount` и фактический размер секции должны совпадать.
|
|
||||||
- textureName в фазе всегда укладывать в 16 байт и NUL-терминировать.
|
|
||||||
- Для `Texm`:
|
|
||||||
- `magic == 'Texm'`.
|
|
||||||
- `mipCount > 0`, `width>0`, `height>0`.
|
|
||||||
- tail либо отсутствует, либо ровно один корректный `Page` chunk без лишних байт.
|
|
||||||
- при эмуляции runtime-загрузчика учитывать, что `Page` обрабатывается только при load-flag `0x400000`.
|
|
||||||
|
|
||||||
### 12.3 Рекомендованные валидации редактора
|
|
||||||
|
|
||||||
- `WEAR`:
|
|
||||||
- `wearCount > 0`.
|
|
||||||
- число строк wear соответствует `wearCount`.
|
|
||||||
- если есть `LIGHTMAPS`, то `lightmapCount > 0` и число строк совпадает.
|
|
||||||
- для buffer-совместимого текста с `LIGHTMAPS` проверять наличие пустой строки перед `LIGHTMAPS`.
|
|
||||||
- `MAT0`:
|
|
||||||
- не выходить за payload при распаковке.
|
|
||||||
- все ссылки фаз/keys проверять на диапазоны.
|
|
||||||
- `Texm`:
|
|
||||||
- `sizeCore <= payload_size`.
|
|
||||||
- проверка `Page` как `8 + rectCount*8`.
|
|
||||||
- предупреждать/блокировать невалидные palette suffix, которые могут дать `idx >= 286` в runtime.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Проверка на реальных данных (`tmp/gamedata`)
|
|
||||||
|
|
||||||
### 13.1 `Material.lib`
|
|
||||||
|
|
||||||
- `905` entries, все `type=MAT0`
|
|
||||||
- `attr2 = 6` у всех
|
|
||||||
- `attr3 = 0` у всех
|
|
||||||
- `phaseCount` до `29`
|
|
||||||
- `animBlockCount` до `8` (ограничение runtime `<20` соблюдается)
|
|
||||||
|
|
||||||
### 13.2 `Textures.lib`
|
|
||||||
|
|
||||||
- `393` entries, все `type=Texm`
|
|
||||||
- форматы: `8888(237), 888(52), 565(47), 4444(42), 0(15)`
|
|
||||||
- `flags4`: `32(361), 0(32)`
|
|
||||||
- `flags5`: `0(312), 0x04000000(81)`
|
|
||||||
- `Page` chunk присутствует у `65` текстур
|
|
||||||
|
|
||||||
### 13.3 `lightmap.lib`
|
|
||||||
|
|
||||||
- `25` entries, все `Texm`
|
|
||||||
- формат: `565`
|
|
||||||
- `mipCount=1`
|
|
||||||
- `flags5`: в основном `0`, встречается `0x00800000`
|
|
||||||
|
|
||||||
### 13.4 `WEAR`
|
|
||||||
|
|
||||||
- `439` entries `type=WEAR`
|
|
||||||
- `attr1=0, attr2=0, attr3=1`
|
|
||||||
- `21` entry содержит блок `LIGHTMAPS` (в текущем наборе везде `lightmapCount=1`)
|
|
||||||
- для всех `21` entry с `LIGHTMAPS` присутствует пустая строка перед `LIGHTMAPS`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Opaque-поля и границы знания
|
|
||||||
|
|
||||||
Для 1:1 runtime/toolchain достаточно фиксировать следующие поля как `opaque-but-required`:
|
|
||||||
|
|
||||||
- `MAT0`:
|
|
||||||
- `k2` в `AnimBlockRaw::KeyRaw` (хранить/писать без изменений);
|
|
||||||
- `metaA/metaB/metaC/metaD` (в `World3D` заполняются и возвращаются наружу; внутренних consumers этих мета-полей не найдено).
|
|
||||||
- `Texm`:
|
|
||||||
- `flags4/flags5/unk6` (часть веток разобрана, но полная доменная семантика не требуется для 1:1).
|
|
||||||
|
|
||||||
Это не блокирует реализацию движка/конвертеров 1:1.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Минимальные псевдокоды для реализации
|
|
||||||
|
|
||||||
### 15.1 `parse_mat0(payload, attr2)`
|
|
||||||
|
|
||||||
```python
|
|
||||||
def parse_mat0(payload: bytes, attr2: int):
|
|
||||||
cur = 0
|
|
||||||
phase_count = u16(payload, cur); cur += 2
|
|
||||||
anim_count = u16(payload, cur); cur += 2
|
|
||||||
if anim_count >= 20:
|
|
||||||
raise ValueError("Too many animations for material")
|
|
||||||
|
|
||||||
if attr2 < 2:
|
|
||||||
metaA, metaB, metaC, metaD = 255, 255, 0x3F800000, 0
|
|
||||||
else:
|
|
||||||
metaA = u8(payload, cur); cur += 1
|
|
||||||
metaB = u8(payload, cur); cur += 1
|
|
||||||
metaC = u32(payload, cur) if attr2 >= 3 else 0x3F800000
|
|
||||||
cur += 4 if attr2 >= 3 else 0
|
|
||||||
metaD = u32(payload, cur) if attr2 >= 4 else 0
|
|
||||||
cur += 4 if attr2 >= 4 else 0
|
|
||||||
|
|
||||||
phases = [payload[cur + i*34 : cur + (i+1)*34] for i in range(phase_count)]
|
|
||||||
cur += 34 * phase_count
|
|
||||||
|
|
||||||
anim = []
|
|
||||||
for _ in range(anim_count):
|
|
||||||
raw = u32(payload, cur); cur += 4
|
|
||||||
key_count = u16(payload, cur); cur += 2
|
|
||||||
keys = [payload[cur + k*6 : cur + (k+1)*6] for k in range(key_count)]
|
|
||||||
cur += 6 * key_count
|
|
||||||
anim.append((raw, keys))
|
|
||||||
|
|
||||||
if cur != len(payload):
|
|
||||||
raise ValueError("MAT0 tail bytes")
|
|
||||||
|
|
||||||
return phase_count, anim_count, metaA, metaB, metaC, metaD, phases, anim
|
|
||||||
```
|
|
||||||
|
|
||||||
### 15.2 `parse_texm(payload)`
|
|
||||||
|
|
||||||
```python
|
|
||||||
def parse_texm(payload: bytes):
|
|
||||||
magic, w, h, mips, f4, f5, unk6, fmt = unpack_u32x8(payload, 0)
|
|
||||||
if magic != 0x6D786554:
|
|
||||||
raise ValueError("not Texm")
|
|
||||||
|
|
||||||
bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444, 88) else 4)
|
|
||||||
pix = 0
|
|
||||||
mw, mh = w, h
|
|
||||||
for _ in range(mips):
|
|
||||||
pix += mw * mh
|
|
||||||
mw = max(1, mw >> 1)
|
|
||||||
mh = max(1, mh >> 1)
|
|
||||||
|
|
||||||
core = 32 + (1024 if fmt == 0 else 0) + bpp * pix
|
|
||||||
if core > len(payload):
|
|
||||||
raise ValueError("truncated")
|
|
||||||
|
|
||||||
page = None
|
|
||||||
if core < len(payload):
|
|
||||||
if core + 8 > len(payload) or payload[core:core+4] != b"Page":
|
|
||||||
raise ValueError("tail without Page")
|
|
||||||
n = u32(payload, core + 4)
|
|
||||||
need = 8 + n * 8
|
|
||||||
if core + need != len(payload):
|
|
||||||
raise ValueError("invalid Page size")
|
|
||||||
page = [unpack_i16x4(payload, core + 8 + i*8) for i in range(n)]
|
|
||||||
|
|
||||||
return (w, h, mips, fmt, f4, f5, unk6, page)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 15.3 `mip_skip_policy(flags5, width, height, mip_count)`
|
|
||||||
|
|
||||||
```python
|
|
||||||
def mip_skip_policy(flags5: int, width: int, height: int, mip_count: int) -> int:
|
|
||||||
if mip_count <= 1:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
m = flags5 & 0x72000000
|
|
||||||
if m == 0x02000000:
|
|
||||||
return 2 if mip_count > 2 else 1
|
|
||||||
if m == 0x10000000:
|
|
||||||
return 1
|
|
||||||
if m == 0x20000000:
|
|
||||||
return 1 if (width >= 256 or height >= 256) else 0
|
|
||||||
if m == 0x40000000:
|
|
||||||
if width > 128 and height > 128:
|
|
||||||
return 2 if mip_count > 2 else 1
|
|
||||||
if width == 128 or height == 128:
|
|
||||||
return 1
|
|
||||||
return 0
|
|
||||||
```
|
|
||||||
|
|
||||||
### 15.4 `parse_wear_buffer_compatible(text)`
|
|
||||||
|
|
||||||
```python
|
|
||||||
def parse_wear_buffer_compatible(text: str):
|
|
||||||
lines = text.splitlines()
|
|
||||||
i = 0
|
|
||||||
|
|
||||||
wear_count = int(lines[i].strip()); i += 1
|
|
||||||
if wear_count <= 0:
|
|
||||||
raise ValueError("Illegal wear length.")
|
|
||||||
|
|
||||||
wear = []
|
|
||||||
for _ in range(wear_count):
|
|
||||||
legacy, name = lines[i].split(maxsplit=1)
|
|
||||||
wear.append((int(legacy), name.strip()))
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
lightmaps = []
|
|
||||||
tail = lines[i:] if i < len(lines) else []
|
|
||||||
if tail and tail[0].strip() == "":
|
|
||||||
# sub_10003F80-совместимый разделитель перед LIGHTMAPS
|
|
||||||
i += 1
|
|
||||||
tail = lines[i:]
|
|
||||||
|
|
||||||
if tail and tail[0].strip().upper() == "LIGHTMAPS":
|
|
||||||
i += 1
|
|
||||||
if i >= len(lines):
|
|
||||||
raise ValueError("Illegal lightmaps length.")
|
|
||||||
light_count = int(lines[i].strip()); i += 1
|
|
||||||
if light_count <= 0:
|
|
||||||
raise ValueError("Illegal lightmaps length.")
|
|
||||||
for _ in range(light_count):
|
|
||||||
legacy, name = lines[i].split(maxsplit=1)
|
|
||||||
lightmaps.append((int(legacy), name.strip()))
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
return wear, lightmaps
|
|
||||||
```
|
|
||||||
|
|
||||||
### 15.5 `select_phase_time_1to1(...)`
|
|
||||||
|
|
||||||
```python
|
|
||||||
def select_phase_time_1to1(game_time: int, start_time: int, keys, mode: int):
|
|
||||||
# keys: list[(phase_index, t_start, t_end)], t_end последнего = cycle_len
|
|
||||||
cycle_len = keys[-1][2]
|
|
||||||
if cycle_len <= 0:
|
|
||||||
return 0, 0.0
|
|
||||||
|
|
||||||
# unsigned div/mod как в runtime
|
|
||||||
delta = (game_time - start_time) & 0xFFFFFFFF
|
|
||||||
q = delta // cycle_len
|
|
||||||
r = delta % cycle_len
|
|
||||||
|
|
||||||
if mode == 1: # ping-pong
|
|
||||||
if q & 1:
|
|
||||||
r = cycle_len - r
|
|
||||||
elif mode == 2: # one-shot
|
|
||||||
if q > 0:
|
|
||||||
k = len(keys) - 1
|
|
||||||
return k, 0.0
|
|
||||||
elif mode == 3: # random
|
|
||||||
r = rand32() % cycle_len
|
|
||||||
start_time = r # side effect как в sub_100031F0
|
|
||||||
|
|
||||||
k = find_segment(keys, r) # t_start <= r < t_end
|
|
||||||
kn = 0 if (k + 1 == len(keys)) else (k + 1)
|
|
||||||
t0, t1 = keys[k][1], keys[k][2]
|
|
||||||
alpha = 0.0 if t1 == t0 else (r - t0) / float(t1 - t0)
|
|
||||||
return (k, kn), alpha
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,517 +1,112 @@
|
|||||||
# MSH animation
|
# MSH animation
|
||||||
|
|
||||||
Документ фиксирует анимационную часть формата MSH (`Res8`, `Res19`) и runtime-алгоритм сэмплирования/смешивания, необходимый для 1:1 совместимого движка и toolchain (reader/writer/converter/editor).
|
`MSH animation` описывает связку `Res8 + Res19` и runtime-правила сэмплирования/смешивания поз.
|
||||||
|
|
||||||
Связанные документы:
|
Связанные страницы:
|
||||||
- [MSH core](msh-core.md) — общая структура модели и `Res1`/`Res2`.
|
|
||||||
- [NRes / RsLi](nres.md) — контейнер и атрибуты записей.
|
|
||||||
|
|
||||||
---
|
- [MSH core](msh-core.md)
|
||||||
|
- [Render pipeline](render.md)
|
||||||
|
|
||||||
## 1. Область и источники
|
## 1. Ресурсы анимации
|
||||||
|
|
||||||
Спецификация основана на:
|
### 1.1. `Res8` (пул ключей)
|
||||||
- `tmp/disassembler1/AniMesh.dll.c` (псевдо-C): `sub_10015FD0`, `sub_10012880`, `sub_10012560`.
|
|
||||||
- `tmp/disassembler2/AniMesh.dll.asm` (ASM): подтверждение x87-пути (`FISTP`) и ветвлений.
|
|
||||||
- `tmp/disassembler1/Ngi32.dll.c` (псевдо-C): `sub_10002F90`, `sub_10014540`, `sub_10014630`, `sub_10015D80`, `sub_10017E60`, `sub_10017F50`, `sub_10006D00`, `niGetProcAddress`.
|
|
||||||
- `tmp/disassembler2/Ngi32.dll.asm` (ASM): подтверждение таблицы `g_FastProc` и FPU control-word setup.
|
|
||||||
- валидации corpus (`testdata`): 435 моделей `*.msh`.
|
|
||||||
|
|
||||||
Ниже разделено на:
|
|
||||||
- **Нормативно**: обязательно для runtime-совместимости.
|
|
||||||
- **Канонично**: как устроены исходные ассеты; важно для детерминированного writer/editor.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Ресурсы и поля модели
|
|
||||||
|
|
||||||
### 2.1. Res8 — key pool (нормативно)
|
|
||||||
|
|
||||||
`Res8` — массив ключей фиксированного шага 24 байта.
|
|
||||||
|
|
||||||
```c
|
```c
|
||||||
struct AnimKey24 {
|
struct AnimKey24 {
|
||||||
float pos_x; // +0x00
|
float pos_x;
|
||||||
float pos_y; // +0x04
|
float pos_y;
|
||||||
float pos_z; // +0x08
|
float pos_z;
|
||||||
float time; // +0x0C
|
float time;
|
||||||
int16_t qx; // +0x10
|
int16_t qx;
|
||||||
int16_t qy; // +0x12
|
int16_t qy;
|
||||||
int16_t qz; // +0x14
|
int16_t qz;
|
||||||
int16_t qw; // +0x16
|
int16_t qw;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Декодирование quaternion-компонент:
|
Декодирование quaternion-компонент: `q = s16 / 32767.0`.
|
||||||
|
|
||||||
|
### 1.2. `Res19` (карта кадров)
|
||||||
|
|
||||||
```c
|
```c
|
||||||
float q = (float)s16 * (1.0f / 32767.0f);
|
uint16_t map_words[]; // size/2 элементов
|
||||||
```
|
```
|
||||||
|
|
||||||
Атрибуты NRes:
|
`Res19.attr2` хранит глобальную длину таймлайна (число кадров).
|
||||||
- `attr1 = size / 24` (количество ключей).
|
|
||||||
- `attr2 = 0` (в observed corpus).
|
|
||||||
- `attr3 = 4` (не stride; это фактический runtime-инвариант формата).
|
|
||||||
|
|
||||||
### 2.2. Res19 — frame->segment map (нормативно)
|
### 1.3. Связь с `Res1`
|
||||||
|
|
||||||
`Res19` — непрерывный `uint16` массив:
|
Для каждого узла:
|
||||||
|
|
||||||
```c
|
- `anim_map_start` (`hdr2`) — начало блока в `Res19` или `0xFFFF`.
|
||||||
uint16_t map_words[]; // count = size / 2
|
- `fallback_key` (`hdr3`) — индекс fallback-ключа в `Res8`.
|
||||||
```
|
|
||||||
|
|
||||||
Атрибуты NRes:
|
## 2. Сэмплирование узла
|
||||||
- `attr1 = size / 2` (число `uint16` слов).
|
|
||||||
- `attr2 = animFrameCount` (глобальная длина таймлайна модели в кадрах).
|
|
||||||
- `attr3 = 2`.
|
|
||||||
|
|
||||||
### 2.3. Связь с Res1 node header (нормативно)
|
Вход: время `t`, текущий узел.
|
||||||
|
Выход: `quat(w,x,y,z)` и `pos(x,y,z)`.
|
||||||
|
|
||||||
Для `Res1` со stride 38 (основной формат):
|
### 2.1. Индекс кадра
|
||||||
- `hdr2` (`node + 0x04`) = `mapStart` (`0xFFFF` => map для узла отсутствует).
|
|
||||||
- `hdr3` (`node + 0x06`) = `fallbackKeyIndex` (индекс ключа в `Res8`).
|
|
||||||
|
|
||||||
Runtime читает эти поля напрямую в `sub_10012880`.
|
Движок использует x87-совместимое округление для выражения `t - 0.5`.
|
||||||
|
Для 1:1 повторения нужно сохранить ту же политику плавающей точки.
|
||||||
|
|
||||||
### 2.4. Поля runtime-модели, задействованные анимацией (нормативно)
|
### 2.2. Выбор key index
|
||||||
|
|
||||||
Инициализация в `sub_10015FD0`:
|
1. Если кадр вне диапазона `frame_count` -> `fallback_key`.
|
||||||
- `model+0x18` -> `Res8` pointer.
|
2. Если `anim_map_start == 0xFFFF` -> `fallback_key`.
|
||||||
- `model+0x1C` -> `Res19` pointer.
|
3. Иначе берётся `map_words[anim_map_start + frame]`:
|
||||||
- `model+0x9C` <- `NResEntry(Res19).attr2` (`animFrameCount`).
|
- если значение `>= fallback_key`, тоже используется `fallback_key`;
|
||||||
|
- иначе используется значение из map.
|
||||||
|
|
||||||
---
|
### 2.3. Интерполяция
|
||||||
|
|
||||||
## 3. Runtime-сэмплирование узла (`sub_10012880`)
|
Если выбран fallback, возвращается ровно этот ключ без интерполяции.
|
||||||
|
|
||||||
Функция возвращает:
|
Иначе:
|
||||||
- quaternion (4 float) в буфер `outQuat`,
|
|
||||||
- позицию (3 float) в `outPos`.
|
|
||||||
|
|
||||||
Вход:
|
1. Берутся соседние ключи `k0` и `k1`.
|
||||||
- `t` — sample time.
|
2. Если `t` точно равен `k0.time` или `k1.time`, возвращается соответствующий ключ.
|
||||||
- текущий `nodeIndex` берётся из runtime-объекта (не из аргумента).
|
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.1. Вычисление frame index (нормативно)
|
Кватернион в runtime хранится в порядке `[w, x, y, z]`.
|
||||||
|
|
||||||
Алгоритм:
|
## 3. Смешивание двух сэмплов
|
||||||
1. `x = t - 0.5`.
|
|
||||||
2. `frame = x87 FISTP(x)` (через 64-битный промежуточный буфер).
|
|
||||||
|
|
||||||
Важно:
|
При blending между позами A и B:
|
||||||
- это не «просто floor»;
|
|
||||||
- поведение зависит от x87 control word.
|
|
||||||
|
|
||||||
В оригинальном runtime control word приводится к каноничному виду в `Ngi32::sub_10006D00`:
|
1. Выбираются валидные стороны по `blend` и валидности времени.
|
||||||
- `cw = (cw & 0xF0FF) | 0x003F`;
|
2. Если активна одна сторона, берётся она.
|
||||||
- это даёт `round-to-nearest` (RC=00), precision control `PC=00` и маскирование x87-исключений.
|
3. Если активны обе:
|
||||||
|
- применяется shortest-path flip для `qB`;
|
||||||
|
- выполняется quaternion blend;
|
||||||
|
- позиция смешивается линейно.
|
||||||
|
|
||||||
Если нужен byte/behavior 1:1, надо повторить именно x87-ветку или её точный эквивалент.
|
Матрица строится из quaternion, а translation подставляется отдельным шагом.
|
||||||
|
|
||||||
### 3.2. Выбор `keyIndex` (нормативно)
|
## 4. Каноника writer
|
||||||
|
|
||||||
```c
|
Рекомендуемые правила:
|
||||||
node = Res1 + nodeIndex * 38;
|
|
||||||
mapStart = u16(node + 4); // hdr2
|
|
||||||
fallback = u16(node + 6); // hdr3
|
|
||||||
|
|
||||||
if ((uint32_t)frame >= animFrameCount
|
1. Ключи узлов писать подряд в `Res8` в порядке узлов.
|
||||||
|| mapStart == 0xFFFF
|
2. `fallback_key` узла указывает на последний ключ его трека.
|
||||||
|| map_words[mapStart + (uint32_t)frame] >= fallback) {
|
3. Для узлов с map выделять блок длины `frame_count` в `Res19`.
|
||||||
keyIndex = fallback;
|
4. Для статических узлов: `anim_map_start = 0xFFFF`, один ключ с `time=0`.
|
||||||
} else {
|
5. `Res8.attr1 = key_count`, `Res8.attr3 = 4`.
|
||||||
keyIndex = map_words[mapStart + (uint32_t)frame];
|
6. `Res19.attr1 = map_word_count`, `Res19.attr2 = frame_count`, `Res19.attr3 = 2`.
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Критично:
|
## 5. Валидация перед сохранением
|
||||||
- runtime не проверяет bounds у `fallback` и `mapStart + frame`; некорректные данные приводят к OOB.
|
|
||||||
|
|
||||||
### 3.3. Сэмплирование ключей (нормативно)
|
- `Res8.size % 24 == 0`
|
||||||
|
- `Res19.size % 2 == 0`
|
||||||
|
- каждый `fallback_key < key_count`
|
||||||
|
- для узла с map: `anim_map_start + frame_count <= map_word_count`
|
||||||
|
- внутри трека времена ключей строго возрастают
|
||||||
|
|
||||||
`k0 = Res8[keyIndex]`.
|
## 6. Статус валидации
|
||||||
|
|
||||||
Ветки:
|
- Форматные проверки включены в `tools/msh_doc_validator.py`.
|
||||||
1. fallback-ветка из п.3.2: возвращается строго `k0` (без `k1`).
|
- В текущем окружении полный игровой корпус MSH не подключен в `testdata`, поэтому массовый прогон здесь не выполнялся.
|
||||||
2. map-ветка:
|
|
||||||
- если `t == k0.time` -> вернуть `k0`;
|
|
||||||
- иначе берётся `k1 = Res8[keyIndex + 1]`;
|
|
||||||
- если `t == k1.time` -> вернуть `k1`;
|
|
||||||
- иначе:
|
|
||||||
- `alpha = (t - k0.time) / (k1.time - k0.time)`;
|
|
||||||
- `pos = lerp(k0.pos, k1.pos, alpha)`;
|
|
||||||
- `quat = fastproc_interp(k0.quat, k1.quat, alpha)` (`g_FastProc[17]`).
|
|
||||||
|
|
||||||
Сравнение `t == key.time` строгое (битовая float-эквивалентность по FPU compare), без epsilon.
|
|
||||||
|
|
||||||
### 3.4. Порядок quaternion-компонент в runtime (нормативно)
|
|
||||||
|
|
||||||
В `Res8` компоненты лежат как `qx,qy,qz,qw`, но в runtime-буферы они попадают в порядке:
|
|
||||||
- `outQuat[0] = qw`;
|
|
||||||
- `outQuat[1] = qx`;
|
|
||||||
- `outQuat[2] = qy`;
|
|
||||||
- `outQuat[3] = qz`.
|
|
||||||
|
|
||||||
То есть все `g_FastProc`-пути в анимации работают с quaternion в порядке `float4 = [w, x, y, z]`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Runtime-смешивание двух сэмплов (`sub_10012560`)
|
|
||||||
|
|
||||||
`sub_10012560(this, tA, tB, blend, outMatrix4x4)` смешивает две позы.
|
|
||||||
|
|
||||||
### 4.1. Валидация входов (нормативно)
|
|
||||||
|
|
||||||
Выбор доступных сэмплов:
|
|
||||||
- `hasA = (blend < 1.0f) && (tA >= 0.0f)`.
|
|
||||||
- `hasB = (blend > 0.0f) && (tB >= 0.0f)`.
|
|
||||||
|
|
||||||
Ветки:
|
|
||||||
- только `hasA`: матрица из A.
|
|
||||||
- только `hasB`: матрица из B.
|
|
||||||
- оба: полноценное смешивание.
|
|
||||||
- ни одного: в оригинале путь не защищён (caller contract).
|
|
||||||
|
|
||||||
### 4.2. Смешивание quaternion (нормативно)
|
|
||||||
|
|
||||||
Перед интерполяцией выполняется shortest-path flip:
|
|
||||||
|
|
||||||
```c
|
|
||||||
if (|qA + qB|^2 < |qA - qB|^2) {
|
|
||||||
qB = -qB;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Далее:
|
|
||||||
- `q = fastproc_blend(qA, qB, blend)` (`g_FastProc[22]`);
|
|
||||||
- `outMatrix = quat_to_matrix(q)` (`g_FastProc[14]`).
|
|
||||||
|
|
||||||
### 4.3. Смешивание translation (нормативно)
|
|
||||||
|
|
||||||
Позиция смешивается отдельно:
|
|
||||||
|
|
||||||
```c
|
|
||||||
pos = (1-blend) * posA + blend * posB;
|
|
||||||
outMatrix[3] = pos.x;
|
|
||||||
outMatrix[7] = pos.y;
|
|
||||||
outMatrix[11] = pos.z;
|
|
||||||
```
|
|
||||||
|
|
||||||
(`sub_1000B8E0` подтверждает, что используются именно эти ячейки).
|
|
||||||
|
|
||||||
### 4.4. Точные `g_FastProc[14/17/22]` (нормативно)
|
|
||||||
|
|
||||||
`niGetProcAddress(i)` в `Ngi32` возвращает `g_FastProc[i]` (таблица function pointers).
|
|
||||||
В `AniMesh` используются:
|
|
||||||
- `call [g_FastProc + 0x38]` -> index 14 -> `quat_to_matrix`.
|
|
||||||
- `call [g_FastProc + 0x44]` -> index 17 -> `quat_interp`.
|
|
||||||
- `call [g_FastProc + 0x58]` -> index 22 -> `quat_blend`.
|
|
||||||
|
|
||||||
Связь с символами `Ngi32` (по адресам таблицы):
|
|
||||||
- `g_FastProc` base = `0x1003A058`;
|
|
||||||
- index 14 -> `0x1003A090`;
|
|
||||||
- index 17 -> `0x1003A09C`;
|
|
||||||
- index 22 -> `0x1003A0B0`.
|
|
||||||
|
|
||||||
Назначения по CPU-веткам (`sub_10002F90`) и семантика:
|
|
||||||
- scalar path: `14=sub_10017E60` (или `sub_10014540`), `17=22=sub_10017F50` (или `sub_10014630`);
|
|
||||||
- SIMD path (`dword_1003A168`): `14=sub_1001D830`, `17=22=sub_10015D80`;
|
|
||||||
- все варианты эквивалентны по математике.
|
|
||||||
|
|
||||||
Точная формула `quat_to_matrix` для `q=[w,x,y,z]`:
|
|
||||||
|
|
||||||
```c
|
|
||||||
m[0] = 1 - 2*(y*y + z*z);
|
|
||||||
m[1] = 2*(x*y + w*z);
|
|
||||||
m[2] = 2*(x*z - w*y);
|
|
||||||
m[3] = 0;
|
|
||||||
|
|
||||||
m[4] = 2*(x*y - w*z);
|
|
||||||
m[5] = 1 - 2*(x*x + z*z);
|
|
||||||
m[6] = 2*(y*z + w*x);
|
|
||||||
m[7] = 0;
|
|
||||||
|
|
||||||
m[8] = 2*(x*z + w*y);
|
|
||||||
m[9] = 2*(y*z - w*x);
|
|
||||||
m[10] = 1 - 2*(x*x + y*y);
|
|
||||||
m[11] = 0;
|
|
||||||
|
|
||||||
m[12] = 0;
|
|
||||||
m[13] = 0;
|
|
||||||
m[14] = 0;
|
|
||||||
m[15] = 1;
|
|
||||||
```
|
|
||||||
|
|
||||||
Точная формула `quat_interp`/`quat_blend` (`index 17` и `22`, один и тот же алгоритм):
|
|
||||||
|
|
||||||
```c
|
|
||||||
float dot = dot4(q0, q1);
|
|
||||||
float sign = 1.0f;
|
|
||||||
if (dot < 0.0f) { dot = -dot; sign = -1.0f; }
|
|
||||||
|
|
||||||
float w0, w1;
|
|
||||||
if (1.0f - dot <= 9.9999997e-6f) {
|
|
||||||
w0 = 1.0f - a;
|
|
||||||
w1 = a;
|
|
||||||
} else {
|
|
||||||
float theta = acos(dot);
|
|
||||||
float inv_sin_theta = 1.0f / sin(theta);
|
|
||||||
w1 = sin(a * theta) * inv_sin_theta;
|
|
||||||
w0 = cos(a * theta) - w1 * dot;
|
|
||||||
}
|
|
||||||
w1 *= sign;
|
|
||||||
out = w0 * q0 + w1 * q1;
|
|
||||||
```
|
|
||||||
|
|
||||||
Примечание: явной нормализации `out` в конце нет; используется закрытая форма SLERP-весов.
|
|
||||||
|
|
||||||
Reference pseudocode:
|
|
||||||
|
|
||||||
```c
|
|
||||||
void blend_pose(Model *m, float tA, float tB, float blend, float out_m[16]) {
|
|
||||||
bool hasA = (blend < 1.0f) && (tA >= 0.0f);
|
|
||||||
bool hasB = (blend > 0.0f) && (tB >= 0.0f);
|
|
||||||
|
|
||||||
float qA[4], qB[4], pA[3], pB[3];
|
|
||||||
if (hasA) sample_node_pose(m, m->node_index, tA, qA, pA);
|
|
||||||
if (hasB) sample_node_pose(m, m->node_index, tB, qB, pB);
|
|
||||||
|
|
||||||
if (hasA && !hasB) { quat_to_matrix(qA, out_m); set_translation(out_m, pA); return; }
|
|
||||||
if (!hasA && hasB) { quat_to_matrix(qB, out_m); set_translation(out_m, pB); return; }
|
|
||||||
// !hasA && !hasB: undefined by design, caller does not use this path.
|
|
||||||
|
|
||||||
if (dot4(qA + qB, qA + qB) < dot4(qA - qB, qA - qB)) negate4(qB);
|
|
||||||
float q[4];
|
|
||||||
fastproc_quat_blend(qA, qB, blend, q); // g_FastProc[22]
|
|
||||||
quat_to_matrix(q, out_m); // g_FastProc[14]
|
|
||||||
|
|
||||||
float p[3];
|
|
||||||
p[0] = (1.0f - blend) * pA[0] + blend * pB[0];
|
|
||||||
p[1] = (1.0f - blend) * pA[1] + blend * pB[1];
|
|
||||||
p[2] = (1.0f - blend) * pA[2] + blend * pB[2];
|
|
||||||
out_m[3] = p[0];
|
|
||||||
out_m[7] = p[1];
|
|
||||||
out_m[11] = p[2];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Каноническая модель данных для toolchain
|
|
||||||
|
|
||||||
Ниже правила, по которым удобно строить editor/writer. Они верифицированы на corpus (435 моделей), и совпадают с тем, как устроены оригинальные ассеты.
|
|
||||||
|
|
||||||
### 5.1. Декомпозиция key pool на track-и узлов (канонично)
|
|
||||||
|
|
||||||
Для `Res1` stride 38:
|
|
||||||
- `fallback_i = node[i].hdr3`.
|
|
||||||
- `start_i = (i == 0) ? 0 : (fallback_{i-1} + 1)`.
|
|
||||||
- track узла `i` = `Res8[start_i .. fallback_i]`.
|
|
||||||
|
|
||||||
Наблюдаемые инварианты:
|
|
||||||
- `fallback_i` строго возрастает по `i`.
|
|
||||||
- track всегда непустой (`fallback_i >= start_i`).
|
|
||||||
- для узлов без map (`hdr2 == 0xFFFF`) track длиной ровно 1 ключ.
|
|
||||||
- для узлов с map track длиной минимум 2 ключа.
|
|
||||||
|
|
||||||
### 5.2. Временная ось ключей (канонично)
|
|
||||||
|
|
||||||
В observed corpus:
|
|
||||||
- `time` всех ключей — целые неотрицательные float (`0.0, 1.0, ...`).
|
|
||||||
- внутри track: строго возрастают.
|
|
||||||
- `time(start_i) == 0.0` у каждого узла.
|
|
||||||
- глобальный `Res19.attr2 == max_i(time(fallback_i)) + 1`.
|
|
||||||
|
|
||||||
### 5.3. Компоновка Res19 map-блоков (канонично)
|
|
||||||
|
|
||||||
Если `Res19.size > 0`:
|
|
||||||
- map-блоки есть только у узлов с `hdr2 != 0xFFFF`;
|
|
||||||
- длина блока каждого такого узла: `frameCount = Res19.attr2`;
|
|
||||||
- блоки идут подряд, без дыр и overlap;
|
|
||||||
- итог: `Res19.attr1 == animated_node_count * frameCount`.
|
|
||||||
|
|
||||||
Если модель статическая:
|
|
||||||
- `Res19.size == 0`, `Res19.attr1 == 0`, `Res19.attr2 == 1`, `Res19.attr3 == 2`;
|
|
||||||
- у всех узлов `hdr2 == 0xFFFF`.
|
|
||||||
|
|
||||||
### 5.4. Семантика `map_words[f]` в каноничном writer
|
|
||||||
|
|
||||||
Для кадра `f` и track `keys[start..end]`:
|
|
||||||
- если `f < keys[start].time` или `f >= keys[end].time` -> писать `fallback = end`;
|
|
||||||
- иначе писать индекс левого ключа сегмента (`start <= idx < end`) такого, что:
|
|
||||||
- `keys[idx].time <= f < keys[idx+1].time`.
|
|
||||||
|
|
||||||
В исходных данных fallback-фреймы кодируются значением `== fallback` (не просто `>= fallback`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Reference IR для редактора/конвертера
|
|
||||||
|
|
||||||
Рекомендуемое промежуточное представление:
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct NodeAnimTrack {
|
|
||||||
uint32_t node_index;
|
|
||||||
bool has_map; // hdr2 != 0xFFFF
|
|
||||||
uint16_t fallback_key; // hdr3 (derived on write)
|
|
||||||
vector<AnimKey> keys; // local keys for node
|
|
||||||
vector<uint16_t> frame_map; // optional, size == frame_count when has_map
|
|
||||||
};
|
|
||||||
|
|
||||||
struct AnimModel {
|
|
||||||
uint32_t frame_count; // Res19.attr2
|
|
||||||
vector<NodeAnimTrack> tracks; // in node order
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Где `AnimKey`:
|
|
||||||
- `pos: float3`,
|
|
||||||
- `time: float`,
|
|
||||||
- `quat_raw: int16[4]` (для lossless),
|
|
||||||
- `quat_decoded: float4` (опционально для API/UI).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Алгоритм чтения (reader)
|
|
||||||
|
|
||||||
1. Загрузить `Res1`, `Res8`, `Res19`.
|
|
||||||
2. Проверить `Res8.size % 24 == 0`, `Res19.size % 2 == 0`.
|
|
||||||
3. Для каждого узла `i` (stride 38):
|
|
||||||
- взять `hdr2/hdr3`;
|
|
||||||
- вычислить `start_i` через предыдущий `hdr3`;
|
|
||||||
- извлечь `keys[start_i..hdr3]`;
|
|
||||||
- если `hdr2 != 0xFFFF`, взять `frame_map = Res19[hdr2 : hdr2 + frame_count]`.
|
|
||||||
4. Валидировать, что map-значения либо `< hdr3`, либо fallback (`== hdr3` канонично).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Алгоритм записи (writer)
|
|
||||||
|
|
||||||
Нормативный минимум для runtime-совместимости:
|
|
||||||
|
|
||||||
1. Собрать keys всех узлов в один `Res8` pool в node-order.
|
|
||||||
2. Записать `hdr3 = end_index` каждого узла.
|
|
||||||
3. Вычислить `frame_count` и записать в `Res19.attr2`.
|
|
||||||
4. Для узлов с map:
|
|
||||||
- `hdr2 = cursor`;
|
|
||||||
- append `frame_count` слов в `Res19`;
|
|
||||||
- `cursor += frame_count`.
|
|
||||||
5. Для узлов без map: `hdr2 = 0xFFFF`.
|
|
||||||
6. Выставить атрибуты:
|
|
||||||
- `Res8.attr1 = key_count`, `Res8.attr2 = 0`, `Res8.attr3 = 4`;
|
|
||||||
- `Res19.attr1 = map_word_count`, `Res19.attr3 = 2`.
|
|
||||||
|
|
||||||
Каноничный writer (рекомендуется):
|
|
||||||
- генерирует map по правилу §5.4;
|
|
||||||
- fallback-фреймы записывает `== fallback`;
|
|
||||||
- для статических узлов использует 1 ключ (`time=0`, `hdr2=0xFFFF`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Валидация перед сохранением
|
|
||||||
|
|
||||||
Обязательные проверки:
|
|
||||||
|
|
||||||
1. `Res8.size % 24 == 0`, `Res19.size % 2 == 0`.
|
|
||||||
2. Для каждого узла: `fallbackKeyIndex < key_count`.
|
|
||||||
3. Если `hdr2 != 0xFFFF`: `hdr2 + frame_count <= map_word_count`.
|
|
||||||
4. Для map-сегмента узла:
|
|
||||||
- любое значение `< fallback` должно удовлетворять `value + 1 < key_count`.
|
|
||||||
5. В track узла:
|
|
||||||
- `time` строго возрастает;
|
|
||||||
- при наличии map минимум 2 ключа.
|
|
||||||
6. `frame_count > 0` (игровые ассеты используют минимум 1).
|
|
||||||
|
|
||||||
Рекомендуемые проверки (каноничность):
|
|
||||||
|
|
||||||
1. `fallback_i` строго возрастает по узлам.
|
|
||||||
2. track каждого узла начинается с `time == 0`.
|
|
||||||
3. `frame_count == max_end_time + 1`.
|
|
||||||
4. map-блоки узлов без дыр/overlap.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Edge cases и совместимость
|
|
||||||
|
|
||||||
### 10.1. `Res19.size == 0`
|
|
||||||
|
|
||||||
Поддерживается runtime-ом:
|
|
||||||
- `frame_count` обычно 1;
|
|
||||||
- `hdr2 == 0xFFFF` у всех узлов;
|
|
||||||
- сэмплирование всегда через fallback key (`hdr3`).
|
|
||||||
|
|
||||||
### 10.2. Узлы без map
|
|
||||||
|
|
||||||
Это нормальный режим для статических/квазистатических узлов:
|
|
||||||
- `hdr2 = 0xFFFF`;
|
|
||||||
- `hdr3` указывает на единственный ключ узла (канонично).
|
|
||||||
|
|
||||||
### 10.3. `Res1.attr3 == 24` (legacy outlier)
|
|
||||||
|
|
||||||
В corpus встречается единично (`MTCHECK.MSH`, `testdata/nres/system.rlb`):
|
|
||||||
- `Res1.attr3 = 24`;
|
|
||||||
- `Res8` содержит 1 ключ;
|
|
||||||
- `Res19.size == 0`.
|
|
||||||
|
|
||||||
Алгоритм `sub_10012880` адресует node как stride 38, поэтому этот случай нельзя интерпретировать правилами текущего 38-byte формата. Практически это отдельный legacy-формат/legacy-path вне описанного runtime-контракта.
|
|
||||||
|
|
||||||
### 10.4. Квантование quaternion при экспорте
|
|
||||||
|
|
||||||
Для новых данных:
|
|
||||||
- используйте `round(q * 32767)`;
|
|
||||||
- clamp к `[-32767, 32767]` (каноничный диапазон ассетов).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Reference pseudocode (1:1 runtime path)
|
|
||||||
|
|
||||||
```c
|
|
||||||
void sample_node_pose(Model *m, int node_idx, float t, float out_quat[4], float out_pos[3]) {
|
|
||||||
Node38 *node = (Node38 *)((uint8_t *)m->res1 + node_idx * 38);
|
|
||||||
uint16_t map_start = node->hdr2;
|
|
||||||
uint16_t fallback = node->hdr3;
|
|
||||||
uint32_t frame_cnt = m->anim_frame_count; // Res19.attr2
|
|
||||||
|
|
||||||
int32_t frame = x87_fistp_i32((double)t - 0.5); // strict path
|
|
||||||
|
|
||||||
uint16_t key_idx;
|
|
||||||
if ((uint32_t)frame >= frame_cnt ||
|
|
||||||
map_start == 0xFFFF ||
|
|
||||||
m->res19[map_start + (uint32_t)frame] >= fallback) {
|
|
||||||
key_idx = fallback;
|
|
||||||
decode_key_quat_pos(&m->res8[key_idx], out_quat, out_pos);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
key_idx = m->res19[map_start + (uint32_t)frame];
|
|
||||||
AnimKey24 *k0 = &m->res8[key_idx];
|
|
||||||
if (t == k0->time) {
|
|
||||||
decode_key_quat_pos(k0, out_quat, out_pos);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimKey24 *k1 = &m->res8[key_idx + 1];
|
|
||||||
if (t == k1->time) {
|
|
||||||
decode_key_quat_pos(k1, out_quat, out_pos);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
float a = (t - k0->time) / (k1->time - k0->time);
|
|
||||||
out_pos[0] = lerp(k0->pos_x, k1->pos_x, a);
|
|
||||||
out_pos[1] = lerp(k0->pos_y, k1->pos_y, a);
|
|
||||||
out_pos[2] = lerp(k0->pos_z, k1->pos_z, a);
|
|
||||||
fastproc_quat_interp(decode_quat(k0), decode_quat(k1), a, out_quat); // g_FastProc[17]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 12. Границы полноты
|
|
||||||
|
|
||||||
Для основного формата (`Res1` stride 38 + `Res8` + `Res19`) эта страница покрывает runtime и toolchain-поведение на уровне, достаточном для 1:1 реализации (reader/writer/converter/editor).
|
|
||||||
|
|
||||||
Единственный подтверждённый неполный сегмент:
|
|
||||||
- legacy `Res1.attr3 == 24` (`MTCHECK.MSH`), для которого в `AniMesh` не найден отдельный открытый decode-path в рамках текущего реверса.
|
|
||||||
|
|
||||||
Для абсолютных 100% по всем историческим вариантам формата дополнительно нужно:
|
|
||||||
- найти и дореверсить runtime-код, который реально обрабатывает `Res1.attr3==24` (если он есть в других модулях/ветках);
|
|
||||||
- получить больше образцов `*.msh` с `attr3==24` для проверки writer/validator-инвариантов.
|
|
||||||
|
|||||||
@@ -1,678 +1,178 @@
|
|||||||
# MSH core
|
# MSH core
|
||||||
|
|
||||||
Документ фиксирует core-часть формата MSH на уровне, достаточном для:
|
`MSH core` описывает геометрию, слоты, батчи и базовые таблицы модели.
|
||||||
|
Документ покрывает контракт, необходимый для 1:1 воспроизведения рендера и коллизии.
|
||||||
|
|
||||||
- реализации runtime-совместимого движка (поведение 1:1);
|
Связанные страницы:
|
||||||
- реализации reader/writer/editor/converter с lossless round-trip;
|
|
||||||
- валидации ассетов и диагностики повреждений.
|
|
||||||
|
|
||||||
Связанные документы:
|
- [MSH animation](msh-animation.md)
|
||||||
|
- [Material](material.md)
|
||||||
|
- [Texture (Texm)](texture.md)
|
||||||
|
- [Render pipeline](render.md)
|
||||||
|
- [NRes / RsLi](nres.md)
|
||||||
|
|
||||||
- [NRes / RsLi](nres.md) — контейнер, каталог, атрибуты, выравнивание.
|
## 1. Общая модель
|
||||||
- [MSH animation](msh-animation.md) — детальная спецификация `Res8`/`Res19`.
|
|
||||||
- [Materials + Texm](materials-texm.md) — материальная часть и текстуры.
|
|
||||||
- [Terrain + map loading](terrain-map-loading.md) — отдельная ветка terrain-ресурсов.
|
|
||||||
|
|
||||||
---
|
MSH-модель хранится как `NRes`-контейнер.
|
||||||
|
Связь таблиц строится по `type`, а не по порядку записей.
|
||||||
|
|
||||||
## 1. Область и источники
|
Базовый путь геометрии:
|
||||||
|
|
||||||
### 1.1. Что покрывает этот документ
|
1. `Res1` выбирает slot по `(node, lod, group)`.
|
||||||
|
2. `Res2.slot` задаёт диапазоны треугольников и батчей.
|
||||||
|
3. `Res13` задаёт диапазон индексов и `baseVertex`.
|
||||||
|
4. `Res6` даёт `uint16` индексы.
|
||||||
|
5. `Res3/Res4/Res5` дают вершины, нормали и UV.
|
||||||
|
|
||||||
Этот документ покрывает именно **core-геометрию и её runtime-связи**:
|
## 2. Карта core-ресурсов
|
||||||
|
|
||||||
- `Res1` (node table),
|
| Type | Ресурс | Обязательность | Stride / layout |
|
||||||
- `Res2` (header + slots),
|
|
||||||
- `Res3/4/5` (позиции/нормали/UV0),
|
|
||||||
- `Res6` (индексы),
|
|
||||||
- `Res7` (triangle descriptors),
|
|
||||||
- `Res10` (node string table),
|
|
||||||
- `Res13` (batch table),
|
|
||||||
- optional `Res15/16/18/20`,
|
|
||||||
- точки стыка с анимацией (`Res8/Res19`).
|
|
||||||
|
|
||||||
### 1.2. Что не покрывает
|
|
||||||
|
|
||||||
- детальную семантику материалов/текстурных фаз (см. `materials-texm.md`),
|
|
||||||
- terrain-ветку (`type 11/14/21` и связанные структуры, см. `terrain-map-loading.md`),
|
|
||||||
- полную математику анимационного сэмплирования (см. `msh-animation.md`).
|
|
||||||
|
|
||||||
### 1.3. Источники реверса
|
|
||||||
|
|
||||||
Основные подтверждения:
|
|
||||||
|
|
||||||
- `tmp/disassembler1/AniMesh.dll.c`:
|
|
||||||
- `sub_10015FD0` (загрузка ресурсов core-модели),
|
|
||||||
- `sub_100124D0` (поиск slot по node/lod/group),
|
|
||||||
- `sub_10012530` (доступ к строке узла в `Res10`),
|
|
||||||
- `sub_1000B2C0`/`sub_10013680` (tri/batch path),
|
|
||||||
- `sub_1000A460` (инициализация runtime-инстансов, копирование глобальных bounds).
|
|
||||||
- `tmp/disassembler2/AniMesh.dll.asm` — подтверждение смещений/stride/ветвлений.
|
|
||||||
- валидация corpus: `testdata/nres` (435 MSH моделей, нулевые ошибки в `tools/msh_doc_validator.py`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Модель данных MSH (high-level)
|
|
||||||
|
|
||||||
MSH-модель — это NRes-контейнер, где ресурсы связаны **не по порядку, а по type-id**.
|
|
||||||
|
|
||||||
Базовая связь таблиц:
|
|
||||||
|
|
||||||
1. `Res1` для `(node, lod, group)` выбирает `slotIndex`.
|
|
||||||
2. `Res2.slot[slotIndex]` даёт диапазоны triangle/batch (`triStart/triCount`, `batchStart/batchCount`).
|
|
||||||
3. `Res13.batch` даёт `indexStart/indexCount/baseVertex`.
|
|
||||||
4. `Res6` даёт сырые `uint16` индексы.
|
|
||||||
5. `Res3/4/5` дают vertex-атрибуты по `baseVertex + index`.
|
|
||||||
|
|
||||||
Ключевая особенность runtime:
|
|
||||||
|
|
||||||
- скиннинг по узлам жёсткий (rigid attachment), без per-vertex bone weights в core-ресурсах.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Карта ресурсов и границы core
|
|
||||||
|
|
||||||
### 3.1. Ресурсы, которые читает core-loader (`sub_10015FD0`)
|
|
||||||
|
|
||||||
| Type | Ресурс | Статус в core-loader | Формат/stride |
|
|
||||||
|---:|---|---|---|
|
|---:|---|---|---|
|
||||||
| 1 | Node table | required | 38 байт/узел (основной случай) |
|
| 1 | Node table | обязательный | обычно 38 байт |
|
||||||
| 2 | Model header + slots | required | `0x8C + slotCount*0x44` |
|
| 2 | Header + slots | обязательный | `0x8C + n*68` |
|
||||||
| 3 | Positions | required | 12 |
|
| 3 | Positions | обязательный | 12 |
|
||||||
| 4 | Packed normals | обычно required | 4 |
|
| 4 | Packed normals | обычно обязательный | 4 |
|
||||||
| 5 | Packed UV0 | обычно required | 4 |
|
| 5 | Packed UV0 | обычно обязательный | 4 |
|
||||||
| 6 | Index buffer | required | 2 |
|
| 6 | Index buffer | обязательный | 2 |
|
||||||
| 7 | Triangle descriptors | обычно required | 16 |
|
| 7 | Tri descriptors | для коллизии/пикинга | 16 |
|
||||||
| 8 | Anim key pool | optional для статических | 24 |
|
| 8 | Anim key pool | для анимированных | 24 |
|
||||||
| 10 | String table | обычно required | variable |
|
| 10 | Node strings | опциональный | variable |
|
||||||
| 13 | Batch table | required | 20 |
|
| 13 | Batch table | обязательный | 20 |
|
||||||
| 15 | Доп. stream | optional | 8 |
|
| 15 | Доп. stream | опциональный | 8 |
|
||||||
| 16 | Tangent/bitangent stream | optional | 8 |
|
| 16 | Доп. stream | опциональный | 8 |
|
||||||
| 18 | Vertex color stream | optional | 4 |
|
| 18 | Доп. stream | опциональный | 4 |
|
||||||
| 19 | Anim mapping | optional для статических | 2 |
|
| 19 | Anim map | для анимированных | 2 |
|
||||||
| 20 | Доп. таблица | optional | variable |
|
| 20 | Доп. таблица | опциональный | variable |
|
||||||
|
|
||||||
### 3.2. Ресурсы, которые встречаются в MSH, но вне этого документа
|
## 3. Основные структуры
|
||||||
|
|
||||||
В corpus из 435 моделей стабильно встречаются также `type 9` и `type 17`.
|
### 3.1. `Res1` (узлы)
|
||||||
Они **не загружаются** `sub_10015FD0` и относятся к некоревым подсистемам (материалы/эффекты/прочие runtime-ветки).
|
|
||||||
|
|
||||||
### 3.3. Прямая MSH и вложенная MSH
|
|
||||||
|
|
||||||
Tooling должен поддерживать два режима входа:
|
|
||||||
|
|
||||||
- файл уже является модельным NRes (`magic NRes` и содержит `type 1/2/3/6/13`),
|
|
||||||
- файл-архив содержит `.msh` entry, внутри которой вложенный NRes модели.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Runtime-контракт загрузки (`sub_10015FD0`)
|
|
||||||
|
|
||||||
`sub_10015FD0` заполняет структуру модели размером `0xA4` байт и строит derived pointers/stride.
|
|
||||||
|
|
||||||
### 4.1. Порядок `find/open`
|
|
||||||
|
|
||||||
Фактический порядок загрузки:
|
|
||||||
|
|
||||||
1. `type 1 -> this+0x00`
|
|
||||||
2. `type 2 -> this+0x04`
|
|
||||||
3. `type 3 -> this+0x0C`
|
|
||||||
4. `type 4 -> this+0x10`
|
|
||||||
5. `type 5 -> this+0x14`
|
|
||||||
6. `type 10 -> this+0x20`
|
|
||||||
7. `type 8 -> this+0x18`
|
|
||||||
8. `type 19 -> this+0x1C`
|
|
||||||
9. `type 7 -> this+0x24`
|
|
||||||
10. `type 13 -> this+0x28`
|
|
||||||
11. `type 6 -> this+0x2C`
|
|
||||||
12. `type 15 -> this+0x34`
|
|
||||||
13. `type 16 -> this+0x38`
|
|
||||||
14. `type 18 -> this+0x64` (через отдельный `find`, optional)
|
|
||||||
15. `type 20 -> this+0x30` (optional)
|
|
||||||
|
|
||||||
### 4.2. Derived-поля (стримы)
|
|
||||||
|
|
||||||
После загрузки ставятся derived-поля:
|
|
||||||
|
|
||||||
- `this+0x08 = Res2 + 0x8C` (начало slot table),
|
|
||||||
- `this+0x3C = Res3`, `this+0x40 = 12`,
|
|
||||||
- `this+0x44 = Res4`, `this+0x48 = 4`,
|
|
||||||
- `this+0x5C = Res5`, `this+0x60 = 4`,
|
|
||||||
- `this+0x8C = Res15`, `this+0x90 = 8`,
|
|
||||||
- `this+0x94 = 0` (инициализация нулём).
|
|
||||||
|
|
||||||
Для `Res16`:
|
|
||||||
|
|
||||||
- если есть: `this+0x4C = Res16`, `this+0x50 = 8`, `this+0x54 = Res16+4`, `this+0x58 = 8`;
|
|
||||||
- если нет: `this+0x4C = 0`, `this+0x54 = 0` (stride остаются несущественными, т.к. указатели нулевые).
|
|
||||||
|
|
||||||
Для `Res18`:
|
|
||||||
|
|
||||||
- если найден: `this+0x64 = ptr`, `this+0x68 = 4`;
|
|
||||||
- иначе: `this+0x64 = 0`, `this+0x68 = 0`.
|
|
||||||
|
|
||||||
### 4.3. Метаданные из каталога NRes
|
|
||||||
|
|
||||||
- `this+0x9C` получает `entry(type19).attr2` (читается из поля `+8` каталожной записи, индекс `entry * 64`).
|
|
||||||
- `this+0xA0` получает `entry(type20).attr1` (поле `+4`) только если `type20` существует и успешно открыт; иначе `0`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Бинарные структуры core-ресурсов
|
|
||||||
|
|
||||||
Все структуры little-endian.
|
|
||||||
|
|
||||||
### 5.1. `Res1` — Node table
|
|
||||||
|
|
||||||
Базовый stride: `38` байт (`19 * uint16`).
|
|
||||||
|
|
||||||
```c
|
```c
|
||||||
struct Node38 {
|
struct Node38 {
|
||||||
uint16_t hdr0; // +0
|
uint16_t hdr0;
|
||||||
uint16_t hdr1; // +2
|
uint16_t parent_or_link;
|
||||||
uint16_t hdr2; // +4
|
uint16_t anim_map_start;
|
||||||
uint16_t hdr3; // +6
|
uint16_t fallback_key;
|
||||||
uint16_t slotIndex[15]; // +8: [lod0 g0..g4][lod1 g0..g4][lod2 g0..g4]
|
uint16_t slotIndex[15]; // lod0:g0..g4, lod1:g0..g4, lod2:g0..g4
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Подтверждённые поля
|
Формула slot-выбора:
|
||||||
|
|
||||||
- `hdr1`: parent/index-link (используется при построении инстанса), `0xFFFF` = нет.
|
|
||||||
- `hdr2`: `mapStart` для `Res19` (см. `msh-animation.md`), `0xFFFF` = нет map.
|
|
||||||
- `hdr3`: fallback key index в `Res8`.
|
|
||||||
- `hdr0`: node flags (есть битовые проверки, но полная доменная семантика не закрыта).
|
|
||||||
|
|
||||||
#### Адресация slot (runtime-функция `sub_100124D0`)
|
|
||||||
|
|
||||||
```c
|
```c
|
||||||
uint16_t get_slot_index(const Node38* node_table, uint32_t nodeIndex, int lod, int group, int current_lod) {
|
slot = node.slotIndex[lod * 5 + group]
|
||||||
int use_lod = (lod == -1) ? current_lod : lod;
|
|
||||||
int word_index = 4 + (int)nodeIndex * 19 + use_lod * 5 + group;
|
|
||||||
return *(uint16_t*)((const uint8_t*)node_table + word_index * 2);
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`0xFFFF` означает "слот отсутствует".
|
`0xFFFF` означает отсутствие слота.
|
||||||
|
|
||||||
#### Вариант stride=24
|
### 3.2. `Res2` (header + slot records)
|
||||||
|
|
||||||
В corpus есть единичный служебный outlier с `Res1.attr3 = 24`.
|
|
||||||
Для 1:1 editing существующих ассетов требуется copy-through этого варианта.
|
|
||||||
Новая генерация должна ориентироваться на stride `38`, если нет чёткой цели поддержать legacy-вариант.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.2. `Res2` — Model header + Slot table
|
|
||||||
|
|
||||||
```
|
|
||||||
Res2:
|
|
||||||
[0x00 .. 0x8B] model header (140 bytes)
|
|
||||||
[0x8C .. end] slot records (68 bytes each)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5.2.1. Header (0x8C)
|
|
||||||
|
|
||||||
Runtime копирует блоки как float-массивы:
|
|
||||||
|
|
||||||
- `0x00..0x5F` (`24 float`) — глобальный hull (`vec3[8]`),
|
|
||||||
- `0x60..0x6F` (`4 float`) — глобальная sphere (`center.xyz + radius`),
|
|
||||||
- `0x70..0x8B` (`7 float`) — сегмент/капсула (`A.xyz`, `B.xyz`, `radius`).
|
|
||||||
|
|
||||||
#### 5.2.2. Slot record (68 bytes)
|
|
||||||
|
|
||||||
```c
|
```c
|
||||||
struct Slot68 {
|
struct Slot68 {
|
||||||
uint16_t triStart; // +0
|
uint16_t triStart;
|
||||||
uint16_t triCount; // +2
|
uint16_t triCount;
|
||||||
uint16_t batchStart; // +4
|
uint16_t batchStart;
|
||||||
uint16_t batchCount; // +6
|
uint16_t batchCount;
|
||||||
|
float aabbMin[3];
|
||||||
float aabbMin[3]; // +8
|
float aabbMax[3];
|
||||||
float aabbMax[3]; // +20
|
float sphereCenter[3];
|
||||||
float sphereCenter[3]; // +32
|
float sphereRadius;
|
||||||
float sphereRadius; // +44
|
uint32_t opaque[5];
|
||||||
|
|
||||||
uint32_t unk30; // +48
|
|
||||||
uint32_t unk34; // +52
|
|
||||||
uint32_t unk38; // +56
|
|
||||||
uint32_t unk3C; // +60
|
|
||||||
uint32_t unk40; // +64
|
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
`triCount` подтверждён как длина диапазона:
|
`opaque[5]` должны сохраняться 1:1.
|
||||||
|
|
||||||
```c
|
### 3.3. `Res3`, `Res4`, `Res5`, `Res6`
|
||||||
triId >= triStart && triId < triStart + triCount
|
|
||||||
```
|
|
||||||
|
|
||||||
Хвост `unk30..unk40` должен сохраняться без изменений в editor/writer.
|
- `Res3`: `float3` позиции (`stride=12`)
|
||||||
|
- `Res4`: `int8[4]` packed normal (`stride=4`)
|
||||||
#### 5.2.3. Bounds semantics
|
- `Res5`: `int16[2]` UV (`stride=4`)
|
||||||
|
- `Res6`: `uint16` индексы (`stride=2`)
|
||||||
- Slot bounds локальны относительно узла.
|
|
||||||
- При world-трансформации sphere radius масштабируется по `max(scaleX, scaleY, scaleZ)` при неравномерном scale.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.3. `Res3` — Positions
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct Position12 {
|
|
||||||
float x;
|
|
||||||
float y;
|
|
||||||
float z;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Stride `12`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.4. `Res4` — Packed normals
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct PackedNormal4 {
|
|
||||||
int8_t nx;
|
|
||||||
int8_t ny;
|
|
||||||
int8_t nz;
|
|
||||||
int8_t nw; // семантика 4-го байта не зафиксирована
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Декодирование:
|
Декодирование:
|
||||||
|
|
||||||
```c
|
- normal = `clamp(n / 127.0, -1..1)`
|
||||||
normal = clamp((float)n / 127.0f, -1.0f, 1.0f)
|
- uv = `packed / 1024.0`
|
||||||
```
|
|
||||||
|
|
||||||
- делитель строго `127.0`;
|
### 3.4. `Res7` и `Res13`
|
||||||
- clamp обязателен из-за `-128 / 127.0`.
|
|
||||||
|
|
||||||
Кодирование (writer):
|
|
||||||
|
|
||||||
```c
|
|
||||||
int8_t q = (int8_t)clamp(round(v * 127.0f), -128, 127);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.5. `Res5` — Packed UV0
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct PackedUV4 {
|
|
||||||
int16_t u;
|
|
||||||
int16_t v;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Декодирование:
|
|
||||||
|
|
||||||
```c
|
|
||||||
uv = packed / 1024.0f
|
|
||||||
```
|
|
||||||
|
|
||||||
Кодирование:
|
|
||||||
|
|
||||||
```c
|
|
||||||
int16_t q = (int16_t)clamp(round(uv * 1024.0f), -32768, 32767);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.6. `Res6` — Index buffer
|
|
||||||
|
|
||||||
Массив `uint16`, stride `2`.
|
|
||||||
|
|
||||||
Runtime-путь:
|
|
||||||
|
|
||||||
```c
|
|
||||||
vertexIndex = Res6[indexStart + i] + batch.baseVertex;
|
|
||||||
```
|
|
||||||
|
|
||||||
`indexStart` хранится в элементах, не в байтах.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.7. `Res7` — Triangle descriptors (16 bytes)
|
|
||||||
|
|
||||||
```c
|
```c
|
||||||
struct TriDesc16 {
|
struct TriDesc16 {
|
||||||
uint16_t triFlags; // +0
|
uint16_t triFlags;
|
||||||
uint16_t linkTri0; // +2
|
uint16_t link0;
|
||||||
uint16_t linkTri1; // +4
|
uint16_t link1;
|
||||||
uint16_t linkTri2; // +6
|
uint16_t link2;
|
||||||
int16_t nX; // +8
|
int16_t nx;
|
||||||
int16_t nY; // +10
|
int16_t ny;
|
||||||
int16_t nZ; // +12
|
int16_t nz;
|
||||||
uint16_t selPacked; // +14
|
uint16_t selPacked;
|
||||||
};
|
};
|
||||||
```
|
|
||||||
|
|
||||||
- `nX/nY/nZ` декодируются через `1/32767`.
|
|
||||||
- `linkTri*` используются в tri-neighbour/collision path.
|
|
||||||
|
|
||||||
Раскладка `selPacked` (3 селектора по 2 бита):
|
|
||||||
|
|
||||||
```c
|
|
||||||
sel0 = (selPacked >> 0) & 0x3; if (sel0 == 3) sel0 = 0xFFFF;
|
|
||||||
sel1 = (selPacked >> 2) & 0x3; if (sel1 == 3) sel1 = 0xFFFF;
|
|
||||||
sel2 = (selPacked >> 4) & 0x3; if (sel2 == 3) sel2 = 0xFFFF;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.8. `Res13` — Batch table (20 bytes)
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct Batch20 {
|
struct Batch20 {
|
||||||
uint16_t batchFlags; // +0
|
uint16_t batchFlags;
|
||||||
uint16_t materialIndex; // +2
|
uint16_t materialIndex;
|
||||||
uint16_t unk4; // +4
|
uint16_t opaque4;
|
||||||
uint16_t unk6; // +6
|
uint16_t opaque6;
|
||||||
uint16_t indexCount; // +8
|
uint16_t indexCount;
|
||||||
uint32_t indexStart; // +10
|
uint32_t indexStart;
|
||||||
uint16_t unk14; // +14
|
uint16_t opaque14;
|
||||||
uint32_t baseVertex; // +16
|
uint32_t baseVertex;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
`unk4/unk6/unk14` семантически не закрыты; writer/editor должны сохранять.
|
`selPacked` хранит 3 селектора по 2 бита; значение `3` трактуется как `0xFFFF`.
|
||||||
|
|
||||||
---
|
## 4. Runtime-обход модели
|
||||||
|
|
||||||
### 5.9. `Res10` — Node string table
|
Псевдокод рендера:
|
||||||
|
|
||||||
Последовательность записей variable-length:
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct Res10Record {
|
|
||||||
uint32_t len; // длина строки без '\0'
|
|
||||||
char text[]; // если len>0: len+1 байт (с '\0'), иначе payload нет
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Переход:
|
|
||||||
|
|
||||||
```c
|
|
||||||
next = cur + 4 + (len ? len + 1 : 0);
|
|
||||||
```
|
|
||||||
|
|
||||||
`sub_10012530` возвращает:
|
|
||||||
|
|
||||||
- `NULL`, если `len == 0`,
|
|
||||||
- `record + 4`, если `len > 0`.
|
|
||||||
|
|
||||||
Индекс записи в `Res10` соответствует `nodeIndex`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.10. Optional streams
|
|
||||||
|
|
||||||
#### `Res15` (stride 8)
|
|
||||||
|
|
||||||
Дополнительный поток на вершину (семантика не полностью подтверждена).
|
|
||||||
|
|
||||||
#### `Res16` (stride 8, split 2x4)
|
|
||||||
|
|
||||||
Runtime делит поток на два interleaved подпотока:
|
|
||||||
|
|
||||||
- stream A: `base+0`, stride 8,
|
|
||||||
- stream B: `base+4`, stride 8.
|
|
||||||
|
|
||||||
В corpus из `testdata/nres` этот ресурс не встретился, но loader поддерживает.
|
|
||||||
|
|
||||||
#### `Res18` (stride 4)
|
|
||||||
|
|
||||||
Vertex color / доп. packed-канал. В corpus встречается на части моделей.
|
|
||||||
|
|
||||||
#### `Res20`
|
|
||||||
|
|
||||||
Доп. таблица неизвестной доменной семантики. Loader хранит pointer и метаданные каталога (`attr1`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.11. Точки стыка с анимацией (`Res8`/`Res19`)
|
|
||||||
|
|
||||||
Core-loader загружает:
|
|
||||||
|
|
||||||
- `Res8` в `this+0x18`,
|
|
||||||
- `Res19` в `this+0x1C`,
|
|
||||||
- `Res19.attr2` в `this+0x9C`.
|
|
||||||
|
|
||||||
Полный runtime-алгоритм сэмплирования/смешивания описан в [MSH animation](msh-animation.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Runtime-алгоритмы core
|
|
||||||
|
|
||||||
### 6.1. Slot lookup (`sub_100124D0`)
|
|
||||||
|
|
||||||
Вход: runtime-node-instance, `group`, `lod`.
|
|
||||||
|
|
||||||
1. Если нет model pointer -> `NULL`.
|
|
||||||
2. `lod == -1` -> подставить `current_lod` инстанса.
|
|
||||||
3. Вычислить `slotIndex` через формулу `4 + node*19 + lod*5 + group`.
|
|
||||||
4. Если `slotIndex == 0xFFFF` -> `NULL`.
|
|
||||||
5. Иначе вернуть `Res2.slotBase + slotIndex * 68`.
|
|
||||||
|
|
||||||
### 6.2. Node string lookup (`sub_10012530`)
|
|
||||||
|
|
||||||
1. Идти по `Res10`-записям `nodeIndex` раз.
|
|
||||||
2. Возвращать `NULL` или `char*` по правилу `len==0`.
|
|
||||||
|
|
||||||
### 6.3. Геометрический обход для рендера
|
|
||||||
|
|
||||||
Reference-путь, эквивалентный runtime-логике:
|
|
||||||
|
|
||||||
```c
|
```c
|
||||||
for each node:
|
for each node:
|
||||||
slot = resolve_slot(node, lod, group)
|
slot = resolve_slot(node, lod, group)
|
||||||
if (!slot) continue
|
if slot == none: continue
|
||||||
|
|
||||||
for b in [slot.batchStart .. slot.batchStart + slot.batchCount):
|
if culled(slot.bounds, node_transform): continue
|
||||||
batch = Res13[b]
|
|
||||||
for i in [0 .. batch.indexCount):
|
|
||||||
idx = Res6[batch.indexStart + i]
|
|
||||||
vtx = batch.baseVertex + idx
|
|
||||||
|
|
||||||
pos = Res3[vtx]
|
for b in slot.batchRange:
|
||||||
nrm = decode_res4(Res4[vtx])
|
batch = batches[b]
|
||||||
uv0 = decode_res5(Res5[vtx])
|
bind_material(batch.materialIndex)
|
||||||
|
|
||||||
|
draw_indexed(
|
||||||
|
baseVertex = batch.baseVertex,
|
||||||
|
indexStart = batch.indexStart,
|
||||||
|
indexCount = batch.indexCount
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6.4. Tri/collision path (обобщённо)
|
## 5. Критические инварианты
|
||||||
|
|
||||||
- `sub_1000B2C0` и `sub_10013680` используют tri-диапазоны слота + `Res7` link/select-поля.
|
Обязательно проверять:
|
||||||
- Для collision/picking-контекста должны быть валидны:
|
|
||||||
- `slot.triStart + slot.triCount <= triDescCount`,
|
|
||||||
- `linkTri*` либо `0xFFFF`, либо `< triDescCount`.
|
|
||||||
|
|
||||||
---
|
- `Res2.size >= 0x8C`
|
||||||
|
- `(Res2.size - 0x8C) % 68 == 0`
|
||||||
|
- `batchStart + batchCount` не выходит за `Res13`
|
||||||
|
- `triStart + triCount` не выходит за `Res7`
|
||||||
|
- `indexStart + indexCount` не выходит за `Res6`
|
||||||
|
- `baseVertex + max(indexSlice) < vertexCount`
|
||||||
|
- `slotIndex == 0xFFFF` или `< slotCount`
|
||||||
|
|
||||||
## 7. Инварианты и валидация (reader)
|
## 6. Важные edge-cases
|
||||||
|
|
||||||
### 7.1. Базовые проверки целостности
|
- Встречается редкий вариант `Res1.attr3 = 24`; для существующих ассетов нужен copy-through.
|
||||||
|
- Для строгого writer лучше генерировать `Res1` в основном формате `38` байт/узел.
|
||||||
|
- Неизвестные поля таблиц нельзя нормализовать или обнулять.
|
||||||
|
|
||||||
- каждый fixed-stride ресурс делится на stride без остатка;
|
## 7. Правила для writer/editor
|
||||||
- `Res2.size >= 0x8C`;
|
|
||||||
- `(Res2.size - 0x8C) % 68 == 0`;
|
|
||||||
- `Res2.attr1 == slotCount`, `Res2.attr3 == 68`;
|
|
||||||
- `Res3.attr3 == 12`, `Res4.attr3 == 4`, `Res5.attr3 == 4`, `Res6.attr3 == 2`, `Res7.attr3 == 16`, `Res13.attr3 == 20`;
|
|
||||||
- `Res8.attr3 == 4` (не stride), `Res19.attr3 == 2`, `Res10.attr3 == 0` (в observed assets).
|
|
||||||
|
|
||||||
### 7.2. Cross-table проверки
|
1. Сохранять неизвестные поля и неизвестные `type`-ресурсы.
|
||||||
|
2. Пересчитывать только явно вычислимые атрибуты (`attr1/attr3` и size-зависимые поля).
|
||||||
|
3. Не менять порядок/контент opaque-данных без явной цели.
|
||||||
|
4. Сериализовать little-endian, без внутреннего padding.
|
||||||
|
|
||||||
- `slot.batchStart + slot.batchCount <= batchCount`;
|
## 8. Статус валидации
|
||||||
- `slot.triStart + slot.triCount <= triDescCount`;
|
|
||||||
- `batch.indexStart + batch.indexCount <= indexCount`;
|
|
||||||
- `batch.baseVertex + max(indexSlice) < vertexCount`;
|
|
||||||
- все `Res1.slotIndex[*]` либо `0xFFFF`, либо `< slotCount`;
|
|
||||||
- для `Res10`: парсинг ровно `nodeCount` записей без хвостовых байт;
|
|
||||||
- для `Res7.linkTri*`: либо `0xFFFF`, либо `< triDescCount`.
|
|
||||||
|
|
||||||
### 7.3. Strict vs tolerant режим
|
- Инварианты формата реализованы в `tools/msh_doc_validator.py`.
|
||||||
|
- В текущем окружении нет загруженного полного корпуса игровых MSH в `testdata`, поэтому массовый прогон по ассетам здесь не выполнялся.
|
||||||
Рекомендуется 2 режима reader:
|
|
||||||
|
|
||||||
- `strict`: любое нарушение инвариантов -> ошибка;
|
|
||||||
- `tolerant`: безопасно отбрасывать/игнорировать только локально повреждённые диапазоны (без OOB).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Правила writer/editor
|
|
||||||
|
|
||||||
### 8.1. Обязательная политика для 1:1 editing
|
|
||||||
|
|
||||||
- сохранять неизвестные поля (`Slot68.unk*`, `Batch20.unk*`, `Node.hdr0` и т.д.) без модификации, если нет осознанного пересчёта;
|
|
||||||
- сохранять неизвестные resource types и их payload/атрибуты;
|
|
||||||
- не полагаться на порядок ресурсов в контейнере: lookup в runtime идёт по type-id.
|
|
||||||
|
|
||||||
### 8.2. Пересчёт атрибутов каталога
|
|
||||||
|
|
||||||
При записи изменённых ресурсов:
|
|
||||||
|
|
||||||
- `attr1` = count (или форматно-специфичное значение),
|
|
||||||
- `attr2` — по формату/семантике ресурса,
|
|
||||||
- `attr3` — stride/константа формата.
|
|
||||||
|
|
||||||
Практические правила для core:
|
|
||||||
|
|
||||||
- `Res1`: `attr1=nodeCount`, `attr3=38` (или исходный вариант, если copy-through legacy), `attr2` лучше сохранять из исходника;
|
|
||||||
- `Res2`: `attr1=slotCount`, `attr2=0`, `attr3=68`;
|
|
||||||
- `Res3/4/5/6/7/13/15/16/18`: `attr1=size/stride`, `attr2=0`, `attr3=stride`;
|
|
||||||
- `Res8`: `attr1=size/24`, `attr3=4`;
|
|
||||||
- `Res10`: `attr1=nodeCount`, `attr2=0`, `attr3=0`;
|
|
||||||
- `Res19`: `attr1=size/2`, `attr2=frameCount`, `attr3=2`.
|
|
||||||
|
|
||||||
### 8.3. Матрица зависимостей при редактировании
|
|
||||||
|
|
||||||
| Операция | Какие ресурсы обновлять |
|
|
||||||
|---|---|
|
|
||||||
| Смещение/деформация вершин | `Res3`, при необходимости `Res4`, bounds в `Res2` |
|
|
||||||
| Изменение UV | `Res5` (и опционально `Res15`) |
|
|
||||||
| Изменение topology (индексы/треугольники) | `Res6`, `Res13`, `Res7`, диапазоны `Res2.slot` |
|
|
||||||
| Изменение LOD/group назначения | `Res1.slotIndex`, возможно `Res2.slot` |
|
|
||||||
| Изменение имени узла | `Res10` |
|
|
||||||
| Изменение иерархии/анимации узлов | `Res1.hdr1/hdr2/hdr3`, `Res8`, `Res19` |
|
|
||||||
| Добавление/удаление slot | `Res2`, ссылки из `Res1`, диапазоны batch/tri |
|
|
||||||
|
|
||||||
### 8.4. Детерминированная сериализация
|
|
||||||
|
|
||||||
- little-endian для всех чисел;
|
|
||||||
- без внутреннего padding в таблицах ресурсов;
|
|
||||||
- выравнивание блоков ресурсов в NRes по 8 байт (через контейнер).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Рекомендованный canonical IR для toolchain
|
|
||||||
|
|
||||||
Минимальный IR для безопасного round-trip:
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct ModelCoreIR {
|
|
||||||
// raw payloads for unknown/passthrough types
|
|
||||||
map<uint32_t, RawResource> raw_passthrough;
|
|
||||||
|
|
||||||
vector<Node> nodes; // Res1 decoded (hdr + matrix)
|
|
||||||
Header140 header; // Res2[0x00..0x8B]
|
|
||||||
vector<Slot> slots; // Res2 slot table (включая unk tail)
|
|
||||||
|
|
||||||
vector<float3> positions; // Res3
|
|
||||||
vector<PackedNormal4> normals_raw; // Res4 raw + optional decoded cache
|
|
||||||
vector<PackedUV4> uv0_raw; // Res5 raw + optional decoded cache
|
|
||||||
|
|
||||||
vector<uint16_t> indices; // Res6
|
|
||||||
vector<TriDesc16> tri; // Res7
|
|
||||||
vector<Batch20> batches; // Res13
|
|
||||||
vector<optional<string>> node_names; // Res10
|
|
||||||
|
|
||||||
optional<vector<uint8_t>> res15_raw;
|
|
||||||
optional<vector<uint8_t>> res16_raw;
|
|
||||||
optional<vector<uint32_t>> colors_raw; // Res18
|
|
||||||
optional<RawResource> res20_raw;
|
|
||||||
|
|
||||||
// animation bridge
|
|
||||||
optional<vector<AnimKey24>> anim_keys; // Res8
|
|
||||||
optional<vector<uint16_t>> anim_map_words; // Res19
|
|
||||||
uint32_t anim_frame_count;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Принцип: где семантика неполная, хранить raw и переизлучать байт-в-байт.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Практика конвертации
|
|
||||||
|
|
||||||
### 10.1. MSH -> OBJ/GLTF
|
|
||||||
|
|
||||||
- `Res3` напрямую в позиции;
|
|
||||||
- `Res6 + Res13` в faces;
|
|
||||||
- нормали/UV декодировать через коэффициенты `1/127`, `1/1024`;
|
|
||||||
- при экспорте по LOD/group использовать `Res1` матрицу слотов, а не "все batch подряд" (если нужен runtime-эквивалент);
|
|
||||||
- пометить ограничения: core не содержит классический weight-скиннинг.
|
|
||||||
|
|
||||||
### 10.2. Обратный импорт (OBJ/GLTF -> MSH)
|
|
||||||
|
|
||||||
Для 1:1 ожидаемого поведения импортёр должен:
|
|
||||||
|
|
||||||
- строить корректные `Res13` диапазоны,
|
|
||||||
- строить/обновлять `Res2.slot` ranges и bounds,
|
|
||||||
- поддерживать quantization при упаковке (`Res4/Res5`),
|
|
||||||
- сохранять unknown-поля таблиц, если вход был редактированием существующей модели.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Наблюдения по corpus (testdata/nres)
|
|
||||||
|
|
||||||
Сводка по 435 MSH-моделям:
|
|
||||||
|
|
||||||
- валидны все 435/435 по `tools/msh_doc_validator.py`;
|
|
||||||
- основной порядок типов:
|
|
||||||
- `414`: `(1,2,3,4,5,15,13,6,7,8,19,9,10,17)`
|
|
||||||
- `21`: `(1,2,3,4,5,18,15,13,6,7,8,19,9,10,17,20)`
|
|
||||||
- `Res1.attr3`: `38` в 434 моделях, `24` в 1 модели;
|
|
||||||
- `Res18` и `Res20` встречаются в 21 модели;
|
|
||||||
- `Res16` в данном corpus не встретился;
|
|
||||||
- `Res8/Res19` присутствуют во всех моделях, но `Res19.attr2=1` часто соответствует статике.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Открытые вопросы (не блокируют 1:1)
|
|
||||||
|
|
||||||
- точная доменная семантика `Node.hdr0` битов;
|
|
||||||
- полные имена/назначения `Batch20.unk4/unk6/unk14`;
|
|
||||||
- назначение `Slot68.unk30..unk40`;
|
|
||||||
- полная семантика `Res15/Res16/Res18/Res20` payload beyond stride-level;
|
|
||||||
- точная семантика 4-го байта в `PackedNormal4`.
|
|
||||||
|
|
||||||
Для runtime/reader/writer это не критично при условии byte-preserving policy.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Чеклист реализации 1:1
|
|
||||||
|
|
||||||
### 13.1. Engine runtime
|
|
||||||
|
|
||||||
- реализован loader-порядок как в `sub_10015FD0`;
|
|
||||||
- slot lookup по формуле `4 + node*19 + lod*5 + group`;
|
|
||||||
- декодирование `Res4` через `/127.0` с clamp;
|
|
||||||
- декодирование `Res5` через `/1024.0`;
|
|
||||||
- tri селекторы `selPacked` трактуются как 2-битные с `3 -> 0xFFFF`;
|
|
||||||
- корректная обработка `0xFFFF` sentinel во всех таблицах.
|
|
||||||
|
|
||||||
### 13.2. Reader/validator
|
|
||||||
|
|
||||||
- строгая проверка stride/размеров/диапазонов;
|
|
||||||
- OOB-защита всех индексных доступов;
|
|
||||||
- поддержка both direct-model и nested `.msh` payload.
|
|
||||||
|
|
||||||
### 13.3. Writer/editor
|
|
||||||
|
|
||||||
- стабильный пересчёт `attr1/attr2/attr3`;
|
|
||||||
- сохранение unknown fields и unknown resource types;
|
|
||||||
- детерминированная сериализация NRes (8-byte align);
|
|
||||||
- regression-проверка round-trip: `decode -> encode -> decode` без расхождений структуры/диапазонов.
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,13 @@
|
|||||||
|
|
||||||
1. [MSH core](msh-core.md) — геометрия, узлы, батчи, LOD, slot-матрица.
|
1. [MSH core](msh-core.md) — геометрия, узлы, батчи, LOD, slot-матрица.
|
||||||
2. [MSH animation](msh-animation.md) — `Res8`, `Res19`, выбор ключей и интерполяция.
|
2. [MSH animation](msh-animation.md) — `Res8`, `Res19`, выбор ключей и интерполяция.
|
||||||
3. [Materials + Texm](materials-texm.md) — материалы, текстуры, палитры, `WEAR`, `LIGHTMAPS`, `Texm`.
|
3. [Material (`MAT0`)](material.md) — формат материала и фазовая анимация.
|
||||||
4. [FXID](fxid.md) — контейнер эффекта и команды runtime-потока.
|
4. [Wear (`WEAR`)](wear.md) — текстовая таблица привязки материалов/lightmap.
|
||||||
5. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру.
|
5. [Texture (`Texm`)](texture.md) — форматы текстур, mip-chain и `Page`.
|
||||||
6. [Runtime pipeline](runtime-pipeline.md) — межмодульное поведение движка в кадре.
|
6. [FXID](fxid.md) — контейнер эффекта и поток команд.
|
||||||
7. [3D implementation notes](msh-notes.md) — контрольные заметки, декодирование и открытые вопросы.
|
7. [Render pipeline](render.md) — полный процесс рендера кадра.
|
||||||
|
8. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру.
|
||||||
|
9. [3D implementation notes](msh-notes.md) — контрольные заметки и открытые вопросы.
|
||||||
|
|
||||||
## Связанные спецификации
|
## Связанные спецификации
|
||||||
|
|
||||||
|
|||||||
147
docs/specs/render.md
Normal file
147
docs/specs/render.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# 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-стратегией.
|
||||||
|
|
||||||
|
## 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`.
|
||||||
@@ -1,123 +1,8 @@
|
|||||||
# Runtime pipeline
|
# Runtime pipeline
|
||||||
|
|
||||||
Документ фиксирует runtime-поведение движка: кто кого вызывает в кадре, как проходят рендер, коллизия и подключение эффектов.
|
Актуальный документ по полному кадру находится здесь:
|
||||||
|
|
||||||
---
|
- [Render pipeline](render.md)
|
||||||
|
|
||||||
## 1.15. Алгоритм рендера модели (реконструкция)
|
Эта страница оставлена как совместимый указатель для старых ссылок.
|
||||||
|
|
||||||
```
|
|
||||||
Вход: model, instanceTransform, cameraFrustum
|
|
||||||
|
|
||||||
1. Определить current_lod ∈ {0, 1, 2} (по дистанции до камеры / настройкам).
|
|
||||||
|
|
||||||
2. Для каждого node (nodeIndex = 0 .. nodeCount−1):
|
|
||||||
a. Вычислить nodeTransform = instanceTransform × nodeLocalTransform
|
|
||||||
|
|
||||||
b. slotIndex = nodeTable[nodeIndex].slotMatrix[current_lod][group=0]
|
|
||||||
если slotIndex == 0xFFFF → пропустить узел
|
|
||||||
|
|
||||||
c. slot = slotTable[slotIndex]
|
|
||||||
|
|
||||||
d. // Frustum culling:
|
|
||||||
transformedAABB = transform(slot.aabb, nodeTransform)
|
|
||||||
если transformedAABB вне cameraFrustum → пропустить
|
|
||||||
|
|
||||||
// Альтернативно по сфере:
|
|
||||||
transformedCenter = nodeTransform × slot.sphereCenter
|
|
||||||
scaledRadius = slot.sphereRadius × max(scaleX, scaleY, scaleZ)
|
|
||||||
если сфера вне frustum → пропустить
|
|
||||||
|
|
||||||
e. Для i = 0 .. slot.batchCount − 1:
|
|
||||||
batch = batchTable[slot.batchStart + i]
|
|
||||||
|
|
||||||
// Фильтрация по batchFlags (если нужна)
|
|
||||||
|
|
||||||
// Установить материал:
|
|
||||||
setMaterial(batch.materialIndex)
|
|
||||||
|
|
||||||
// Установить transform:
|
|
||||||
setWorldMatrix(nodeTransform)
|
|
||||||
|
|
||||||
// Нарисовать:
|
|
||||||
DrawIndexedPrimitive(
|
|
||||||
baseVertex = batch.baseVertex,
|
|
||||||
indexStart = batch.indexStart,
|
|
||||||
indexCount = batch.indexCount,
|
|
||||||
primitiveType = TRIANGLE_LIST
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1.16. Алгоритм обхода треугольников (коллизия / пикинг)
|
|
||||||
|
|
||||||
```
|
|
||||||
Вход: model, nodeIndex, lod, group, filterMask, callback
|
|
||||||
|
|
||||||
1. slotIndex = nodeTable[nodeIndex].slotMatrix[lod][group]
|
|
||||||
если slotIndex == 0xFFFF → выход
|
|
||||||
|
|
||||||
2. slot = slotTable[slotIndex]
|
|
||||||
triDescIndex = slot.triStart
|
|
||||||
|
|
||||||
3. Для каждого batch в диапазоне [slot.batchStart .. slot.batchStart + slot.batchCount − 1]:
|
|
||||||
batch = batchTable[batchIndex]
|
|
||||||
triCount = batch.indexCount / 3 // округление: (indexCount + 2) / 3
|
|
||||||
|
|
||||||
Для t = 0 .. triCount − 1:
|
|
||||||
triDesc = triDescTable[triDescIndex]
|
|
||||||
|
|
||||||
// Фильтрация:
|
|
||||||
если (triDesc.triFlags & filterMask) → пропустить
|
|
||||||
|
|
||||||
// Получить индексы вершин:
|
|
||||||
idx0 = indexBuffer[batch.indexStart + t*3 + 0] + batch.baseVertex
|
|
||||||
idx1 = indexBuffer[batch.indexStart + t*3 + 1] + batch.baseVertex
|
|
||||||
idx2 = indexBuffer[batch.indexStart + t*3 + 2] + batch.baseVertex
|
|
||||||
|
|
||||||
// Получить позиции:
|
|
||||||
p0 = positions[idx0]
|
|
||||||
p1 = positions[idx1]
|
|
||||||
p2 = positions[idx2]
|
|
||||||
|
|
||||||
callback(triDesc, idx0, idx1, idx2, p0, p1, p2)
|
|
||||||
|
|
||||||
triDescIndex += 1
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3.1. Архитектурный обзор
|
|
||||||
|
|
||||||
Подсистема эффектов реализована в `Effect.dll` и интегрирована в рендер через `Terrain.dll`.
|
|
||||||
|
|
||||||
### Экспорты Effect.dll
|
|
||||||
|
|
||||||
| Функция | Описание |
|
|
||||||
|----------------------|--------------------------------------------------------|
|
|
||||||
| `CreateFxManager` | Создать менеджер эффектов (3 параметра: int, int, int) |
|
|
||||||
| `InitializeSettings` | Инициализировать настройки эффектов |
|
|
||||||
|
|
||||||
`CreateFxManager` возвращает объект‑менеджер, который регистрируется в движке и управляет всеми эффектами.
|
|
||||||
|
|
||||||
### Телеметрия из Terrain.dll
|
|
||||||
|
|
||||||
Terrain.dll содержит отладочную статистику рендера:
|
|
||||||
|
|
||||||
```
|
|
||||||
"Rendered meshes : %d"
|
|
||||||
"Rendered primitives : %d"
|
|
||||||
"Rendered faces : %d"
|
|
||||||
"Rendered particles/batches : %d/%d"
|
|
||||||
```
|
|
||||||
|
|
||||||
Из этого следует:
|
|
||||||
|
|
||||||
- Частицы рендерятся **батчами** (группами).
|
|
||||||
- Статистика частиц отделена от статистики мешей.
|
|
||||||
- Частицы интегрированы в общий 3D‑рендер‑пайплайн.
|
|
||||||
|
|
||||||
|
|||||||
125
docs/specs/texture.md
Normal file
125
docs/specs/texture.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# 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;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
82
docs/specs/wear.md
Normal 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`.
|
||||||
@@ -29,13 +29,17 @@ nav:
|
|||||||
- Behavior system: specs/behavior.md
|
- Behavior system: specs/behavior.md
|
||||||
- Control system: specs/control.md
|
- Control system: specs/control.md
|
||||||
- FXID: specs/fxid.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
|
- Missions: specs/missions.md
|
||||||
- MSH animation: specs/msh-animation.md
|
- MSH animation: specs/msh-animation.md
|
||||||
- MSH core: specs/msh-core.md
|
- MSH core: specs/msh-core.md
|
||||||
- Network system: specs/network.md
|
- Network system: specs/network.md
|
||||||
- NRes / RsLi: specs/nres.md
|
- NRes / RsLi: specs/nres.md
|
||||||
- Runtime pipeline: specs/runtime-pipeline.md
|
- Render pipeline: specs/render.md
|
||||||
|
- Runtime pointer: specs/runtime-pipeline.md
|
||||||
- Sound system: specs/sound.md
|
- Sound system: specs/sound.md
|
||||||
- Terrain + map loading: specs/terrain-map-loading.md
|
- Terrain + map loading: specs/terrain-map-loading.md
|
||||||
- UI system: specs/ui.md
|
- UI system: specs/ui.md
|
||||||
|
|||||||
Reference in New Issue
Block a user