aboutsummaryrefslogtreecommitdiff
path: root/crates/texm
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-02-19 03:46:23 +0300
committerValentin Popov <valentin@popov.link>2026-02-19 03:46:23 +0300
commit0e19660eb5122c8c52d5e909927884ad5c50b813 (patch)
tree6a53c24544ca828f08c2b6872d568b1edc1a4cef /crates/texm
parent8a69872576eed41a918643be52a80fe74a054974 (diff)
downloadfparkan-0e19660eb5122c8c52d5e909927884ad5c50b813.tar.xz
fparkan-0e19660eb5122c8c52d5e909927884ad5c50b813.zip
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.
Diffstat (limited to 'crates/texm')
-rw-r--r--crates/texm/Cargo.toml7
-rw-r--r--crates/texm/README.md15
-rw-r--r--crates/texm/src/error.rs61
-rw-r--r--crates/texm/src/lib.rs258
-rw-r--r--crates/texm/src/tests.rs150
5 files changed, 491 insertions, 0 deletions
diff --git a/crates/texm/Cargo.toml b/crates/texm/Cargo.toml
new file mode 100644
index 0000000..7085293
--- /dev/null
+++ b/crates/texm/Cargo.toml
@@ -0,0 +1,7 @@
+[package]
+name = "texm"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+nres = { path = "../nres" }
diff --git a/crates/texm/README.md b/crates/texm/README.md
new file mode 100644
index 0000000..370ac54
--- /dev/null
+++ b/crates/texm/README.md
@@ -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).
diff --git a/crates/texm/src/error.rs b/crates/texm/src/error.rs
new file mode 100644
index 0000000..a5dda77
--- /dev/null
+++ b/crates/texm/src/error.rs
@@ -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 {}
diff --git a/crates/texm/src/lib.rs b/crates/texm/src/lib.rs
new file mode 100644
index 0000000..c3616d5
--- /dev/null
+++ b/crates/texm/src/lib.rs
@@ -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;
diff --git a/crates/texm/src/tests.rs b/crates/texm/src/tests.rs
new file mode 100644
index 0000000..d021346
--- /dev/null
+++ b/crates/texm/src/tests.rs
@@ -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
+ }
+ );
+}