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:
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
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user