aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-22 12:12:27 +0300
committerValentin Popov <valentin@popov.link>2026-06-22 12:13:32 +0300
commitd0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 (patch)
treea0bd35c3940be62a5b5de1acc2366af377ffd181 /crates
parent7416fdc7e9a48837fff5056e6dc8d0774e90964b (diff)
downloadfparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.tar.xz
fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.zip
feat: implement FParkan architecture foundation
Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation.
Diffstat (limited to 'crates')
-rw-r--r--crates/common/Cargo.toml6
-rw-r--r--crates/common/src/lib.rs61
-rw-r--r--crates/fparkan-animation/Cargo.toml11
-rw-r--r--crates/fparkan-animation/src/lib.rs1217
-rw-r--r--crates/fparkan-assets/Cargo.toml21
-rw-r--r--crates/fparkan-assets/src/lib.rs481
-rw-r--r--crates/fparkan-binary/Cargo.toml11
-rw-r--r--crates/fparkan-binary/src/lib.rs308
-rw-r--r--crates/fparkan-corpus/Cargo.toml12
-rw-r--r--crates/fparkan-corpus/src/lib.rs695
-rw-r--r--crates/fparkan-diagnostics/Cargo.toml11
-rw-r--r--crates/fparkan-diagnostics/src/lib.rs301
-rw-r--r--crates/fparkan-fx/Cargo.toml15
-rw-r--r--crates/fparkan-fx/src/lib.rs1025
-rw-r--r--crates/fparkan-material/Cargo.toml18
-rw-r--r--crates/fparkan-material/src/lib.rs1272
-rw-r--r--crates/fparkan-mission-format/Cargo.toml13
-rw-r--r--crates/fparkan-mission-format/src/lib.rs1172
-rw-r--r--crates/fparkan-msh/Cargo.toml16
-rw-r--r--crates/fparkan-msh/src/lib.rs1767
-rw-r--r--crates/fparkan-nres/Cargo.toml13
-rw-r--r--crates/fparkan-nres/src/lib.rs1935
-rw-r--r--crates/fparkan-path/Cargo.toml11
-rw-r--r--crates/fparkan-path/src/lib.rs259
-rw-r--r--crates/fparkan-platform/Cargo.toml11
-rw-r--r--crates/fparkan-platform/src/lib.rs93
-rw-r--r--crates/fparkan-prototype/Cargo.toml20
-rw-r--r--crates/fparkan-prototype/src/lib.rs2114
-rw-r--r--crates/fparkan-render/Cargo.toml12
-rw-r--r--crates/fparkan-render/src/lib.rs554
-rw-r--r--crates/fparkan-resource/Cargo.toml15
-rw-r--r--crates/fparkan-resource/src/lib.rs880
-rw-r--r--crates/fparkan-rsli/Cargo.toml12
-rw-r--r--crates/fparkan-rsli/src/lib.rs2113
-rw-r--r--crates/fparkan-runtime/Cargo.toml22
-rw-r--r--crates/fparkan-runtime/src/lib.rs1099
-rw-r--r--crates/fparkan-terrain-format/Cargo.toml13
-rw-r--r--crates/fparkan-terrain-format/src/lib.rs1910
-rw-r--r--crates/fparkan-terrain/Cargo.toml15
-rw-r--r--crates/fparkan-terrain/src/lib.rs1079
-rw-r--r--crates/fparkan-test-support/Cargo.toml12
-rw-r--r--crates/fparkan-test-support/src/lib.rs25
-rw-r--r--crates/fparkan-texm/Cargo.toml14
-rw-r--r--crates/fparkan-texm/src/lib.rs1187
-rw-r--r--crates/fparkan-vfs/Cargo.toml12
-rw-r--r--crates/fparkan-vfs/src/lib.rs456
-rw-r--r--crates/fparkan-world/Cargo.toml11
-rw-r--r--crates/fparkan-world/src/lib.rs840
-rw-r--r--crates/msh-core/Cargo.toml12
-rw-r--r--crates/msh-core/README.md14
-rw-r--r--crates/msh-core/src/error.rs75
-rw-r--r--crates/msh-core/src/lib.rs434
-rw-r--r--crates/msh-core/src/tests.rs438
-rw-r--r--crates/nres/Cargo.toml10
-rw-r--r--crates/nres/README.md42
-rw-r--r--crates/nres/src/error.rs110
-rw-r--r--crates/nres/src/lib.rs772
-rw-r--r--crates/nres/src/tests.rs983
-rw-r--r--crates/render-core/Cargo.toml11
-rw-r--r--crates/render-core/README.md14
-rw-r--r--crates/render-core/src/lib.rs146
-rw-r--r--crates/render-core/src/tests.rs256
-rw-r--r--crates/render-demo/Cargo.toml31
-rw-r--r--crates/render-demo/README.md84
-rw-r--r--crates/render-demo/build.rs4
-rw-r--r--crates/render-demo/src/lib.rs591
-rw-r--r--crates/render-demo/src/main.rs997
-rw-r--r--crates/render-mission-demo/Cargo.toml33
-rw-r--r--crates/render-mission-demo/src/lib.rs881
-rw-r--r--crates/render-mission-demo/src/main.rs924
-rw-r--r--crates/render-parity/Cargo.toml9
-rw-r--r--crates/render-parity/README.md16
-rw-r--r--crates/render-parity/src/lib.rs212
-rw-r--r--crates/render-parity/src/main.rs405
-rw-r--r--crates/rsli/Cargo.toml11
-rw-r--r--crates/rsli/README.md58
-rw-r--r--crates/rsli/src/compress/deflate.rs14
-rw-r--r--crates/rsli/src/compress/lzh.rs303
-rw-r--r--crates/rsli/src/compress/lzss.rs79
-rw-r--r--crates/rsli/src/compress/mod.rs9
-rw-r--r--crates/rsli/src/compress/xor.rs29
-rw-r--r--crates/rsli/src/error.rs140
-rw-r--r--crates/rsli/src/lib.rs470
-rw-r--r--crates/rsli/src/parse.rs278
-rw-r--r--crates/rsli/src/tests.rs1338
-rw-r--r--crates/terrain-core/Cargo.toml10
-rw-r--r--crates/terrain-core/src/lib.rs281
-rw-r--r--crates/texm/Cargo.toml9
-rw-r--r--crates/texm/README.md15
-rw-r--r--crates/texm/src/error.rs86
-rw-r--r--crates/texm/src/lib.rs417
-rw-r--r--crates/texm/src/tests.rs330
-rw-r--r--crates/tma/Cargo.toml10
-rw-r--r--crates/tma/src/lib.rs485
-rw-r--r--crates/unitdat/Cargo.toml10
-rw-r--r--crates/unitdat/src/lib.rs180
96 files changed, 23103 insertions, 12133 deletions
diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml
deleted file mode 100644
index e020b17..0000000
--- a/crates/common/Cargo.toml
+++ /dev/null
@@ -1,6 +0,0 @@
-[package]
-name = "common"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs
deleted file mode 100644
index c0d57f7..0000000
--- a/crates/common/src/lib.rs
+++ /dev/null
@@ -1,61 +0,0 @@
-use std::fs;
-use std::io;
-use std::path::{Path, PathBuf};
-
-/// Resource payload that can be either borrowed from mapped bytes or owned.
-#[derive(Clone, Debug)]
-pub enum ResourceData<'a> {
- Borrowed(&'a [u8]),
- Owned(Vec<u8>),
-}
-
-impl<'a> ResourceData<'a> {
- pub fn as_slice(&self) -> &[u8] {
- match self {
- Self::Borrowed(slice) => slice,
- Self::Owned(buf) => buf.as_slice(),
- }
- }
-
- pub fn into_owned(self) -> Vec<u8> {
- match self {
- Self::Borrowed(slice) => slice.to_vec(),
- Self::Owned(buf) => buf,
- }
- }
-}
-
-impl AsRef<[u8]> for ResourceData<'_> {
- fn as_ref(&self) -> &[u8] {
- self.as_slice()
- }
-}
-
-/// Output sink used by `read_into`/`load_into` APIs.
-pub trait OutputBuffer {
- /// Writes the full payload to the sink, replacing any previous content.
- fn write_exact(&mut self, data: &[u8]) -> io::Result<()>;
-}
-
-impl OutputBuffer for Vec<u8> {
- fn write_exact(&mut self, data: &[u8]) -> io::Result<()> {
- self.clear();
- self.extend_from_slice(data);
- Ok(())
- }
-}
-
-/// Recursively collects all files under `root`.
-pub fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
- let Ok(entries) = fs::read_dir(root) else {
- return;
- };
- for entry in entries.flatten() {
- let path = entry.path();
- if path.is_dir() {
- collect_files_recursive(&path, out);
- } else if path.is_file() {
- out.push(path);
- }
- }
-}
diff --git a/crates/fparkan-animation/Cargo.toml b/crates/fparkan-animation/Cargo.toml
new file mode 100644
index 0000000..a3e5d9e
--- /dev/null
+++ b/crates/fparkan-animation/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "fparkan-animation"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-animation/src/lib.rs b/crates/fparkan-animation/src/lib.rs
new file mode 100644
index 0000000..9bf9ef5
--- /dev/null
+++ b/crates/fparkan-animation/src/lib.rs
@@ -0,0 +1,1217 @@
+#![forbid(unsafe_code)]
+#![allow(clippy::cast_precision_loss)]
+//! Deterministic animation sampling contracts.
+
+use std::fmt;
+
+/// Numeric profile.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum NumericProfile {
+ /// Portable reference.
+ PortableReference,
+ /// X87-compatible compatibility profile for captured parity vectors.
+ X87Compatibility,
+}
+
+/// Animation time in frames.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct AnimationTime(pub f32);
+
+/// Pose.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Pose {
+ /// Translation.
+ pub translation: [f32; 3],
+ /// Quaternion.
+ pub rotation: [f32; 4],
+}
+
+impl Default for Pose {
+ fn default() -> Self {
+ Self {
+ translation: [0.0; 3],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ }
+ }
+}
+
+/// Scalar animation key.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ScalarKey {
+ /// Frame number.
+ pub frame: u32,
+ /// Scalar value at the frame.
+ pub value: f32,
+}
+
+/// Pose animation key.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct PoseKey {
+ /// Frame number.
+ pub frame: u32,
+ /// Pose at the frame.
+ pub pose: Pose,
+}
+
+/// Pose key addressed by a floating-point animation time.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct TimedPoseKey {
+ /// Key time in frames.
+ pub time: AnimationTime,
+ /// Pose at the time.
+ pub pose: Pose,
+}
+
+/// Decoded 24-byte animation key.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct AnimKey24 {
+ /// Key time.
+ pub time: AnimationTime,
+ /// Pose decoded from signed fixed-point channels.
+ pub pose: Pose,
+}
+
+/// Optional frame remapping table.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct FrameMap {
+ attr_frame_count: u16,
+ frames: Vec<u16>,
+}
+
+/// Scalar track with a deterministic fallback.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ScalarTrack {
+ fallback: f32,
+ keys: Vec<ScalarKey>,
+}
+
+/// Pose track with a deterministic fallback.
+#[derive(Clone, Debug, PartialEq)]
+pub struct PoseTrack {
+ fallback: Pose,
+ keys: Vec<PoseKey>,
+}
+
+/// Pose track keyed by floating-point animation times.
+#[derive(Clone, Debug, PartialEq)]
+pub struct TimedPoseTrack {
+ fallback: Pose,
+ keys: Vec<TimedPoseKey>,
+}
+
+/// Parent index for a node in an animation hierarchy.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct ParentIndex(pub Option<u16>);
+
+/// Node pose after hierarchy evaluation.
+#[derive(Clone, Debug, PartialEq)]
+pub struct NodePoseBuffer {
+ /// Global poses in node order.
+ pub poses: Vec<Pose>,
+}
+
+/// Difference between portable and x87-compatible samples.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct NumericProfileDifference {
+ /// Time that was sampled.
+ pub time: AnimationTime,
+ /// Per-axis translation delta: x87 - portable.
+ pub translation_delta: [f32; 3],
+ /// Per-component quaternion delta: x87 - portable.
+ pub rotation_delta: [f32; 4],
+}
+
+/// Material animation state.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct MaterialAnimationState {
+ /// Time used by material phase evaluation.
+ pub time: AnimationTime,
+ /// Named deterministic random stream.
+ pub rng: NamedRngStream,
+}
+
+/// Named deterministic random stream.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct NamedRngStream {
+ state: u64,
+ calls: u64,
+}
+
+/// Animation sampling error.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum AnimationError {
+ /// Track keys are not sorted by frame or contain duplicate frames.
+ NonMonotonicKeys,
+ /// Time was NaN or infinite.
+ InvalidTime,
+ /// Quaternion could not be normalized.
+ InvalidQuaternion,
+ /// Input buffer size is invalid for the expected record stride.
+ InvalidSize,
+ /// Frame map entry points outside the clip frame count.
+ InvalidFrameMapValue {
+ /// Requested mapped frame.
+ frame: u16,
+ /// Declared frame count.
+ frame_count: u16,
+ },
+ /// Parent index is not before its child.
+ ParentOrder {
+ /// Child node index.
+ child: usize,
+ /// Parent node index.
+ parent: usize,
+ },
+ /// Parent graph contains a cycle.
+ ParentCycle {
+ /// Node where the cycle was detected.
+ node: usize,
+ },
+}
+
+impl fmt::Display for AnimationError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{self:?}")
+ }
+}
+
+impl std::error::Error for AnimationError {}
+
+impl ScalarTrack {
+ /// Creates a scalar track.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AnimationError::NonMonotonicKeys`] when keys are not strictly
+ /// sorted by frame.
+ pub fn new(fallback: f32, keys: Vec<ScalarKey>) -> Result<Self, AnimationError> {
+ validate_scalar_keys(&keys)?;
+ Ok(Self { fallback, keys })
+ }
+
+ /// Returns the keys in frame order.
+ #[must_use]
+ pub fn keys(&self) -> &[ScalarKey] {
+ &self.keys
+ }
+
+ /// Samples the scalar track with clamp-and-linear semantics.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AnimationError::InvalidTime`] when `time` is NaN or infinite.
+ pub fn sample(&self, time: AnimationTime) -> Result<f32, AnimationError> {
+ validate_time(time)?;
+ let Some(first) = self.keys.first() else {
+ return Ok(self.fallback);
+ };
+ if time.0 <= first.frame as f32 {
+ return Ok(first.value);
+ }
+
+ for pair in self.keys.windows(2) {
+ let left = pair[0];
+ let right = pair[1];
+ let left_frame = left.frame as f32;
+ let right_frame = right.frame as f32;
+ if time.0 <= right_frame {
+ let span = right_frame - left_frame;
+ let t = if span == 0.0 {
+ 0.0
+ } else {
+ (time.0 - left_frame) / span
+ };
+ return Ok(lerp(left.value, right.value, t));
+ }
+ }
+
+ Ok(self.keys.last().map_or(self.fallback, |key| key.value))
+ }
+}
+
+impl AnimKey24 {
+ /// Decodes one 24-byte animation key.
+ ///
+ /// Layout: `position:f32x3`, `time:f32`, `rotation:i16x4` scaled by
+ /// `1/32767`.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AnimationError::InvalidSize`] when the record is not exactly
+ /// 24 bytes or [`AnimationError::InvalidTime`] when the key time is not
+ /// finite.
+ pub fn decode(bytes: &[u8]) -> Result<Self, AnimationError> {
+ if bytes.len() != 24 {
+ return Err(AnimationError::InvalidSize);
+ }
+ let translation = [
+ read_f32(bytes, 0)?,
+ read_f32(bytes, 4)?,
+ read_f32(bytes, 8)?,
+ ];
+ let time = AnimationTime(read_f32(bytes, 12)?);
+ validate_time(time)?;
+ let raw_rotation = [
+ f32::from(read_i16(bytes, 16)?) / 32767.0,
+ f32::from(read_i16(bytes, 18)?) / 32767.0,
+ f32::from(read_i16(bytes, 20)?) / 32767.0,
+ f32::from(read_i16(bytes, 22)?) / 32767.0,
+ ];
+ Ok(Self {
+ time,
+ pose: Pose {
+ translation,
+ rotation: raw_rotation,
+ },
+ })
+ }
+
+ /// Returns a pose ready for runtime sampling.
+ ///
+ /// Degenerate all-zero quaternions are treated as identity, matching the
+ /// safe static-node fallback used by legacy animation data.
+ #[must_use]
+ pub fn sampling_pose(&self) -> Pose {
+ let rotation = normalize_quat(self.pose.rotation).unwrap_or(Pose::default().rotation);
+ Pose {
+ translation: self.pose.translation,
+ rotation,
+ }
+ }
+}
+
+impl TimedPoseTrack {
+ /// Creates a pose track keyed by floating-point times.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AnimationError::NonMonotonicKeys`] when keys are not strictly
+ /// sorted by time, [`AnimationError::InvalidTime`] when a key time is not
+ /// finite, or [`AnimationError::InvalidQuaternion`] when a key rotation
+ /// cannot be normalized.
+ pub fn new(fallback: Pose, keys: Vec<TimedPoseKey>) -> Result<Self, AnimationError> {
+ validate_pose(&fallback)?;
+ validate_timed_pose_keys(&keys)?;
+ Ok(Self { fallback, keys })
+ }
+
+ /// Returns keys in time order.
+ #[must_use]
+ pub fn keys(&self) -> &[TimedPoseKey] {
+ &self.keys
+ }
+
+ /// Samples the pose track with linear translation and normalized
+ /// quaternion interpolation.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AnimationError::InvalidTime`] when `time` is NaN or infinite.
+ pub fn sample(&self, time: AnimationTime) -> Result<Pose, AnimationError> {
+ validate_time(time)?;
+ let Some(first) = self.keys.first() else {
+ return Ok(self.fallback);
+ };
+ if time.0 <= first.time.0 {
+ return Ok(first.pose);
+ }
+
+ for pair in self.keys.windows(2) {
+ let left = pair[0];
+ let right = pair[1];
+ if time.0 <= right.time.0 {
+ let span = right.time.0 - left.time.0;
+ let t = if span == 0.0 {
+ 0.0
+ } else {
+ (time.0 - left.time.0) / span
+ };
+ return blend_pose(left.pose, right.pose, t);
+ }
+ }
+
+ Ok(self.keys.last().map_or(self.fallback, |key| key.pose))
+ }
+}
+
+impl FrameMap {
+ /// Decodes a `u16` frame map from type-19 bytes and an attr frame count.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AnimationError::InvalidSize`] when bytes are not u16-aligned.
+ pub fn decode(bytes: &[u8], attr_frame_count: u16) -> Result<Self, AnimationError> {
+ if !bytes.len().is_multiple_of(2) {
+ return Err(AnimationError::InvalidSize);
+ }
+ let mut frames = Vec::with_capacity(bytes.len() / 2);
+ for offset in (0..bytes.len()).step_by(2) {
+ frames.push(read_u16(bytes, offset)?);
+ }
+ Ok(Self {
+ attr_frame_count,
+ frames,
+ })
+ }
+
+ /// Resolves a logical frame through the optional map.
+ ///
+ /// Missing map entries and invalid mapped values fall back to the input
+ /// frame, which is the documented compatibility branch for incomplete
+ /// legacy clips.
+ #[must_use]
+ pub fn resolve_or_fallback(&self, logical_frame: u16) -> u16 {
+ let Some(mapped) = self.frames.get(usize::from(logical_frame)).copied() else {
+ return logical_frame;
+ };
+ if mapped < self.attr_frame_count {
+ mapped
+ } else {
+ logical_frame
+ }
+ }
+
+ /// Resolves a logical frame and reports invalid mapped values explicitly.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AnimationError::InvalidFrameMapValue`] when the mapped frame is
+ /// outside the declared attr frame count.
+ pub fn resolve_strict(&self, logical_frame: u16) -> Result<u16, AnimationError> {
+ let Some(mapped) = self.frames.get(usize::from(logical_frame)).copied() else {
+ return Ok(logical_frame);
+ };
+ if mapped < self.attr_frame_count {
+ Ok(mapped)
+ } else {
+ Err(AnimationError::InvalidFrameMapValue {
+ frame: mapped,
+ frame_count: self.attr_frame_count,
+ })
+ }
+ }
+
+ /// Declared frame count from attributes.
+ #[must_use]
+ pub const fn attr_frame_count(&self) -> u16 {
+ self.attr_frame_count
+ }
+
+ /// Raw frame map values.
+ #[must_use]
+ pub fn frames(&self) -> &[u16] {
+ &self.frames
+ }
+}
+
+impl PoseTrack {
+ /// Creates a pose track.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AnimationError::NonMonotonicKeys`] when keys are not strictly
+ /// sorted by frame, or [`AnimationError::InvalidQuaternion`] when a key
+ /// rotation cannot be normalized.
+ pub fn new(fallback: Pose, keys: Vec<PoseKey>) -> Result<Self, AnimationError> {
+ validate_pose(&fallback)?;
+ validate_pose_keys(&keys)?;
+ Ok(Self { fallback, keys })
+ }
+
+ /// Returns the keys in frame order.
+ #[must_use]
+ pub fn keys(&self) -> &[PoseKey] {
+ &self.keys
+ }
+
+ /// Samples the pose track with linear translation and normalized quaternion
+ /// interpolation.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AnimationError::InvalidTime`] when `time` is NaN or infinite.
+ pub fn sample(
+ &self,
+ time: AnimationTime,
+ _profile: NumericProfile,
+ ) -> Result<Pose, AnimationError> {
+ validate_time(time)?;
+ let Some(first) = self.keys.first() else {
+ return Ok(self.fallback);
+ };
+ if time.0 <= first.frame as f32 {
+ return Ok(first.pose);
+ }
+
+ for pair in self.keys.windows(2) {
+ let left = pair[0];
+ let right = pair[1];
+ let left_frame = left.frame as f32;
+ let right_frame = right.frame as f32;
+ if time.0 <= right_frame {
+ let span = right_frame - left_frame;
+ let t = if span == 0.0 {
+ 0.0
+ } else {
+ (time.0 - left_frame) / span
+ };
+ return blend_pose(left.pose, right.pose, t);
+ }
+ }
+
+ Ok(self.keys.last().map_or(self.fallback, |key| key.pose))
+ }
+}
+
+impl NamedRngStream {
+ /// Creates a deterministic stream from a global seed and a stable stream
+ /// name.
+ #[must_use]
+ pub fn new(seed: u64, name: &str) -> Self {
+ let mut state = 0x9e37_79b9_7f4a_7c15_u64 ^ seed;
+ for byte in name.as_bytes() {
+ state ^= u64::from(*byte);
+ state = splitmix64(state);
+ }
+ if state == 0 {
+ state = 0x6a09_e667_f3bc_c909;
+ }
+ Self { state, calls: 0 }
+ }
+
+ /// Returns how many values have been generated.
+ #[must_use]
+ pub const fn calls(&self) -> u64 {
+ self.calls
+ }
+
+ /// Returns the next deterministic `u32`.
+ pub fn next_u32(&mut self) -> u32 {
+ self.calls = self.calls.wrapping_add(1);
+ self.state = splitmix64(self.state);
+ (self.state >> 32) as u32
+ }
+
+ /// Returns the next deterministic scalar in `[0, 1]`.
+ pub fn next_unit_f32(&mut self) -> f32 {
+ let value = self.next_u32() >> 8;
+ value as f32 / 0x00ff_ffff_u32 as f32
+ }
+}
+
+impl MaterialAnimationState {
+ /// Advances material time without drawing or emitting side effects.
+ #[must_use]
+ pub fn advanced(self, delta_frames: f32) -> Self {
+ Self {
+ time: AnimationTime(self.time.0 + delta_frames),
+ rng: self.rng,
+ }
+ }
+}
+
+/// Builds a canonical pose capture from a track and frame list.
+///
+/// # Errors
+///
+/// Returns [`AnimationError`] when pose sampling fails.
+pub fn canonical_pose_capture(
+ track: &PoseTrack,
+ times: &[AnimationTime],
+) -> Result<Vec<u8>, AnimationError> {
+ let mut out = Vec::new();
+ for time in times {
+ let pose = track.sample(*time, NumericProfile::PortableReference)?;
+ out.extend_from_slice(b"P,");
+ write_f32_bits(&mut out, time.0);
+ for value in pose.translation {
+ out.push(b',');
+ write_f32_bits(&mut out, value);
+ }
+ for value in pose.rotation {
+ out.push(b',');
+ write_f32_bits(&mut out, value);
+ }
+ out.push(b'\n');
+ }
+ Ok(out)
+}
+
+/// Builds a canonical pose capture from a float-time track.
+///
+/// # Errors
+///
+/// Returns [`AnimationError`] when pose sampling fails.
+pub fn canonical_timed_pose_capture(
+ track: &TimedPoseTrack,
+ times: &[AnimationTime],
+) -> Result<Vec<u8>, AnimationError> {
+ let mut out = Vec::new();
+ for time in times {
+ let pose = track.sample(*time)?;
+ out.extend_from_slice(b"P,");
+ write_f32_bits(&mut out, time.0);
+ for value in pose.translation {
+ out.push(b',');
+ write_f32_bits(&mut out, value);
+ }
+ for value in pose.rotation {
+ out.push(b',');
+ write_f32_bits(&mut out, value);
+ }
+ out.push(b'\n');
+ }
+ Ok(out)
+}
+
+/// Blends two optional poses.
+///
+/// When only one side is valid, the valid side is returned. When both sides are
+/// absent, [`AnimationError::InvalidQuaternion`] is returned as a deterministic
+/// invalid-pose marker.
+///
+/// # Errors
+///
+/// Returns [`AnimationError`] when both inputs are invalid or quaternion
+/// interpolation cannot be normalized.
+pub fn blend_optional_pose(
+ left: Option<Pose>,
+ right: Option<Pose>,
+ weight: f32,
+) -> Result<Pose, AnimationError> {
+ match (left, right) {
+ (Some(left), Some(right)) => blend_pose(left, right, weight),
+ (Some(pose), None) | (None, Some(pose)) => Ok(pose),
+ (None, None) => Err(AnimationError::InvalidQuaternion),
+ }
+}
+
+/// Evaluates local poses into global poses with parent-before-child ordering.
+///
+/// # Errors
+///
+/// Returns [`AnimationError::ParentOrder`] when a parent appears after its
+/// child, or [`AnimationError::ParentCycle`] when a node is its own ancestor.
+pub fn evaluate_hierarchy(
+ parents: &[ParentIndex],
+ local_poses: &[Pose],
+) -> Result<NodePoseBuffer, AnimationError> {
+ if parents.len() != local_poses.len() {
+ return Err(AnimationError::InvalidSize);
+ }
+ for (index, parent) in parents.iter().enumerate() {
+ let Some(raw_parent) = parent.0 else {
+ continue;
+ };
+ let parent_index = usize::from(raw_parent);
+ if parent_index == index {
+ return Err(AnimationError::ParentCycle { node: index });
+ }
+ if parent_index > index {
+ return Err(AnimationError::ParentOrder {
+ child: index,
+ parent: parent_index,
+ });
+ }
+ }
+
+ let mut global = Vec::with_capacity(local_poses.len());
+ for (index, pose) in local_poses.iter().copied().enumerate() {
+ let composed = if let Some(parent) = parents[index].0 {
+ compose_pose(global[usize::from(parent)], pose)?
+ } else {
+ pose
+ };
+ global.push(composed);
+ }
+ Ok(NodePoseBuffer { poses: global })
+}
+
+/// Compares portable and x87-compatible profile samples explicitly.
+///
+/// # Errors
+///
+/// Returns [`AnimationError`] when either profile fails to sample.
+pub fn compare_numeric_profiles(
+ track: &PoseTrack,
+ times: &[AnimationTime],
+) -> Result<Vec<NumericProfileDifference>, AnimationError> {
+ let mut out = Vec::with_capacity(times.len());
+ for time in times {
+ let portable = track.sample(*time, NumericProfile::PortableReference)?;
+ let x87 = track.sample(*time, NumericProfile::X87Compatibility)?;
+ out.push(NumericProfileDifference {
+ time: *time,
+ translation_delta: [
+ x87.translation[0] - portable.translation[0],
+ x87.translation[1] - portable.translation[1],
+ x87.translation[2] - portable.translation[2],
+ ],
+ rotation_delta: [
+ x87.rotation[0] - portable.rotation[0],
+ x87.rotation[1] - portable.rotation[1],
+ x87.rotation[2] - portable.rotation[2],
+ x87.rotation[3] - portable.rotation[3],
+ ],
+ });
+ }
+ Ok(out)
+}
+
+fn validate_scalar_keys(keys: &[ScalarKey]) -> Result<(), AnimationError> {
+ for pair in keys.windows(2) {
+ if pair[0].frame >= pair[1].frame {
+ return Err(AnimationError::NonMonotonicKeys);
+ }
+ }
+ Ok(())
+}
+
+fn validate_pose_keys(keys: &[PoseKey]) -> Result<(), AnimationError> {
+ for key in keys {
+ validate_pose(&key.pose)?;
+ }
+ for pair in keys.windows(2) {
+ if pair[0].frame >= pair[1].frame {
+ return Err(AnimationError::NonMonotonicKeys);
+ }
+ }
+ Ok(())
+}
+
+fn validate_timed_pose_keys(keys: &[TimedPoseKey]) -> Result<(), AnimationError> {
+ for key in keys {
+ validate_time(key.time)?;
+ validate_pose(&key.pose)?;
+ }
+ for pair in keys.windows(2) {
+ if pair[0].time.0 >= pair[1].time.0 {
+ return Err(AnimationError::NonMonotonicKeys);
+ }
+ }
+ Ok(())
+}
+
+fn validate_pose(pose: &Pose) -> Result<(), AnimationError> {
+ normalize_quat(pose.rotation).map(|_| ())
+}
+
+fn validate_time(time: AnimationTime) -> Result<(), AnimationError> {
+ if time.0.is_finite() {
+ Ok(())
+ } else {
+ Err(AnimationError::InvalidTime)
+ }
+}
+
+fn blend_pose(left: Pose, right: Pose, t: f32) -> Result<Pose, AnimationError> {
+ let mut right_rotation = right.rotation;
+ if dot4(left.rotation, right_rotation) < 0.0 {
+ for value in &mut right_rotation {
+ *value = -*value;
+ }
+ }
+
+ Ok(Pose {
+ translation: [
+ lerp(left.translation[0], right.translation[0], t),
+ lerp(left.translation[1], right.translation[1], t),
+ lerp(left.translation[2], right.translation[2], t),
+ ],
+ rotation: normalize_quat([
+ lerp(left.rotation[0], right_rotation[0], t),
+ lerp(left.rotation[1], right_rotation[1], t),
+ lerp(left.rotation[2], right_rotation[2], t),
+ lerp(left.rotation[3], right_rotation[3], t),
+ ])?,
+ })
+}
+
+fn normalize_quat(quat: [f32; 4]) -> Result<[f32; 4], AnimationError> {
+ let len2 = dot4(quat, quat);
+ if !len2.is_finite() || len2 <= f32::EPSILON {
+ return Err(AnimationError::InvalidQuaternion);
+ }
+ let inv = len2.sqrt().recip();
+ Ok([quat[0] * inv, quat[1] * inv, quat[2] * inv, quat[3] * inv])
+}
+
+fn dot4(left: [f32; 4], right: [f32; 4]) -> f32 {
+ left[0] * right[0] + left[1] * right[1] + left[2] * right[2] + left[3] * right[3]
+}
+
+fn lerp(left: f32, right: f32, t: f32) -> f32 {
+ left + (right - left) * t
+}
+
+fn splitmix64(mut value: u64) -> u64 {
+ value = value.wrapping_add(0x9e37_79b9_7f4a_7c15);
+ let mut mixed = value;
+ mixed = (mixed ^ (mixed >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
+ mixed = (mixed ^ (mixed >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
+ mixed ^ (mixed >> 31)
+}
+
+fn write_f32_bits(out: &mut Vec<u8>, value: f32) {
+ out.extend_from_slice(format!("{:08x}", value.to_bits()).as_bytes());
+}
+
+fn compose_pose(parent: Pose, child: Pose) -> Result<Pose, AnimationError> {
+ Ok(Pose {
+ translation: [
+ parent.translation[0] + child.translation[0],
+ parent.translation[1] + child.translation[1],
+ parent.translation[2] + child.translation[2],
+ ],
+ rotation: normalize_quat(mul_quat(parent.rotation, child.rotation))?,
+ })
+}
+
+fn mul_quat(left: [f32; 4], right: [f32; 4]) -> [f32; 4] {
+ let [lx, ly, lz, lw] = left;
+ let [rx, ry, rz, rw] = right;
+ [
+ lw * rx + lx * rw + ly * rz - lz * ry,
+ lw * ry - lx * rz + ly * rw + lz * rx,
+ lw * rz + lx * ry - ly * rx + lz * rw,
+ lw * rw - lx * rx - ly * ry - lz * rz,
+ ]
+}
+
+fn read_u16(bytes: &[u8], offset: usize) -> Result<u16, AnimationError> {
+ let raw = bytes
+ .get(offset..offset + 2)
+ .ok_or(AnimationError::InvalidSize)?;
+ Ok(u16::from_le_bytes(
+ raw.try_into().map_err(|_| AnimationError::InvalidSize)?,
+ ))
+}
+
+fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, AnimationError> {
+ let raw = bytes
+ .get(offset..offset + 4)
+ .ok_or(AnimationError::InvalidSize)?;
+ Ok(u32::from_le_bytes(
+ raw.try_into().map_err(|_| AnimationError::InvalidSize)?,
+ ))
+}
+
+fn read_f32(bytes: &[u8], offset: usize) -> Result<f32, AnimationError> {
+ Ok(f32::from_bits(read_u32(bytes, offset)?))
+}
+
+fn read_i16(bytes: &[u8], offset: usize) -> Result<i16, AnimationError> {
+ let raw = bytes
+ .get(offset..offset + 2)
+ .ok_or(AnimationError::InvalidSize)?;
+ Ok(i16::from_le_bytes(
+ raw.try_into().map_err(|_| AnimationError::InvalidSize)?,
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn scalar_track_clamps_and_interpolates() {
+ let track = ScalarTrack::new(
+ -1.0,
+ vec![
+ ScalarKey {
+ frame: 10,
+ value: 2.0,
+ },
+ ScalarKey {
+ frame: 20,
+ value: 6.0,
+ },
+ ],
+ )
+ .expect("track");
+
+ assert_eq!(track.sample(AnimationTime(0.0)).expect("sample"), 2.0);
+ assert_eq!(track.sample(AnimationTime(15.0)).expect("sample"), 4.0);
+ assert_eq!(track.sample(AnimationTime(30.0)).expect("sample"), 6.0);
+ }
+
+ #[test]
+ fn anim_key24_decodes_signed_quaternion() {
+ let mut bytes = [0_u8; 24];
+ bytes[0..4].copy_from_slice(&(-1.0_f32).to_bits().to_le_bytes());
+ bytes[4..8].copy_from_slice(&(2.0_f32).to_bits().to_le_bytes());
+ bytes[8..12].copy_from_slice(&(0.0_f32).to_bits().to_le_bytes());
+ bytes[12..16].copy_from_slice(&(12.5_f32).to_bits().to_le_bytes());
+ bytes[16..18].copy_from_slice(&0_i16.to_le_bytes());
+ bytes[18..20].copy_from_slice(&(-23170_i16).to_le_bytes());
+ bytes[20..22].copy_from_slice(&0_i16.to_le_bytes());
+ bytes[22..24].copy_from_slice(&23170_i16.to_le_bytes());
+
+ let key = AnimKey24::decode(&bytes).expect("key");
+
+ assert_eq!(key.time, AnimationTime(12.5));
+ assert_eq!(key.pose.translation, [-1.0, 2.0, 0.0]);
+ assert!(key.pose.rotation[1] < 0.0);
+ assert!((key.pose.rotation[1] + std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
+ assert!((key.pose.rotation[3] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
+ }
+
+ #[test]
+ fn frame_map_decodes_u16_and_uses_attr_frame_count() {
+ let map = FrameMap::decode(&[2, 0, 4, 0], 5).expect("map");
+
+ assert_eq!(map.attr_frame_count(), 5);
+ assert_eq!(map.frames(), &[2, 4]);
+ assert_eq!(map.resolve_strict(0).expect("mapped"), 2);
+ assert_eq!(map.resolve_strict(2).expect("fallback missing"), 2);
+ }
+
+ #[test]
+ fn frame_map_falls_back_when_absent_or_invalid() {
+ let empty = FrameMap::decode(&[], 3).expect("empty map");
+ let invalid = FrameMap::decode(&[5, 0], 3).expect("invalid map");
+
+ assert_eq!(empty.resolve_or_fallback(2), 2);
+ assert_eq!(invalid.resolve_or_fallback(0), 0);
+ assert_eq!(
+ invalid.resolve_strict(0).expect_err("invalid mapped value"),
+ AnimationError::InvalidFrameMapValue {
+ frame: 5,
+ frame_count: 3,
+ }
+ );
+ }
+
+ #[test]
+ fn exact_key_time_returns_exact_pose() {
+ let target = Pose {
+ translation: [1.0, 2.0, 3.0],
+ rotation: [0.0, 0.0, 1.0, 0.0],
+ };
+ let track = PoseTrack::new(
+ Pose::default(),
+ vec![
+ PoseKey {
+ frame: 0,
+ pose: Pose::default(),
+ },
+ PoseKey {
+ frame: 8,
+ pose: target,
+ },
+ ],
+ )
+ .expect("track");
+
+ assert_eq!(
+ track
+ .sample(AnimationTime(8.0), NumericProfile::PortableReference)
+ .expect("pose"),
+ target
+ );
+ }
+
+ #[test]
+ fn scalar_track_uses_fallback_when_empty() {
+ let track = ScalarTrack::new(3.5, Vec::new()).expect("track");
+
+ assert_eq!(track.sample(AnimationTime(4.0)).expect("sample"), 3.5);
+ }
+
+ #[test]
+ fn rejects_unsorted_keys_and_invalid_time() {
+ let track = ScalarTrack::new(
+ 0.0,
+ vec![
+ ScalarKey {
+ frame: 7,
+ value: 0.0,
+ },
+ ScalarKey {
+ frame: 7,
+ value: 1.0,
+ },
+ ],
+ );
+ assert_eq!(
+ track.expect_err("unsorted"),
+ AnimationError::NonMonotonicKeys
+ );
+
+ let track = ScalarTrack::new(0.0, Vec::new()).expect("track");
+ assert_eq!(
+ track
+ .sample(AnimationTime(f32::NAN))
+ .expect_err("invalid time"),
+ AnimationError::InvalidTime
+ );
+ }
+
+ #[test]
+ fn pose_track_blends_translation_and_rotation() {
+ let track = PoseTrack::new(
+ Pose::default(),
+ vec![
+ PoseKey {
+ frame: 0,
+ pose: Pose::default(),
+ },
+ PoseKey {
+ frame: 10,
+ pose: Pose {
+ translation: [10.0, 20.0, 30.0],
+ rotation: [0.0, 1.0, 0.0, 0.0],
+ },
+ },
+ ],
+ )
+ .expect("track");
+
+ let pose = track
+ .sample(AnimationTime(5.0), NumericProfile::PortableReference)
+ .expect("pose");
+
+ assert_eq!(pose.translation, [5.0, 10.0, 15.0]);
+ assert!((pose.rotation[1] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_001);
+ assert!((pose.rotation[3] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_001);
+ }
+
+ #[test]
+ fn timed_pose_track_samples_float_key_times() {
+ let track = TimedPoseTrack::new(
+ Pose::default(),
+ vec![
+ TimedPoseKey {
+ time: AnimationTime(1.5),
+ pose: Pose::default(),
+ },
+ TimedPoseKey {
+ time: AnimationTime(3.5),
+ pose: Pose {
+ translation: [4.0, 8.0, 12.0],
+ rotation: [0.0, 0.0, 1.0, 0.0],
+ },
+ },
+ ],
+ )
+ .expect("track");
+
+ let pose = track.sample(AnimationTime(2.5)).expect("pose");
+
+ assert_eq!(pose.translation, [2.0, 4.0, 6.0]);
+ assert!((pose.rotation[2] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_001);
+ assert!((pose.rotation[3] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_001);
+ }
+
+ #[test]
+ fn quaternion_shortest_path_sign_flip_is_stable() {
+ let track = PoseTrack::new(
+ Pose::default(),
+ vec![
+ PoseKey {
+ frame: 0,
+ pose: Pose {
+ translation: [0.0; 3],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ PoseKey {
+ frame: 10,
+ pose: Pose {
+ translation: [0.0; 3],
+ rotation: [0.0, 0.0, 0.0, -1.0],
+ },
+ },
+ ],
+ )
+ .expect("track");
+
+ let pose = track
+ .sample(AnimationTime(5.0), NumericProfile::PortableReference)
+ .expect("pose");
+
+ assert_eq!(pose.rotation, [0.0, 0.0, 0.0, 1.0]);
+ }
+
+ #[test]
+ fn zero_or_degenerate_key_interval_is_rejected() {
+ let track = PoseTrack::new(
+ Pose::default(),
+ vec![
+ PoseKey {
+ frame: 1,
+ pose: Pose::default(),
+ },
+ PoseKey {
+ frame: 1,
+ pose: Pose::default(),
+ },
+ ],
+ );
+
+ assert_eq!(
+ track.expect_err("duplicate key"),
+ AnimationError::NonMonotonicKeys
+ );
+ }
+
+ #[test]
+ fn x87_boundary_golden_vectors_and_profile_difference_report() {
+ let track = PoseTrack::new(
+ Pose::default(),
+ vec![
+ PoseKey {
+ frame: 0,
+ pose: Pose::default(),
+ },
+ PoseKey {
+ frame: 2,
+ pose: Pose {
+ translation: [2.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ ],
+ )
+ .expect("track");
+
+ let portable = track
+ .sample(AnimationTime(1.0), NumericProfile::PortableReference)
+ .expect("portable");
+ let x87 = track
+ .sample(AnimationTime(1.0), NumericProfile::X87Compatibility)
+ .expect("x87");
+
+ assert_eq!(portable, x87);
+ assert_eq!(portable.translation, [1.0, 0.0, 0.0]);
+ assert_eq!(
+ compare_numeric_profiles(&track, &[AnimationTime(1.0)]).expect("diff"),
+ vec![NumericProfileDifference {
+ time: AnimationTime(1.0),
+ translation_delta: [0.0; 3],
+ rotation_delta: [0.0; 4],
+ }]
+ );
+ }
+
+ #[test]
+ fn blend_optional_pose_uses_valid_side() {
+ let valid = Pose {
+ translation: [3.0, 4.0, 5.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ };
+
+ assert_eq!(
+ blend_optional_pose(Some(valid), None, 0.5).expect("left"),
+ valid
+ );
+ assert_eq!(
+ blend_optional_pose(None, Some(valid), 0.5).expect("right"),
+ valid
+ );
+ assert_eq!(
+ blend_optional_pose(None, None, 0.5).expect_err("invalid"),
+ AnimationError::InvalidQuaternion
+ );
+ }
+
+ #[test]
+ fn hierarchy_evaluates_parent_before_child_and_rejects_cycles() {
+ let local = vec![
+ Pose {
+ translation: [1.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ Pose {
+ translation: [0.0, 2.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ ];
+
+ let buffer = evaluate_hierarchy(&[ParentIndex(None), ParentIndex(Some(0))], &local)
+ .expect("hierarchy");
+
+ assert_eq!(buffer.poses[0].translation, [1.0, 0.0, 0.0]);
+ assert_eq!(buffer.poses[1].translation, [1.0, 2.0, 0.0]);
+ assert_eq!(
+ evaluate_hierarchy(&[ParentIndex(Some(0))], &[Pose::default()]).expect_err("cycle"),
+ AnimationError::ParentCycle { node: 0 }
+ );
+ assert_eq!(
+ evaluate_hierarchy(
+ &[ParentIndex(Some(1)), ParentIndex(None)],
+ &[Pose::default(), Pose::default()],
+ )
+ .expect_err("order"),
+ AnimationError::ParentOrder {
+ child: 0,
+ parent: 1,
+ }
+ );
+ }
+
+ #[test]
+ fn generated_valid_quaternions_remain_finite() {
+ for index in 1..64_u16 {
+ let mut bytes = [0_u8; 24];
+ bytes[12..16].copy_from_slice(&f32::from(index).to_bits().to_le_bytes());
+ bytes[16..18].copy_from_slice(&(i16::try_from(index).expect("small")).to_le_bytes());
+ bytes[18..20].copy_from_slice(&123_i16.to_le_bytes());
+ bytes[20..22].copy_from_slice(&(-456_i16).to_le_bytes());
+ bytes[22..24].copy_from_slice(&32767_i16.to_le_bytes());
+
+ let key = AnimKey24::decode(&bytes).expect("key");
+
+ assert!(key.pose.rotation.iter().all(|value| value.is_finite()));
+ }
+ }
+
+ #[test]
+ fn named_rng_stream_is_stable_and_named() {
+ let mut material_a = NamedRngStream::new(42, "material");
+ let mut material_b = NamedRngStream::new(42, "material");
+ let mut fx = NamedRngStream::new(42, "fx");
+
+ assert_eq!(material_a.next_u32(), material_b.next_u32());
+ assert_ne!(material_a.next_u32(), fx.next_u32());
+ assert_eq!(material_a.calls(), 2);
+ }
+
+ #[test]
+ fn pose_capture_uses_float_bits() {
+ let track = PoseTrack::new(
+ Pose::default(),
+ vec![PoseKey {
+ frame: 0,
+ pose: Pose::default(),
+ }],
+ )
+ .expect("track");
+
+ let capture = canonical_pose_capture(&track, &[AnimationTime(0.0)]).expect("capture");
+
+ assert_eq!(
+ capture,
+ b"P,00000000,00000000,00000000,00000000,00000000,00000000,00000000,3f800000\n"
+ );
+ }
+
+ #[test]
+ fn timed_pose_capture_uses_float_bits() {
+ let track = TimedPoseTrack::new(
+ Pose::default(),
+ vec![TimedPoseKey {
+ time: AnimationTime(0.5),
+ pose: Pose::default(),
+ }],
+ )
+ .expect("track");
+
+ let capture = canonical_timed_pose_capture(&track, &[AnimationTime(0.5)]).expect("capture");
+
+ assert_eq!(
+ capture,
+ b"P,3f000000,00000000,00000000,00000000,00000000,00000000,00000000,3f800000\n"
+ );
+ }
+}
diff --git a/crates/fparkan-assets/Cargo.toml b/crates/fparkan-assets/Cargo.toml
new file mode 100644
index 0000000..4b901f3
--- /dev/null
+++ b/crates/fparkan-assets/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "fparkan-assets"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+fparkan-material = { path = "../fparkan-material" }
+fparkan-msh = { path = "../fparkan-msh" }
+fparkan-nres = { path = "../fparkan-nres" }
+fparkan-path = { path = "../fparkan-path" }
+fparkan-prototype = { path = "../fparkan-prototype" }
+fparkan-resource = { path = "../fparkan-resource" }
+fparkan-texm = { path = "../fparkan-texm" }
+
+[dev-dependencies]
+fparkan-vfs = { path = "../fparkan-vfs" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs
new file mode 100644
index 0000000..78ffb0b
--- /dev/null
+++ b/crates/fparkan-assets/src/lib.rs
@@ -0,0 +1,481 @@
+#![forbid(unsafe_code)]
+//! Asset manager ports and transactional preparation models.
+
+use fparkan_material::{decode_wear, resolve_material, WEAR_KIND};
+use fparkan_msh::{decode_msh, validate_msh};
+use fparkan_nres::{decode as decode_nres, ReadProfile};
+use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName};
+use fparkan_prototype::{EffectivePrototype, PrototypeGeometry, PrototypeGraph};
+use fparkan_resource::{ResourceKey, ResourceRepository};
+use fparkan_texm::decode_texm;
+use std::collections::BTreeSet;
+use std::fmt;
+use std::hash::{Hash, Hasher};
+use std::marker::PhantomData;
+use std::sync::Arc;
+
+const TEXTURES_ARCHIVE: &str = "textures.lib";
+const LIGHTMAP_ARCHIVE: &str = "lightmap.lib";
+
+/// Stable typed identifier for a prepared asset.
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
+pub struct AssetId<T> {
+ raw: u64,
+ marker: PhantomData<T>,
+}
+
+impl<T> AssetId<T> {
+ /// Creates an asset id from a stable raw value.
+ #[must_use]
+ pub const fn new(raw: u64) -> Self {
+ Self {
+ raw,
+ marker: PhantomData,
+ }
+ }
+
+ /// Returns the stable raw id.
+ #[must_use]
+ pub const fn raw(self) -> u64 {
+ self.raw
+ }
+}
+
+/// CPU-side data needed before a visual can be handed to a renderer.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PreparedVisual {
+ /// Stable id derived from the prototype geometry key.
+ pub id: AssetId<PreparedVisual>,
+ /// Optional mesh resource backing the visual.
+ pub mesh: Option<ResourceKey>,
+ /// Number of validated model nodes.
+ pub model_nodes: usize,
+ /// Number of validated material slots on the model.
+ pub model_slots: usize,
+ /// Number of validated render batches.
+ pub model_batches: usize,
+ /// Number of WEAR material slots resolved through MAT0.
+ pub material_count: usize,
+ /// Number of texture phase requests decoded as TEXM.
+ pub texture_count: usize,
+ /// Number of lightmap requests decoded as TEXM.
+ pub lightmap_count: usize,
+}
+
+/// A transactional mission asset preparation plan.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct MissionAssetPlan {
+ /// Number of visual prototypes in the plan.
+ pub visual_count: usize,
+ /// Number of mesh-backed visuals.
+ pub model_count: usize,
+ /// Number of material slot requests.
+ pub material_count: usize,
+ /// Number of texture phase requests.
+ pub texture_count: usize,
+ /// Number of lightmap requests.
+ pub lightmap_count: usize,
+}
+
+/// Coarse CPU-side asset budgets.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct AssetBudgets {
+ /// Bytes parsed from source resource payloads.
+ pub parsed_bytes: u64,
+}
+
+/// Errors raised while preparing CPU-side assets.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum AssetError {
+ /// A required cross-resource dependency was not found.
+ MissingDependency(String),
+ /// A prototype did not describe a usable visual.
+ InvalidPrototype(String),
+ /// A repository operation failed.
+ Resource(String),
+ /// MSH parsing or validation failed.
+ Msh(String),
+ /// WEAR/MAT0 parsing or resolution failed.
+ Material(String),
+ /// TEXM parsing failed.
+ Texture(String),
+}
+
+impl fmt::Display for AssetError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::MissingDependency(value) => write!(f, "missing dependency: {value}"),
+ Self::InvalidPrototype(value) => write!(f, "invalid prototype: {value}"),
+ Self::Resource(value) => write!(f, "resource error: {value}"),
+ Self::Msh(value) => write!(f, "msh error: {value}"),
+ Self::Material(value) => write!(f, "material error: {value}"),
+ Self::Texture(value) => write!(f, "texture error: {value}"),
+ }
+ }
+}
+
+impl std::error::Error for AssetError {}
+
+/// Port implemented by typed asset loaders.
+pub trait AssetLoader<T> {
+ /// Loads an asset for the given resource key.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AssetError`] when the resource cannot be resolved or decoded.
+ fn load(&self, key: &ResourceKey) -> Result<Arc<T>, AssetError>;
+}
+
+/// Minimal asset manager façade over an immutable resource repository.
+#[derive(Debug)]
+pub struct AssetManager<R> {
+ repository: R,
+}
+
+impl<R> AssetManager<R> {
+ /// Creates a manager backed by the given repository.
+ #[must_use]
+ pub const fn new(repository: R) -> Self {
+ Self { repository }
+ }
+
+ /// Returns the backing repository.
+ #[must_use]
+ pub const fn repository(&self) -> &R {
+ &self.repository
+ }
+}
+
+impl<R: ResourceRepository> AssetManager<R> {
+ /// Prepares one prototype visual using the manager repository.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AssetError`] if any model, material, texture, or lightmap
+ /// dependency is missing or malformed.
+ pub fn prepare_visual(&self, proto: &EffectivePrototype) -> Result<PreparedVisual, AssetError> {
+ prepare_visual_with_repository(&self.repository, proto)
+ }
+
+ /// Builds a mission plan by preparing each resolved prototype.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AssetError`] if any visual dependency is missing or malformed.
+ pub fn build_mission_asset_plan<'a>(
+ &self,
+ prototypes: impl IntoIterator<Item = &'a EffectivePrototype>,
+ ) -> Result<MissionAssetPlan, AssetError> {
+ build_mission_asset_plan_with_repository(&self.repository, prototypes)
+ }
+}
+
+/// Produces a count-only plan from a prototype graph.
+#[must_use]
+pub fn build_mission_asset_plan(graph: &PrototypeGraph) -> MissionAssetPlan {
+ MissionAssetPlan {
+ visual_count: graph.prototype_requests.len(),
+ ..MissionAssetPlan::default()
+ }
+}
+
+/// Builds a fully validated CPU-side mission asset plan.
+///
+/// # Errors
+///
+/// Returns [`AssetError`] if any reachable visual dependency is missing or
+/// malformed.
+pub fn build_mission_asset_plan_with_repository<'a, R: ResourceRepository>(
+ repository: &R,
+ prototypes: impl IntoIterator<Item = &'a EffectivePrototype>,
+) -> Result<MissionAssetPlan, AssetError> {
+ let mut plan = MissionAssetPlan::default();
+ let mut prepared_visuals = BTreeSet::new();
+
+ for proto in prototypes {
+ let visual_id = stable_visual_id(proto);
+ if !prepared_visuals.insert(visual_id) {
+ continue;
+ }
+ let visual = prepare_visual_with_repository(repository, proto)?;
+ plan.visual_count += 1;
+ if visual.mesh.is_some() {
+ plan.model_count += 1;
+ }
+ plan.material_count += visual.material_count;
+ plan.texture_count += visual.texture_count;
+ plan.lightmap_count += visual.lightmap_count;
+ }
+
+ Ok(plan)
+}
+
+/// Validates a prototype visual without resolving cross-resource dependencies.
+///
+/// This is useful for tests and API callers that only need a stable visual id.
+///
+/// # Errors
+///
+/// Returns [`AssetError`] when the prototype geometry is malformed.
+pub fn prepare_visual(proto: &EffectivePrototype) -> Result<PreparedVisual, AssetError> {
+ let id = stable_visual_id(proto);
+ let mesh = match &proto.geometry {
+ PrototypeGeometry::Mesh(key) => Some(key.clone()),
+ PrototypeGeometry::NonGeometric => None,
+ };
+
+ Ok(PreparedVisual {
+ id: AssetId::new(id),
+ mesh,
+ model_nodes: 0,
+ model_slots: 0,
+ model_batches: 0,
+ material_count: 0,
+ texture_count: 0,
+ lightmap_count: 0,
+ })
+}
+
+/// Prepares one visual and validates all CPU-side resource dependencies.
+///
+/// # Errors
+///
+/// Returns [`AssetError`] if the model, WEAR table, MAT0 materials, texture
+/// phases, or lightmaps cannot be resolved and decoded.
+pub fn prepare_visual_with_repository<R: ResourceRepository>(
+ repository: &R,
+ proto: &EffectivePrototype,
+) -> Result<PreparedVisual, AssetError> {
+ let PrototypeGeometry::Mesh(mesh_key) = &proto.geometry else {
+ return prepare_visual(proto);
+ };
+
+ let nres = decode_nres(
+ read_key(repository, mesh_key, Some("mesh"))?,
+ ReadProfile::Compatible,
+ )
+ .map_err(|err| AssetError::Msh(err.to_string()))?;
+ let msh_document = decode_msh(&nres).map_err(|err| AssetError::Msh(err.to_string()))?;
+ let model = validate_msh(&msh_document).map_err(|err| AssetError::Msh(err.to_string()))?;
+
+ let wear_name = sibling_name(mesh_key, "wea")?;
+ let wear_key = ResourceKey {
+ archive: mesh_key.archive.clone(),
+ name: wear_name,
+ type_id: Some(WEAR_KIND),
+ };
+ let wear = decode_wear(&read_key(repository, &wear_key, Some("wear"))?)
+ .map_err(|err| AssetError::Material(err.to_string()))?;
+
+ let mut material_count = 0;
+ let mut texture_count = 0;
+ let mut lightmap_count = 0;
+ for material_index in 0..wear.entries.len() {
+ let material_index = u16::try_from(material_index).map_err(|_| {
+ AssetError::Material("material index does not fit archive format".to_string())
+ })?;
+ let material = resolve_material(repository, &wear, material_index)
+ .map_err(|err| AssetError::Material(err.to_string()))?;
+ material_count += 1;
+
+ for texture in material.document.texture_requests() {
+ resolve_texm(repository, &texture, &[TEXTURES_ARCHIVE, LIGHTMAP_ARCHIVE])?;
+ texture_count += 1;
+ }
+ }
+
+ for lightmap in &wear.lightmaps {
+ resolve_texm(
+ repository,
+ &lightmap.lightmap,
+ &[LIGHTMAP_ARCHIVE, TEXTURES_ARCHIVE],
+ )?;
+ lightmap_count += 1;
+ }
+
+ Ok(PreparedVisual {
+ id: AssetId::new(stable_visual_id(proto)),
+ mesh: Some(mesh_key.clone()),
+ model_nodes: model.node_count,
+ model_slots: model.slots.len(),
+ model_batches: model.batches.len(),
+ material_count,
+ texture_count,
+ lightmap_count,
+ })
+}
+
+fn read_key<R: ResourceRepository>(
+ repository: &R,
+ key: &ResourceKey,
+ label: Option<&str>,
+) -> Result<Arc<[u8]>, AssetError> {
+ let handle = repository
+ .open_archive(&key.archive)
+ .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))
+ .and_then(|archive| {
+ repository
+ .find(archive, &key.name)
+ .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))
+ })?
+ .ok_or_else(|| AssetError::MissingDependency(format!("{label:?} {key:?}")))?;
+ let bytes = repository
+ .read(handle)
+ .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))?;
+ Ok(Arc::from(bytes.into_owned()))
+}
+
+fn resolve_texm<R: ResourceRepository>(
+ repository: &R,
+ name: &ResourceName,
+ archives: &[&str],
+) -> Result<(), AssetError> {
+ for archive in archives {
+ let key = ResourceKey {
+ archive: parse_path(archive)?,
+ name: name.clone(),
+ type_id: None,
+ };
+ match read_key(repository, &key, Some("texm")) {
+ Ok(bytes) => {
+ decode_texm(bytes).map_err(|err| AssetError::Texture(err.to_string()))?;
+ return Ok(());
+ }
+ Err(AssetError::MissingDependency(_) | AssetError::Resource(_)) => {}
+ Err(err) => return Err(err),
+ }
+ }
+
+ Err(AssetError::MissingDependency(format!("{name:?}")))
+}
+
+fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, AssetError> {
+ let dot = key
+ .name
+ .0
+ .iter()
+ .rposition(|byte| *byte == b'.')
+ .ok_or_else(|| {
+ AssetError::InvalidPrototype(format!("resource name has no extension: {:?}", key.name))
+ })?;
+ let mut name = key.name.0[..dot].to_vec();
+ name.push(b'.');
+ name.extend_from_slice(extension.as_bytes());
+ Ok(ResourceName(name))
+}
+
+fn stable_visual_id(proto: &EffectivePrototype) -> u64 {
+ let mut hasher = StableHasher::default();
+ match &proto.geometry {
+ PrototypeGeometry::Mesh(key) => {
+ 1_u8.hash(&mut hasher);
+ key.archive.as_str().hash(&mut hasher);
+ key.name.0.hash(&mut hasher);
+ key.type_id.hash(&mut hasher);
+ }
+ PrototypeGeometry::NonGeometric => {
+ 0_u8.hash(&mut hasher);
+ }
+ }
+ hasher.finish()
+}
+
+fn parse_path(value: &str) -> Result<NormalizedPath, AssetError> {
+ normalize_relative(value.as_bytes(), PathPolicy::HostCompatible)
+ .map_err(|err| AssetError::InvalidPrototype(format!("{err}")))
+}
+
+#[derive(Default)]
+struct StableHasher(u64);
+
+impl Hasher for StableHasher {
+ fn finish(&self) -> u64 {
+ self.0
+ }
+
+ fn write(&mut self, bytes: &[u8]) {
+ let mut value = if self.0 == 0 {
+ 0xcbf2_9ce4_8422_2325
+ } else {
+ self.0
+ };
+ for byte in bytes {
+ value ^= u64::from(*byte);
+ value = value.wrapping_mul(0x0000_0100_0000_01b3);
+ }
+ self.0 = value;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_prototype::build_prototype_graph;
+ use fparkan_resource::{resource_name, CachedResourceRepository};
+ use fparkan_vfs::{DirectoryVfs, Vfs};
+ use std::path::PathBuf;
+
+ #[test]
+ fn count_only_plan_uses_graph_requests() {
+ let graph = PrototypeGraph::default();
+
+ let plan = build_mission_asset_plan(&graph);
+
+ assert_eq!(plan.visual_count, 0);
+ assert_eq!(plan.model_count, 0);
+ }
+
+ #[test]
+ fn prepares_real_unit_asset_plan() {
+ let root = fixture_root("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
+ let repository = CachedResourceRepository::new(Arc::clone(&vfs));
+ let roots = [resource_name(b"UNITS/AUTO/swlklas.dat")];
+
+ let (graph, prototypes) =
+ build_prototype_graph(&repository, vfs.as_ref(), &roots).expect("prototype graph");
+ let count_only = build_mission_asset_plan(&graph);
+ let plan = build_mission_asset_plan_with_repository(&repository, &prototypes)
+ .expect("asset preparation");
+
+ assert_eq!(count_only.visual_count, 12);
+ assert_eq!(prototypes.len(), 12);
+ assert_eq!(plan.visual_count, 11);
+ assert_eq!(plan.model_count, 11);
+ assert_eq!(plan.material_count, 62);
+ assert_eq!(plan.texture_count, 77);
+ assert_eq!(plan.lightmap_count, 0);
+ }
+
+ #[test]
+ fn repository_plan_deduplicates_duplicate_visuals_but_graph_preserves_requests() {
+ let root = fixture_root("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
+ let repository = CachedResourceRepository::new(Arc::clone(&vfs));
+ let roots = [
+ resource_name(b"UNITS/AUTO/swlklas.dat"),
+ resource_name(b"UNITS/AUTO/swlklas.dat"),
+ ];
+
+ let (graph, prototypes) =
+ build_prototype_graph(&repository, vfs.as_ref(), &roots).expect("prototype graph");
+ let count_only = build_mission_asset_plan(&graph);
+ let plan = build_mission_asset_plan_with_repository(&repository, &prototypes)
+ .expect("asset preparation");
+
+ assert_eq!(graph.roots.len(), 2);
+ assert_eq!(count_only.visual_count, 24);
+ assert_eq!(prototypes.len(), 24);
+ assert_eq!(plan.visual_count, 11);
+ assert_eq!(plan.model_count, 11);
+ assert_eq!(plan.material_count, 62);
+ assert_eq!(plan.texture_count, 77);
+ }
+
+ fn fixture_root(part: &str) -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("testdata")
+ .join(part)
+ }
+}
diff --git a/crates/fparkan-binary/Cargo.toml b/crates/fparkan-binary/Cargo.toml
new file mode 100644
index 0000000..2bab5be
--- /dev/null
+++ b/crates/fparkan-binary/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "fparkan-binary"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-binary/src/lib.rs b/crates/fparkan-binary/src/lib.rs
new file mode 100644
index 0000000..ef5a0e4
--- /dev/null
+++ b/crates/fparkan-binary/src/lib.rs
@@ -0,0 +1,308 @@
+#![forbid(unsafe_code)]
+//! Bounded little-endian binary cursor and checked layout helpers.
+
+use std::fmt;
+
+/// Parser limits shared by binary formats.
+#[derive(Clone, Copy, Debug)]
+pub struct Limits {
+ /// Maximum file bytes.
+ pub max_file_bytes: u64,
+ /// Maximum entries.
+ pub max_entries: u32,
+ /// Maximum string bytes.
+ pub max_string_bytes: u32,
+ /// Maximum array items.
+ pub max_array_items: u32,
+ /// Maximum recursion depth.
+ pub max_recursion_depth: u16,
+ /// Maximum decoded bytes.
+ pub max_decoded_bytes: u64,
+}
+
+impl Default for Limits {
+ fn default() -> Self {
+ Self {
+ max_file_bytes: 256 * 1024 * 1024,
+ max_entries: 1_000_000,
+ max_string_bytes: 64 * 1024,
+ max_array_items: 1_000_000,
+ max_recursion_depth: 64,
+ max_decoded_bytes: 512 * 1024 * 1024,
+ }
+ }
+}
+
+/// Decode error.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum DecodeError {
+ /// Input ended before requested bytes.
+ UnexpectedEof {
+ /// Offset where read was attempted.
+ offset: u64,
+ /// Required byte count.
+ needed: u64,
+ /// Remaining byte count.
+ remaining: u64,
+ },
+ /// Arithmetic overflow.
+ IntegerOverflow,
+ /// Count exceeds limit.
+ LimitExceeded {
+ /// Declared count.
+ count: u64,
+ /// Configured limit.
+ limit: u64,
+ },
+ /// Cursor did not end at EOF.
+ TrailingBytes {
+ /// Offset where EOF was expected.
+ offset: u64,
+ /// Remaining byte count.
+ remaining: u64,
+ },
+ /// Invalid data.
+ Invalid(&'static str),
+}
+
+impl fmt::Display for DecodeError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::UnexpectedEof {
+ offset,
+ needed,
+ remaining,
+ } => write!(
+ f,
+ "unexpected EOF at {offset}: need {needed}, have {remaining}"
+ ),
+ Self::IntegerOverflow => write!(f, "integer overflow"),
+ Self::LimitExceeded { count, limit } => {
+ write!(f, "count {count} exceeds limit {limit}")
+ }
+ Self::TrailingBytes { offset, remaining } => {
+ write!(f, "trailing bytes at {offset}: {remaining}")
+ }
+ Self::Invalid(reason) => write!(f, "invalid data: {reason}"),
+ }
+ }
+}
+
+impl std::error::Error for DecodeError {}
+
+/// Cursor checkpoint.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct Checkpoint(pub u64);
+
+/// Bounded cursor.
+#[derive(Clone, Debug)]
+pub struct Cursor<'a> {
+ bytes: &'a [u8],
+ offset: usize,
+}
+
+impl<'a> Cursor<'a> {
+ /// Creates a cursor.
+ #[must_use]
+ pub fn new(bytes: &'a [u8]) -> Self {
+ Self { bytes, offset: 0 }
+ }
+
+ /// Current offset.
+ #[must_use]
+ pub fn offset(&self) -> u64 {
+ self.offset as u64
+ }
+
+ /// Remaining bytes.
+ #[must_use]
+ pub fn remaining(&self) -> usize {
+ self.bytes.len().saturating_sub(self.offset)
+ }
+
+ /// Creates a checkpoint.
+ #[must_use]
+ pub fn checkpoint(&self) -> Checkpoint {
+ Checkpoint(self.offset())
+ }
+
+ /// Reads exact bytes.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`DecodeError::IntegerOverflow`] if the requested end offset
+ /// overflows, or [`DecodeError::UnexpectedEof`] if there are not enough
+ /// bytes remaining.
+ pub fn read_exact(&mut self, len: usize) -> Result<&'a [u8], DecodeError> {
+ let end = self
+ .offset
+ .checked_add(len)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ if end > self.bytes.len() {
+ return Err(DecodeError::UnexpectedEof {
+ offset: self.offset(),
+ needed: len as u64,
+ remaining: self.remaining() as u64,
+ });
+ }
+ let out = &self.bytes[self.offset..end];
+ self.offset = end;
+ Ok(out)
+ }
+
+ /// Reads a little-endian u16.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`DecodeError`] if two bytes cannot be read.
+ pub fn read_u16_le(&mut self) -> Result<u16, DecodeError> {
+ let b = self.read_exact(2)?;
+ Ok(u16::from_le_bytes([b[0], b[1]]))
+ }
+
+ /// Reads a little-endian u32.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`DecodeError`] if four bytes cannot be read.
+ pub fn read_u32_le(&mut self) -> Result<u32, DecodeError> {
+ let b = self.read_exact(4)?;
+ Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
+ }
+
+ /// Reads a little-endian i32.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`DecodeError`] if four bytes cannot be read.
+ pub fn read_i32_le(&mut self) -> Result<i32, DecodeError> {
+ let b = self.read_exact(4)?;
+ Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]]))
+ }
+
+ /// Reads a little-endian f32.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`DecodeError`] if four bytes cannot be read.
+ pub fn read_f32_le(&mut self) -> Result<f32, DecodeError> {
+ Ok(f32::from_bits(self.read_u32_le()?))
+ }
+
+ /// Requires exact EOF.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`DecodeError::TrailingBytes`] when unread bytes remain.
+ pub fn require_eof(&self) -> Result<(), DecodeError> {
+ if self.remaining() == 0 {
+ Ok(())
+ } else {
+ Err(DecodeError::TrailingBytes {
+ offset: self.offset(),
+ remaining: self.remaining() as u64,
+ })
+ }
+ }
+}
+
+/// Validates `count * stride <= remaining` and returns bytes as usize.
+///
+/// # Errors
+///
+/// Returns [`DecodeError::IntegerOverflow`] on arithmetic or conversion
+/// overflow, or [`DecodeError::UnexpectedEof`] when the declared byte count is
+/// larger than the remaining bounded input.
+pub fn checked_count_bytes(count: u64, stride: u64, remaining: u64) -> Result<usize, DecodeError> {
+ let bytes = count
+ .checked_mul(stride)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ if bytes > remaining {
+ return Err(DecodeError::UnexpectedEof {
+ offset: 0,
+ needed: bytes,
+ remaining,
+ });
+ }
+ usize::try_from(bytes).map_err(|_| DecodeError::IntegerOverflow)
+}
+
+/// Validates a declared allocation size before constructing the allocation.
+///
+/// # Errors
+///
+/// Returns [`DecodeError::LimitExceeded`] when `declared` is larger than
+/// `limit`, or [`DecodeError::IntegerOverflow`] when the accepted size cannot
+/// be represented by the host `usize`.
+pub fn checked_allocation_len(declared: u64, limit: u64) -> Result<usize, DecodeError> {
+ if declared > limit {
+ return Err(DecodeError::LimitExceeded {
+ count: declared,
+ limit,
+ });
+ }
+ usize::try_from(declared).map_err(|_| DecodeError::IntegerOverflow)
+}
+
+/// Reads length-prefixed bytes.
+///
+/// # Errors
+///
+/// Returns [`DecodeError`] if the length cannot be read, exceeds `max`, or the
+/// declared payload is truncated.
+pub fn read_lp_bytes(cursor: &mut Cursor<'_>, max: u32) -> Result<Vec<u8>, DecodeError> {
+ let len = cursor.read_u32_le()?;
+ if len > max {
+ return Err(DecodeError::LimitExceeded {
+ count: u64::from(len),
+ limit: u64::from(max),
+ });
+ }
+ let len = checked_allocation_len(u64::from(len), u64::from(max))?;
+ Ok(cursor.read_exact(len)?.to_vec())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn rejects_count_stride_overflow() {
+ assert_eq!(
+ checked_count_bytes(u64::MAX, 2, u64::MAX),
+ Err(DecodeError::IntegerOverflow)
+ );
+ }
+
+ #[test]
+ fn exact_eof_reports_trailing() {
+ let mut cursor = Cursor::new(&[1, 2]);
+ assert_eq!(cursor.read_exact(1).expect("byte"), &[1]);
+ assert!(matches!(
+ cursor.require_eof(),
+ Err(DecodeError::TrailingBytes { .. })
+ ));
+ }
+
+ #[test]
+ fn rejects_oversized_declared_allocation_before_read() {
+ assert_eq!(
+ checked_allocation_len(1025, 1024),
+ Err(DecodeError::LimitExceeded {
+ count: 1025,
+ limit: 1024
+ })
+ );
+
+ let bytes = 2048u32.to_le_bytes();
+ let mut cursor = Cursor::new(&bytes);
+ assert_eq!(
+ read_lp_bytes(&mut cursor, 1024),
+ Err(DecodeError::LimitExceeded {
+ count: 2048,
+ limit: 1024
+ })
+ );
+ assert_eq!(cursor.offset(), 4);
+ }
+}
diff --git a/crates/fparkan-corpus/Cargo.toml b/crates/fparkan-corpus/Cargo.toml
new file mode 100644
index 0000000..29fb436
--- /dev/null
+++ b/crates/fparkan-corpus/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "fparkan-corpus"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+fparkan-path = { path = "../fparkan-path" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-corpus/src/lib.rs b/crates/fparkan-corpus/src/lib.rs
new file mode 100644
index 0000000..ba26c73
--- /dev/null
+++ b/crates/fparkan-corpus/src/lib.rs
@@ -0,0 +1,695 @@
+#![forbid(unsafe_code)]
+//! Licensed corpus discovery and aggregate reports.
+
+use fparkan_path::{ascii_lookup_key, normalize_relative, PathPolicy};
+use std::collections::{BTreeMap, BTreeSet};
+use std::fmt;
+use std::fs;
+use std::io::Write;
+use std::path::{Path, PathBuf};
+
+/// Corpus kind.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum CorpusKind {
+ /// Demo corpus.
+ Demo,
+ /// Part 1 full game.
+ Part1,
+ /// Part 2 full game.
+ Part2,
+ /// Unknown local directory.
+ Unknown,
+}
+
+/// Corpus root.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct CorpusRoot(pub PathBuf);
+
+/// Discovery options.
+#[derive(Clone, Copy, Debug, Default)]
+pub struct DiscoverOptions {
+ /// Whether symlinks may be traversed.
+ pub follow_symlinks: bool,
+}
+
+/// File manifest entry.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ManifestEntry {
+ /// Normalized relative path.
+ pub path: String,
+ /// File size in bytes.
+ pub size: u64,
+ /// Stable content fingerprint.
+ pub hash: u64,
+}
+
+/// Corpus manifest.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct CorpusManifest {
+ /// Kind.
+ pub kind: CorpusKind,
+ /// Sorted files.
+ pub files: Vec<ManifestEntry>,
+ /// Casefold collisions.
+ pub casefold_collisions: Vec<Vec<String>>,
+}
+
+/// Aggregate report.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct CorpusReport {
+ /// Schema version.
+ pub schema: u32,
+ /// Kind.
+ pub kind: CorpusKind,
+ /// Total files.
+ pub files: usize,
+ /// Total bytes.
+ pub bytes: u64,
+ /// Metrics.
+ pub metrics: BTreeMap<String, u64>,
+ /// Casefold collision count.
+ pub casefold_collisions: usize,
+ /// Manifest fingerprint.
+ pub fingerprint: u64,
+}
+
+/// Corpus error.
+#[derive(Debug)]
+pub enum CorpusError {
+ /// I/O failure.
+ Io {
+ /// Path where I/O failed.
+ path: PathBuf,
+ /// Source error.
+ source: std::io::Error,
+ },
+ /// Invalid root.
+ InvalidRoot(PathBuf),
+ /// Invalid path.
+ InvalidPath(String),
+}
+
+impl fmt::Display for CorpusError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
+ Self::InvalidRoot(path) => write!(f, "invalid corpus root: {}", path.display()),
+ Self::InvalidPath(path) => write!(f, "invalid corpus path: {path}"),
+ }
+ }
+}
+
+impl std::error::Error for CorpusError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Io { source, .. } => Some(source),
+ Self::InvalidRoot(_) | Self::InvalidPath(_) => None,
+ }
+ }
+}
+
+/// Discovers a corpus under a root directory.
+///
+/// # Errors
+///
+/// Returns [`CorpusError`] if the root is invalid, traversal encounters an I/O
+/// error, or a discovered path cannot be represented by the legacy path policy.
+pub fn discover(root: &Path, options: DiscoverOptions) -> Result<CorpusManifest, CorpusError> {
+ if !root.is_dir() {
+ return Err(CorpusError::InvalidRoot(root.to_path_buf()));
+ }
+ let mut files = Vec::new();
+ walk(root, root, options, &mut files)?;
+ files.sort_by(|a, b| a.path.cmp(&b.path));
+
+ let kind = classify(root, &files);
+ let casefold_collisions = detect_casefold_collisions(&files);
+ Ok(CorpusManifest {
+ kind,
+ files,
+ casefold_collisions,
+ })
+}
+
+fn walk(
+ root: &Path,
+ dir: &Path,
+ options: DiscoverOptions,
+ out: &mut Vec<ManifestEntry>,
+) -> Result<(), CorpusError> {
+ let read_dir = fs::read_dir(dir).map_err(|source| CorpusError::Io {
+ path: dir.to_path_buf(),
+ source,
+ })?;
+ let mut entries = Vec::new();
+ for entry in read_dir {
+ let entry = entry.map_err(|source| CorpusError::Io {
+ path: dir.to_path_buf(),
+ source,
+ })?;
+ entries.push(entry.path());
+ }
+ entries.sort();
+ for path in entries {
+ if path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.starts_with('.'))
+ {
+ continue;
+ }
+ let metadata = fs::symlink_metadata(&path).map_err(|source| CorpusError::Io {
+ path: path.clone(),
+ source,
+ })?;
+ if metadata.file_type().is_symlink() && !options.follow_symlinks {
+ continue;
+ }
+ if metadata.is_dir() {
+ walk(root, &path, options, out)?;
+ continue;
+ }
+ if !metadata.is_file() {
+ continue;
+ }
+ let rel = path
+ .strip_prefix(root)
+ .map_err(|_| CorpusError::InvalidPath(path.display().to_string()))?;
+ let rel_text = rel
+ .to_str()
+ .ok_or_else(|| CorpusError::InvalidPath(path.display().to_string()))?;
+ let normalized = normalize_relative(rel_text.as_bytes(), PathPolicy::HostCompatible)
+ .map_err(|_| CorpusError::InvalidPath(rel_text.to_string()))?;
+ let bytes = fs::read(&path).map_err(|source| CorpusError::Io {
+ path: path.clone(),
+ source,
+ })?;
+ out.push(ManifestEntry {
+ path: normalized.as_str().to_string(),
+ size: metadata.len(),
+ hash: stable_hash(&bytes),
+ });
+ }
+ Ok(())
+}
+
+fn classify(root: &Path, files: &[ManifestEntry]) -> CorpusKind {
+ let name = root
+ .file_name()
+ .and_then(|v| v.to_str())
+ .unwrap_or_default()
+ .to_ascii_uppercase();
+ if name == "IS" {
+ CorpusKind::Part1
+ } else if name == "IS2" {
+ CorpusKind::Part2
+ } else if files
+ .iter()
+ .any(|f| f.path.eq_ignore_ascii_case("iron_3d.exe"))
+ {
+ CorpusKind::Part1
+ } else {
+ CorpusKind::Unknown
+ }
+}
+
+fn detect_casefold_collisions(files: &[ManifestEntry]) -> Vec<Vec<String>> {
+ let mut grouped: BTreeMap<Vec<u8>, BTreeSet<String>> = BTreeMap::new();
+ for file in files {
+ grouped
+ .entry(ascii_lookup_key(file.path.as_bytes()).0)
+ .or_default()
+ .insert(file.path.clone());
+ }
+ grouped
+ .into_values()
+ .filter(|paths| paths.len() > 1)
+ .map(|paths| paths.into_iter().collect())
+ .collect()
+}
+
+/// Builds aggregate report.
+#[must_use]
+pub fn report(root: &Path, manifest: &CorpusManifest) -> CorpusReport {
+ let mut metrics = BTreeMap::new();
+ metrics.insert("nres_files".to_string(), 0);
+ metrics.insert("nres_entries".to_string(), 0);
+ metrics.insert("rsli_files".to_string(), 0);
+ metrics.insert("tma_files".to_string(), 0);
+ metrics.insert("land_msh_files".to_string(), 0);
+ metrics.insert("land_map_files".to_string(), 0);
+ metrics.insert("unit_dat_files".to_string(), 0);
+ metrics.insert("msh_entries".to_string(), 0);
+ metrics.insert("mat0_entries".to_string(), 0);
+ metrics.insert("texm_entries".to_string(), 0);
+ metrics.insert("fxid_entries".to_string(), 0);
+ metrics.insert("wear_entries".to_string(), 0);
+
+ for entry in &manifest.files {
+ let lower = entry.path.to_ascii_lowercase();
+ if lower.ends_with("data.tma") {
+ bump(&mut metrics, "tma_files", 1);
+ }
+ if lower.ends_with("land.msh") {
+ bump(&mut metrics, "land_msh_files", 1);
+ }
+ if lower.ends_with("land.map") {
+ bump(&mut metrics, "land_map_files", 1);
+ }
+ if has_extension(&lower, "dat")
+ && (lower.starts_with("units/") || lower.contains("/units/"))
+ {
+ bump(&mut metrics, "unit_dat_files", 1);
+ }
+
+ let path = root.join(&entry.path);
+ if let Ok(bytes) = fs::read(path) {
+ if bytes.starts_with(b"NRes") {
+ bump(&mut metrics, "nres_files", 1);
+ if let Some(entries) = inspect_nres_entries(&bytes) {
+ bump(&mut metrics, "nres_entries", entries.len() as u64);
+ for entry in entries {
+ let name = entry.name.to_ascii_lowercase();
+ if has_extension(&name, "msh") {
+ bump(&mut metrics, "msh_entries", 1);
+ }
+ match entry.kind {
+ 0x3054_414D => {
+ bump(&mut metrics, "mat0_entries", 1);
+ }
+ 0x6D78_6554 => {
+ bump(&mut metrics, "texm_entries", 1);
+ }
+ 0x4449_5846 => {
+ bump(&mut metrics, "fxid_entries", 1);
+ }
+ 0x5241_4557 => {
+ bump(&mut metrics, "wear_entries", 1);
+ }
+ _ => {}
+ }
+ }
+ }
+ } else if bytes.starts_with(b"NL") {
+ bump(&mut metrics, "rsli_files", 1);
+ }
+ }
+ }
+
+ CorpusReport {
+ schema: 1,
+ kind: manifest.kind,
+ files: manifest.files.len(),
+ bytes: manifest.files.iter().map(|f| f.size).sum(),
+ metrics,
+ casefold_collisions: manifest.casefold_collisions.len(),
+ fingerprint: fingerprint(manifest),
+ }
+}
+
+fn bump(metrics: &mut BTreeMap<String, u64>, key: &str, delta: u64) {
+ if let Some(value) = metrics.get_mut(key) {
+ *value = value.saturating_add(delta);
+ }
+}
+
+fn has_extension(path: &str, expected: &str) -> bool {
+ Path::new(path)
+ .extension()
+ .is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
+}
+
+#[derive(Clone, Debug)]
+struct NresEntryBrief {
+ kind: u32,
+ name: String,
+}
+
+fn inspect_nres_entries(bytes: &[u8]) -> Option<Vec<NresEntryBrief>> {
+ if bytes.len() < 16 || !bytes.starts_with(b"NRes") {
+ return None;
+ }
+ let count = i32::from_le_bytes(bytes.get(8..12)?.try_into().ok()?);
+ if count < 0 {
+ return None;
+ }
+ let count = usize::try_from(count).ok()?;
+ let directory_len = count.checked_mul(64)?;
+ let directory_offset = bytes.len().checked_sub(directory_len)?;
+ let mut names = Vec::with_capacity(count);
+ for index in 0..count {
+ let base = directory_offset.checked_add(index.checked_mul(64)?)?;
+ let kind = u32::from_le_bytes(bytes.get(base..base + 4)?.try_into().ok()?);
+ let raw = bytes.get(base + 20..base + 56)?;
+ let len = raw.iter().position(|b| *b == 0).unwrap_or(raw.len());
+ names.push(NresEntryBrief {
+ kind,
+ name: String::from_utf8_lossy(&raw[..len]).to_string(),
+ });
+ }
+ Some(names)
+}
+
+/// Computes stable manifest fingerprint.
+#[must_use]
+pub fn fingerprint(manifest: &CorpusManifest) -> u64 {
+ let mut state = 0xcbf2_9ce4_8422_2325;
+ for file in &manifest.files {
+ hash_into(&mut state, file.path.as_bytes());
+ hash_into(&mut state, &file.size.to_le_bytes());
+ hash_into(&mut state, &file.hash.to_le_bytes());
+ }
+ state
+}
+
+fn stable_hash(bytes: &[u8]) -> u64 {
+ let mut state = 0xcbf2_9ce4_8422_2325;
+ hash_into(&mut state, bytes);
+ state
+}
+
+fn hash_into(state: &mut u64, bytes: &[u8]) {
+ for byte in bytes {
+ *state ^= u64::from(*byte);
+ *state = state.wrapping_mul(0x0000_0100_0000_01b3);
+ }
+}
+
+/// Writes report atomically.
+///
+/// # Errors
+///
+/// Returns [`CorpusError`] if the parent directory, temporary file, write, or
+/// final rename operation fails.
+pub fn write_report_atomic(path: &Path, report: &CorpusReport) -> Result<(), CorpusError> {
+ let tmp = path.with_extension("tmp");
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent).map_err(|source| CorpusError::Io {
+ path: parent.to_path_buf(),
+ source,
+ })?;
+ }
+ let mut file = fs::File::create(&tmp).map_err(|source| CorpusError::Io {
+ path: tmp.clone(),
+ source,
+ })?;
+ file.write_all(render_report_json(report).as_bytes())
+ .map_err(|source| CorpusError::Io {
+ path: tmp.clone(),
+ source,
+ })?;
+ file.sync_all().map_err(|source| CorpusError::Io {
+ path: tmp.clone(),
+ source,
+ })?;
+ fs::rename(&tmp, path).map_err(|source| CorpusError::Io {
+ path: path.to_path_buf(),
+ source,
+ })?;
+ Ok(())
+}
+
+/// Renders report JSON.
+#[must_use]
+pub fn render_report_json(report: &CorpusReport) -> String {
+ let mut out = format!(
+ "{{\"schema_version\":\"fparkan-corpus-report-v1\",\"schema\":{},\"kind\":\"{:?}\",\"files\":{},\"bytes\":{},\"casefold_collisions\":{},\"fingerprint\":\"{:016x}\",\"metrics\":{{",
+ report.schema,
+ report.kind,
+ report.files,
+ report.bytes,
+ report.casefold_collisions,
+ report.fingerprint
+ );
+ for (idx, (key, value)) in report.metrics.iter().enumerate() {
+ if idx > 0 {
+ out.push(',');
+ }
+ out.push('"');
+ out.push_str(key);
+ out.push_str("\":");
+ out.push_str(&value.to_string());
+ }
+ out.push_str("}}");
+ out.push('}');
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_path::join_under;
+ use std::time::{SystemTime, UNIX_EPOCH};
+
+ #[test]
+ fn report_for_testdata_roots() {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("testdata")
+ .join("IS");
+ if !root.is_dir() {
+ return;
+ }
+ let manifest = discover(&root, DiscoverOptions::default()).expect("manifest");
+ let report = report(&root, &manifest);
+ assert!(report.files > 0);
+ assert!(report.metrics["nres_files"] > 0);
+ }
+
+ #[test]
+ fn licensed_part1_manifest_profile_and_counts_match_baseline() {
+ let root = testdata_root("IS");
+ let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest");
+ let report = report(&root, &manifest);
+
+ assert_eq!(manifest.kind, CorpusKind::Part1);
+ assert_eq!(report.files, 1_017);
+ assert_eq!(report.metrics["nres_files"], 120);
+ assert_eq!(report.metrics["rsli_files"], 2);
+ assert_eq!(report.metrics["tma_files"], 29);
+ assert_eq!(report.metrics["land_msh_files"], 33);
+ assert_eq!(report.metrics["land_map_files"], 33);
+ assert_eq!(report.metrics["unit_dat_files"], 425);
+ }
+
+ #[test]
+ fn licensed_part2_manifest_profile_and_counts_match_baseline() {
+ let root = testdata_root("IS2");
+ let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest");
+ let report = report(&root, &manifest);
+
+ assert_eq!(manifest.kind, CorpusKind::Part2);
+ assert_eq!(report.files, 1_302);
+ assert_eq!(report.metrics["nres_files"], 134);
+ assert_eq!(report.metrics["rsli_files"], 2);
+ assert_eq!(report.metrics["tma_files"], 31);
+ assert_eq!(report.metrics["land_msh_files"], 32);
+ assert_eq!(report.metrics["land_map_files"], 32);
+ assert_eq!(report.metrics["unit_dat_files"], 676);
+ }
+
+ #[test]
+ fn licensed_part1_has_no_casefold_relative_path_collisions() {
+ let root = testdata_root("IS");
+ let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest");
+
+ assert!(manifest.casefold_collisions.is_empty());
+ }
+
+ #[test]
+ fn licensed_part2_has_no_casefold_relative_path_collisions() {
+ let root = testdata_root("IS2");
+ let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest");
+
+ assert!(manifest.casefold_collisions.is_empty());
+ }
+
+ #[test]
+ fn licensed_part1_paths_stay_under_root() {
+ assert_discovered_paths_stay_under_root("IS");
+ }
+
+ #[test]
+ fn licensed_part2_paths_stay_under_root() {
+ assert_discovered_paths_stay_under_root("IS2");
+ }
+
+ #[test]
+ fn report_json_contains_metrics_and_hashes_not_paths_or_payloads() {
+ let manifest = CorpusManifest {
+ kind: CorpusKind::Part1,
+ files: vec![ManifestEntry {
+ path: "secret/payload.bin".to_string(),
+ size: 4,
+ hash: stable_hash(b"DATA"),
+ }],
+ casefold_collisions: Vec::new(),
+ };
+ let report = report(Path::new("."), &manifest);
+ let json = render_report_json(&report);
+
+ assert!(json.contains("\"schema_version\":\"fparkan-corpus-report-v1\""));
+ assert!(json.contains("\"fingerprint\":"));
+ assert!(json.contains("\"metrics\":"));
+ assert!(!json.contains("secret/payload.bin"));
+ assert!(!json.contains("DATA"));
+ }
+
+ #[test]
+ fn deterministic_traversal_is_creation_order_independent() {
+ let first = temp_dir("order-first");
+ let second = temp_dir("order-second");
+ fs::create_dir_all(first.join("nested")).expect("first nested");
+ fs::create_dir_all(second.join("nested")).expect("second nested");
+
+ fs::write(first.join("b.bin"), b"b").expect("first b");
+ fs::write(first.join("nested").join("a.bin"), b"a").expect("first a");
+ fs::write(second.join("nested").join("a.bin"), b"a").expect("second a");
+ fs::write(second.join("b.bin"), b"b").expect("second b");
+
+ let first_manifest = discover(&first, DiscoverOptions::default()).expect("first manifest");
+ let second_manifest =
+ discover(&second, DiscoverOptions::default()).expect("second manifest");
+
+ assert_eq!(first_manifest.files, second_manifest.files);
+ let _ = fs::remove_dir_all(first);
+ let _ = fs::remove_dir_all(second);
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn unreadable_directory_produces_error() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let root = temp_dir("unreadable");
+ let child = root.join("locked");
+ fs::create_dir_all(&child).expect("locked dir");
+ fs::set_permissions(&child, fs::Permissions::from_mode(0o000)).expect("lock dir");
+
+ let result = discover(&root, DiscoverOptions::default());
+
+ fs::set_permissions(&child, fs::Permissions::from_mode(0o700)).expect("unlock dir");
+ let _ = fs::remove_dir_all(root);
+ assert!(matches!(result, Err(CorpusError::Io { path, .. }) if path.ends_with("locked")));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn symlink_loop_is_not_traversed_by_default() {
+ use std::os::unix::fs::symlink;
+
+ let root = temp_dir("symlink-loop");
+ fs::write(root.join("real.bin"), b"real").expect("real file");
+ symlink(&root, root.join("loop")).expect("loop symlink");
+
+ let manifest = discover(&root, DiscoverOptions::default()).expect("manifest");
+
+ assert_eq!(manifest.files.len(), 1);
+ assert_eq!(manifest.files[0].path, "real.bin");
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn casefold_collisions_are_registered() {
+ let manifest = CorpusManifest {
+ kind: CorpusKind::Unknown,
+ files: vec![
+ ManifestEntry {
+ path: "Textures/Foo.TEX".to_string(),
+ size: 1,
+ hash: 1,
+ },
+ ManifestEntry {
+ path: "textures/foo.tex".to_string(),
+ size: 1,
+ hash: 2,
+ },
+ ],
+ casefold_collisions: Vec::new(),
+ };
+
+ let collisions = detect_casefold_collisions(&manifest.files);
+
+ assert_eq!(
+ collisions,
+ vec![vec![
+ "Textures/Foo.TEX".to_string(),
+ "textures/foo.tex".to_string()
+ ]]
+ );
+ }
+
+ #[test]
+ fn fingerprint_changes() {
+ let mut manifest = CorpusManifest {
+ kind: CorpusKind::Unknown,
+ files: vec![ManifestEntry {
+ path: "a".to_string(),
+ size: 1,
+ hash: 1,
+ }],
+ casefold_collisions: Vec::new(),
+ };
+ let a = fingerprint(&manifest);
+ manifest.files[0].hash = 2;
+ assert_ne!(a, fingerprint(&manifest));
+ }
+
+ #[test]
+ fn atomic_report_write() {
+ let tmp = std::env::temp_dir().join(format!(
+ "fparkan-report-{}.json",
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("clock")
+ .as_nanos()
+ ));
+ let report = CorpusReport {
+ schema: 1,
+ kind: CorpusKind::Unknown,
+ files: 0,
+ bytes: 0,
+ metrics: BTreeMap::new(),
+ casefold_collisions: 0,
+ fingerprint: 0,
+ };
+ write_report_atomic(&tmp, &report).expect("write");
+ assert!(tmp.is_file());
+ let _ = fs::remove_file(tmp);
+ }
+
+ fn temp_dir(name: &str) -> PathBuf {
+ let path = std::env::temp_dir().join(format!(
+ "fparkan-corpus-{name}-{}",
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("clock")
+ .as_nanos()
+ ));
+ fs::create_dir_all(&path).expect("temp dir");
+ path
+ }
+
+ fn testdata_root(part: &str) -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("testdata")
+ .join(part)
+ }
+
+ fn assert_discovered_paths_stay_under_root(part: &str) {
+ let root = testdata_root(part);
+ let manifest = discover(&root, DiscoverOptions::default()).expect("licensed manifest");
+
+ for entry in &manifest.files {
+ let normalized = normalize_relative(entry.path.as_bytes(), PathPolicy::HostCompatible)
+ .expect("discovered path should re-normalize");
+ let joined = join_under(&root, &normalized).expect("discovered path should join");
+ assert!(
+ joined.starts_with(&root),
+ "discovered path escaped root: {}",
+ entry.path
+ );
+ }
+ }
+}
diff --git a/crates/fparkan-diagnostics/Cargo.toml b/crates/fparkan-diagnostics/Cargo.toml
new file mode 100644
index 0000000..8e7b1bd
--- /dev/null
+++ b/crates/fparkan-diagnostics/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "fparkan-diagnostics"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-diagnostics/src/lib.rs b/crates/fparkan-diagnostics/src/lib.rs
new file mode 100644
index 0000000..8b3e160
--- /dev/null
+++ b/crates/fparkan-diagnostics/src/lib.rs
@@ -0,0 +1,301 @@
+#![forbid(unsafe_code)]
+//! Structured diagnostics shared by `FParkan` crates.
+
+/// Diagnostic severity.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum Severity {
+ /// Informational note.
+ Info,
+ /// Recoverable warning.
+ Warning,
+ /// Error for the current operation.
+ Error,
+ /// Fatal error for the current run.
+ Fatal,
+}
+
+/// Evidence level for a contract or interpretation.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum EvidenceStatus {
+ /// Described by project documentation.
+ Documented,
+ /// Verified by synthetic fixtures.
+ SyntheticVerified,
+ /// Verified against the licensed corpus.
+ CorpusVerified,
+ /// Verified by runtime capture.
+ RuntimeCaptured,
+ /// Working hypothesis; not a runtime contract.
+ Hypothesis,
+}
+
+/// Operation phase where a diagnostic was produced.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum Phase {
+ /// Discovery.
+ Discover,
+ /// Read.
+ Read,
+ /// Parse.
+ Parse,
+ /// Validate.
+ Validate,
+ /// Resolve.
+ Resolve,
+ /// Prepare.
+ Prepare,
+ /// Construct.
+ Construct,
+ /// Register.
+ Register,
+ /// Simulate.
+ Simulate,
+ /// Render.
+ Render,
+}
+
+/// Byte span in an input source.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct SourceSpan {
+ /// Start offset.
+ pub offset: u64,
+ /// Length in bytes.
+ pub length: u64,
+}
+
+/// Stable diagnostic code.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub struct DiagnosticCode(pub &'static str);
+
+/// Context attached to a diagnostic.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct DiagnosticContext {
+ /// Phase.
+ pub phase: Option<Phase>,
+ /// Redacted or logical path.
+ pub path: Option<String>,
+ /// Archive entry name.
+ pub archive_entry: Option<String>,
+ /// Object/prototype key.
+ pub object_key: Option<String>,
+ /// Input span.
+ pub span: Option<SourceSpan>,
+}
+
+/// Structured diagnostic with cause chain.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Diagnostic {
+ /// Stable code.
+ pub code: DiagnosticCode,
+ /// Severity.
+ pub severity: Severity,
+ /// Human message.
+ pub message: String,
+ /// Context.
+ pub context: DiagnosticContext,
+ /// Causes.
+ pub causes: Vec<Diagnostic>,
+}
+
+/// Creates a diagnostic with default error severity.
+#[must_use]
+pub fn diagnostic(code: DiagnosticCode, message: impl Into<String>) -> Diagnostic {
+ Diagnostic {
+ code,
+ severity: Severity::Error,
+ message: message.into(),
+ context: DiagnosticContext::default(),
+ causes: Vec::new(),
+ }
+}
+
+impl Diagnostic {
+ /// Returns a copy with severity changed.
+ #[must_use]
+ pub fn with_severity(mut self, severity: Severity) -> Self {
+ self.severity = severity;
+ self
+ }
+
+ /// Returns a copy with context changed.
+ #[must_use]
+ pub fn with_context(mut self, context: DiagnosticContext) -> Self {
+ self.context = context;
+ self
+ }
+
+ /// Adds a cause.
+ pub fn push_cause(&mut self, cause: Diagnostic) {
+ self.causes.push(cause);
+ }
+}
+
+/// Renders a compact human-readable diagnostic.
+#[must_use]
+pub fn render_human(diagnostic: &Diagnostic) -> String {
+ let mut out = format!(
+ "{:?} {}: {}",
+ diagnostic.severity, diagnostic.code.0, diagnostic.message
+ );
+ if let Some(path) = &diagnostic.context.path {
+ out.push_str(" [");
+ out.push_str(path);
+ out.push(']');
+ }
+ out
+}
+
+/// Renders deterministic JSON without requiring a serialization dependency.
+#[must_use]
+pub fn render_json(diagnostic: &Diagnostic) -> String {
+ fn esc(value: &str) -> String {
+ let mut out = String::with_capacity(value.len() + 2);
+ for ch in value.chars() {
+ match ch {
+ '\\' => out.push_str("\\\\"),
+ '"' => out.push_str("\\\""),
+ '\n' => out.push_str("\\n"),
+ '\r' => out.push_str("\\r"),
+ '\t' => out.push_str("\\t"),
+ _ => out.push(ch),
+ }
+ }
+ out
+ }
+
+ let mut out = String::new();
+ out.push('{');
+ out.push_str("\"code\":\"");
+ out.push_str(&esc(diagnostic.code.0));
+ out.push_str("\",\"severity\":\"");
+ out.push_str(match diagnostic.severity {
+ Severity::Info => "info",
+ Severity::Warning => "warning",
+ Severity::Error => "error",
+ Severity::Fatal => "fatal",
+ });
+ out.push_str("\",\"message\":\"");
+ out.push_str(&esc(&diagnostic.message));
+ out.push_str("\",\"context\":{");
+ if let Some(phase) = diagnostic.context.phase {
+ out.push_str("\"phase\":\"");
+ out.push_str(match phase {
+ Phase::Discover => "discover",
+ Phase::Read => "read",
+ Phase::Parse => "parse",
+ Phase::Validate => "validate",
+ Phase::Resolve => "resolve",
+ Phase::Prepare => "prepare",
+ Phase::Construct => "construct",
+ Phase::Register => "register",
+ Phase::Simulate => "simulate",
+ Phase::Render => "render",
+ });
+ out.push('"');
+ }
+ if let Some(path) = &diagnostic.context.path {
+ if diagnostic.context.phase.is_some() {
+ out.push(',');
+ }
+ out.push_str("\"path\":\"");
+ out.push_str(&esc(path));
+ out.push('"');
+ }
+ if let Some(entry) = &diagnostic.context.archive_entry {
+ if diagnostic.context.phase.is_some() || diagnostic.context.path.is_some() {
+ out.push(',');
+ }
+ out.push_str("\"archive_entry\":\"");
+ out.push_str(&esc(entry));
+ out.push('"');
+ }
+ if let Some(key) = &diagnostic.context.object_key {
+ if diagnostic.context.phase.is_some()
+ || diagnostic.context.path.is_some()
+ || diagnostic.context.archive_entry.is_some()
+ {
+ out.push(',');
+ }
+ out.push_str("\"object_key\":\"");
+ out.push_str(&esc(key));
+ out.push('"');
+ }
+ if let Some(span) = diagnostic.context.span {
+ if diagnostic.context.phase.is_some()
+ || diagnostic.context.path.is_some()
+ || diagnostic.context.archive_entry.is_some()
+ || diagnostic.context.object_key.is_some()
+ {
+ out.push(',');
+ }
+ out.push_str("\"span\":{\"offset\":");
+ out.push_str(&span.offset.to_string());
+ out.push_str(",\"length\":");
+ out.push_str(&span.length.to_string());
+ out.push('}');
+ }
+ out.push_str("},\"causes\":[");
+ for (idx, cause) in diagnostic.causes.iter().enumerate() {
+ if idx > 0 {
+ out.push(',');
+ }
+ out.push_str(&render_json(cause));
+ }
+ out.push_str("]}");
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn json_is_stable() {
+ let d = diagnostic(DiagnosticCode("S0-DIAG-001"), "keeps context").with_context(
+ DiagnosticContext {
+ phase: Some(Phase::Parse),
+ ..DiagnosticContext::default()
+ },
+ );
+ assert_eq!(
+ render_json(&d),
+ "{\"code\":\"S0-DIAG-001\",\"severity\":\"error\",\"message\":\"keeps context\",\"context\":{\"phase\":\"parse\"},\"causes\":[]}"
+ );
+ }
+
+ #[test]
+ fn diagnostic_chain_preserves_context() {
+ let mut root = diagnostic(DiagnosticCode("ROOT"), "root").with_context(DiagnosticContext {
+ phase: Some(Phase::Resolve),
+ path: Some("archives/material.lib".to_string()),
+ archive_entry: Some("MATERIAL.MAT0".to_string()),
+ object_key: Some("unit/tank".to_string()),
+ span: Some(SourceSpan {
+ offset: 12,
+ length: 4,
+ }),
+ });
+ root.push_cause(diagnostic(DiagnosticCode("CAUSE"), "cause").with_context(
+ DiagnosticContext {
+ phase: Some(Phase::Parse),
+ path: Some("archives/material.lib".to_string()),
+ span: Some(SourceSpan {
+ offset: 16,
+ length: 8,
+ }),
+ ..DiagnosticContext::default()
+ },
+ ));
+
+ let json = render_json(&root);
+
+ assert!(json.contains("\"code\":\"ROOT\""));
+ assert!(json.contains("\"phase\":\"resolve\""));
+ assert!(json.contains("\"path\":\"archives/material.lib\""));
+ assert!(json.contains("\"archive_entry\":\"MATERIAL.MAT0\""));
+ assert!(json.contains("\"object_key\":\"unit/tank\""));
+ assert!(json.contains("\"span\":{\"offset\":12,\"length\":4}"));
+ assert!(json.contains("\"code\":\"CAUSE\""));
+ assert!(json.contains("\"span\":{\"offset\":16,\"length\":8}"));
+ }
+}
diff --git a/crates/fparkan-fx/Cargo.toml b/crates/fparkan-fx/Cargo.toml
new file mode 100644
index 0000000..9bdae4d
--- /dev/null
+++ b/crates/fparkan-fx/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "fparkan-fx"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+fparkan-binary = { path = "../fparkan-binary" }
+
+[dev-dependencies]
+fparkan-nres = { path = "../fparkan-nres" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs
new file mode 100644
index 0000000..9675507
--- /dev/null
+++ b/crates/fparkan-fx/src/lib.rs
@@ -0,0 +1,1025 @@
+#![forbid(unsafe_code)]
+//! FXID effect contracts.
+
+use fparkan_binary::{Cursor, DecodeError};
+use std::sync::Arc;
+
+/// `FXID` `NRes` entry type.
+pub const FXID_KIND: u32 = 0x4449_5846;
+const HEADER_SIZE: usize = 60;
+
+/// FX document.
+#[derive(Clone, Debug)]
+pub struct FxDocument {
+ bytes: Arc<[u8]>,
+ header: FxHeader,
+ commands: Vec<FxCommand>,
+}
+
+/// FX header.
+#[derive(Clone, Debug, PartialEq)]
+pub struct FxHeader {
+ /// Number of commands in the stream.
+ pub command_count: u32,
+ /// Time mode.
+ pub time_mode: u32,
+ /// Duration in seconds.
+ pub duration_seconds: f32,
+ /// Phase jitter.
+ pub phase_jitter: f32,
+ /// Opaque flags.
+ pub flags: u32,
+ /// Opaque settings id.
+ pub settings_id: u32,
+ /// Random spatial shift.
+ pub random_shift: [f32; 3],
+ /// Local pivot.
+ pub pivot: [f32; 3],
+ /// Base scale.
+ pub scale: [f32; 3],
+}
+
+/// FX opcode.
+#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
+pub enum FxOpcode {
+ /// Opcode 1.
+ Op1,
+ /// Opcode 2.
+ Op2,
+ /// Opcode 3.
+ Op3,
+ /// Opcode 4.
+ Op4,
+ /// Opcode 5.
+ Op5,
+ /// Opcode 6.
+ Op6,
+ /// Opcode 7.
+ Op7,
+ /// Opcode 8.
+ Op8,
+ /// Opcode 9.
+ Op9,
+ /// Opcode 10.
+ Op10,
+}
+
+/// FX resource reference.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct FxResourceRef {
+ /// Fixed archive field bytes.
+ pub archive_raw: [u8; 32],
+ /// Fixed name field bytes.
+ pub name_raw: [u8; 32],
+}
+
+/// FX command.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct FxCommand {
+ /// Raw command word.
+ pub word: u32,
+ /// Decoded opcode.
+ pub opcode: FxOpcode,
+ /// Enabled bit.
+ pub enabled: bool,
+ /// Command body after the word.
+ pub raw_body: Arc<[u8]>,
+ /// Resource references discovered in known command layouts.
+ pub resource_refs: Vec<FxResourceRef>,
+}
+
+/// FX instance id.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct FxInstanceId(pub u64);
+
+/// FX seed.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct FxSeed(pub u64);
+
+/// External transform snapshot.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Transform {
+ /// Translation.
+ pub translation: [f32; 3],
+ /// Rotation quaternion.
+ pub rotation: [f32; 4],
+ /// Scale.
+ pub scale: [f32; 3],
+}
+
+impl Default for Transform {
+ fn default() -> Self {
+ Self {
+ translation: [0.0; 3],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ scale: [1.0; 3],
+ }
+ }
+}
+
+/// Game time in ticks.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct GameTime(pub u64);
+
+/// FX runtime state.
+#[derive(Clone, Debug)]
+pub struct FxState {
+ /// Instance id.
+ pub id: FxInstanceId,
+ /// Source document.
+ pub document: Arc<FxDocument>,
+ /// Seed.
+ pub seed: FxSeed,
+ /// Transform at creation time.
+ pub transform: Transform,
+ /// Last updated time.
+ pub time: GameTime,
+ /// RNG call count reserved for deterministic captures.
+ pub rng_calls: u64,
+ /// Lifecycle phase.
+ pub lifecycle: FxLifecycle,
+}
+
+/// FX lifecycle phase.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub enum FxLifecycle {
+ /// Running and eligible to emit.
+ #[default]
+ Running,
+ /// Stopped and not eligible to emit.
+ Stopped,
+ /// Ended permanently for the current instance.
+ Ended,
+}
+
+/// Visual FX emission produced from a command.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct FxPrimitive {
+ /// Command index.
+ pub command_index: u32,
+ /// Opcode.
+ pub opcode: FxOpcode,
+}
+
+/// Sound FX emission produced from a command.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct FxSoundEvent {
+ /// Command index.
+ pub command_index: u32,
+}
+
+/// FX emission.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum FxEmission {
+ /// Visual primitive.
+ Primitive(FxPrimitive),
+ /// Sound event.
+ Sound(FxSoundEvent),
+}
+
+/// FX decode/runtime error.
+#[derive(Debug)]
+pub enum FxError {
+ /// Binary decode error.
+ Decode(DecodeError),
+ /// Unknown opcode.
+ UnknownOpcode {
+ /// Command index.
+ index: u32,
+ /// Raw opcode byte.
+ opcode: u8,
+ },
+ /// Command stream exceeds payload.
+ CommandOutOfBounds {
+ /// Command index.
+ index: u32,
+ /// Expected command end.
+ expected_end: u64,
+ /// Payload size.
+ payload_size: u64,
+ },
+ /// Resource reference cannot be framed from body.
+ InvalidResourceRef {
+ /// Command index.
+ index: u32,
+ /// Opcode.
+ opcode: FxOpcode,
+ },
+ /// A referenced dependency is missing.
+ MissingDependency {
+ /// Effect name or stable effect id.
+ effect: String,
+ /// Command index.
+ command_index: u32,
+ /// Archive name.
+ archive: String,
+ /// Resource name.
+ name: String,
+ },
+}
+
+impl From<DecodeError> for FxError {
+ fn from(value: DecodeError) -> Self {
+ Self::Decode(value)
+ }
+}
+
+impl std::fmt::Display for FxError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Decode(source) => write!(f, "{source}"),
+ Self::UnknownOpcode { index, opcode } => {
+ write!(f, "unknown FX opcode {opcode} at command {index}")
+ }
+ Self::CommandOutOfBounds {
+ index,
+ expected_end,
+ payload_size,
+ } => write!(
+ f,
+ "FX command {index} out of bounds: expected_end={expected_end}, payload_size={payload_size}"
+ ),
+ Self::InvalidResourceRef { index, opcode } => {
+ write!(f, "invalid FX resource reference in command {index} ({opcode:?})")
+ }
+ Self::MissingDependency {
+ effect,
+ command_index,
+ archive,
+ name,
+ } => write!(
+ f,
+ "missing FX dependency: effect={effect}, command={command_index}, archive={archive}, name={name}"
+ ),
+ }
+ }
+}
+
+impl std::error::Error for FxError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Decode(source) => Some(source),
+ Self::UnknownOpcode { .. }
+ | Self::CommandOutOfBounds { .. }
+ | Self::InvalidResourceRef { .. }
+ | Self::MissingDependency { .. } => None,
+ }
+ }
+}
+
+/// Decodes an `FXID` payload.
+///
+/// # Errors
+///
+/// Returns [`FxError`] when the 60-byte header, fixed-size command stream, or
+/// exact EOF framing is invalid.
+pub fn decode_fxid(bytes: Arc<[u8]>) -> Result<FxDocument, FxError> {
+ let mut cursor = Cursor::new(&bytes);
+ let header = read_header(&mut cursor)?;
+ debug_assert_eq!(cursor.offset(), HEADER_SIZE as u64);
+ let mut commands = Vec::with_capacity(
+ usize::try_from(header.command_count)
+ .map_err(|_| FxError::Decode(DecodeError::IntegerOverflow))?,
+ );
+ for index in 0..header.command_count {
+ let start = cursor.offset();
+ let word = cursor.read_u32_le()?;
+ let opcode_byte = (word & 0xFF) as u8;
+ let opcode = opcode_from_byte(opcode_byte).ok_or(FxError::UnknownOpcode {
+ index,
+ opcode: opcode_byte,
+ })?;
+ let command_size = command_size(opcode);
+ let expected_end = start
+ .checked_add(u64::try_from(command_size).map_err(|_| DecodeError::IntegerOverflow)?)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ if expected_end > bytes.len() as u64 {
+ return Err(FxError::CommandOutOfBounds {
+ index,
+ expected_end,
+ payload_size: bytes.len() as u64,
+ });
+ }
+ let body_size = command_size
+ .checked_sub(4)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let body = cursor.read_exact(body_size)?;
+ let raw_body = Arc::from(body.to_vec().into_boxed_slice());
+ let resource_refs = resource_refs(index, opcode, body)?;
+ commands.push(FxCommand {
+ word,
+ opcode,
+ enabled: ((word >> 8) & 1) != 0,
+ raw_body,
+ resource_refs,
+ });
+ }
+ cursor.require_eof()?;
+ Ok(FxDocument {
+ bytes,
+ header,
+ commands,
+ })
+}
+
+/// Creates an FX instance.
+///
+/// # Errors
+///
+/// Currently returns [`FxError`] only for future resource/lifecycle validation
+/// hooks; creation is deterministic for a decoded document.
+pub fn create_instance(
+ document: Arc<FxDocument>,
+ seed: FxSeed,
+ transform: Transform,
+) -> Result<FxState, FxError> {
+ Ok(FxState {
+ id: FxInstanceId(seed.0),
+ document,
+ seed,
+ transform,
+ time: GameTime::default(),
+ rng_calls: 0,
+ lifecycle: FxLifecycle::Running,
+ })
+}
+
+/// Updates FX simulation time without emitting side effects.
+///
+/// # Errors
+///
+/// Reserved for future runtime validation.
+pub fn update(state: &mut FxState, time: GameTime) -> Result<(), FxError> {
+ state.time = time;
+ Ok(())
+}
+
+/// Emits active commands without advancing state.
+///
+/// # Errors
+///
+/// Reserved for future resource/runtime validation.
+pub fn emit(state: &FxState, out: &mut Vec<FxEmission>) -> Result<(), FxError> {
+ if state.lifecycle != FxLifecycle::Running {
+ return Ok(());
+ }
+ for (index, command) in state.document.commands.iter().enumerate() {
+ if !command.enabled {
+ continue;
+ }
+ let command_index = u32::try_from(index).map_err(|_| DecodeError::IntegerOverflow)?;
+ if command.opcode == FxOpcode::Op2 {
+ out.push(FxEmission::Sound(FxSoundEvent { command_index }));
+ } else {
+ out.push(FxEmission::Primitive(FxPrimitive {
+ command_index,
+ opcode: command.opcode,
+ }));
+ }
+ }
+ Ok(())
+}
+
+/// Stops an FX instance.
+pub fn stop(state: &mut FxState) {
+ state.lifecycle = FxLifecycle::Stopped;
+}
+
+/// Restarts a stopped FX instance from a time.
+pub fn restart(state: &mut FxState, time: GameTime) {
+ state.lifecycle = FxLifecycle::Running;
+ state.time = time;
+}
+
+/// Ends an FX instance permanently.
+pub fn end(state: &mut FxState) {
+ state.lifecycle = FxLifecycle::Ended;
+}
+
+/// Validates resource references through a caller-provided dependency probe.
+///
+/// # Errors
+///
+/// Returns [`FxError::MissingDependency`] with effect, command, archive and
+/// resource name context when the probe reports a missing resource.
+pub fn validate_dependencies(
+ document: &FxDocument,
+ effect: &str,
+ exists: impl Fn(&[u8], &[u8]) -> bool,
+) -> Result<(), FxError> {
+ for (index, command) in document.commands.iter().enumerate() {
+ for reference in &command.resource_refs {
+ if !exists(reference.archive_name(), reference.resource_name()) {
+ return Err(FxError::MissingDependency {
+ effect: effect.to_string(),
+ command_index: u32::try_from(index)
+ .map_err(|_| DecodeError::IntegerOverflow)?,
+ archive: String::from_utf8_lossy(reference.archive_name()).into_owned(),
+ name: String::from_utf8_lossy(reference.resource_name()).into_owned(),
+ });
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Builds a byte-stable capture for emitted commands.
+///
+/// # Errors
+///
+/// Returns [`FxError`] when emission fails.
+pub fn canonical_emission_capture(state: &FxState) -> Result<Vec<u8>, FxError> {
+ let mut emissions = Vec::new();
+ emit(state, &mut emissions)?;
+ let mut out = Vec::new();
+ for emission in emissions {
+ match emission {
+ FxEmission::Primitive(primitive) => {
+ out.extend_from_slice(
+ format!("P,{}, {:?}\n", primitive.command_index, primitive.opcode).as_bytes(),
+ );
+ }
+ FxEmission::Sound(sound) => {
+ out.extend_from_slice(format!("S,{}\n", sound.command_index).as_bytes());
+ }
+ }
+ }
+ Ok(out)
+}
+
+impl FxDocument {
+ /// Returns original bytes.
+ #[must_use]
+ pub fn bytes(&self) -> &[u8] {
+ &self.bytes
+ }
+
+ /// Returns the parsed header.
+ #[must_use]
+ pub fn header(&self) -> &FxHeader {
+ &self.header
+ }
+
+ /// Returns commands in disk order.
+ #[must_use]
+ pub fn commands(&self) -> &[FxCommand] {
+ &self.commands
+ }
+}
+
+impl FxResourceRef {
+ /// Archive name before first NUL, ASCII-trimmed.
+ #[must_use]
+ pub fn archive_name(&self) -> &[u8] {
+ bounded_cstr(&self.archive_raw)
+ }
+
+ /// Resource name before first NUL, ASCII-trimmed.
+ #[must_use]
+ pub fn resource_name(&self) -> &[u8] {
+ bounded_cstr(&self.name_raw)
+ }
+}
+
+fn read_header(cursor: &mut Cursor<'_>) -> Result<FxHeader, FxError> {
+ Ok(FxHeader {
+ command_count: cursor.read_u32_le()?,
+ time_mode: cursor.read_u32_le()?,
+ duration_seconds: cursor.read_f32_le()?,
+ phase_jitter: cursor.read_f32_le()?,
+ flags: cursor.read_u32_le()?,
+ settings_id: cursor.read_u32_le()?,
+ random_shift: [
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ ],
+ pivot: [
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ ],
+ scale: [
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ ],
+ })
+}
+
+fn opcode_from_byte(opcode: u8) -> Option<FxOpcode> {
+ match opcode {
+ 1 => Some(FxOpcode::Op1),
+ 2 => Some(FxOpcode::Op2),
+ 3 => Some(FxOpcode::Op3),
+ 4 => Some(FxOpcode::Op4),
+ 5 => Some(FxOpcode::Op5),
+ 6 => Some(FxOpcode::Op6),
+ 7 => Some(FxOpcode::Op7),
+ 8 => Some(FxOpcode::Op8),
+ 9 => Some(FxOpcode::Op9),
+ 10 => Some(FxOpcode::Op10),
+ _ => None,
+ }
+}
+
+fn command_size(opcode: FxOpcode) -> usize {
+ match opcode {
+ FxOpcode::Op1 => 224,
+ FxOpcode::Op2 => 148,
+ FxOpcode::Op3 => 200,
+ FxOpcode::Op4 => 204,
+ FxOpcode::Op5 => 112,
+ FxOpcode::Op6 => 4,
+ FxOpcode::Op7 | FxOpcode::Op9 | FxOpcode::Op10 => 208,
+ FxOpcode::Op8 => 248,
+ }
+}
+
+fn resource_refs(index: u32, opcode: FxOpcode, body: &[u8]) -> Result<Vec<FxResourceRef>, FxError> {
+ if !has_resource_ref(opcode) {
+ return Ok(Vec::new());
+ }
+ let raw = body
+ .get(..64)
+ .ok_or(FxError::InvalidResourceRef { index, opcode })?;
+ let mut archive_raw = [0; 32];
+ let mut name_raw = [0; 32];
+ archive_raw.copy_from_slice(&raw[..32]);
+ name_raw.copy_from_slice(&raw[32..64]);
+ Ok(vec![FxResourceRef {
+ archive_raw,
+ name_raw,
+ }])
+}
+
+fn has_resource_ref(opcode: FxOpcode) -> bool {
+ matches!(
+ opcode,
+ FxOpcode::Op2
+ | FxOpcode::Op3
+ | FxOpcode::Op4
+ | FxOpcode::Op5
+ | FxOpcode::Op7
+ | FxOpcode::Op8
+ | FxOpcode::Op9
+ | FxOpcode::Op10
+ )
+}
+
+fn bounded_cstr(raw: &[u8]) -> &[u8] {
+ let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len());
+ trim_ascii(&raw[..len])
+}
+
+fn trim_ascii(bytes: &[u8]) -> &[u8] {
+ let mut start = 0usize;
+ let mut end = bytes.len();
+ while start < end && bytes[start].is_ascii_whitespace() {
+ start += 1;
+ }
+ while end > start && bytes[end - 1].is_ascii_whitespace() {
+ end -= 1;
+ }
+ &bytes[start..end]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_nres::ReadProfile;
+ use std::collections::BTreeMap;
+ use std::path::{Path, PathBuf};
+
+ #[test]
+ fn decodes_synthetic_opcodes_and_refs() {
+ let mut bytes = header(2);
+ bytes.extend_from_slice(&command_with_ref(0x0102, 148, b"sounds.lib", b"boom.wav"));
+ bytes.extend_from_slice(&command(0x0106, 4));
+ let document = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx");
+
+ assert_eq!(document.header().command_count, 2);
+ assert_eq!(document.commands()[0].opcode, FxOpcode::Op2);
+ assert!(document.commands()[0].enabled);
+ assert_eq!(
+ document.commands()[0].resource_refs[0].archive_name(),
+ b"sounds.lib"
+ );
+ assert_eq!(document.commands()[1].opcode, FxOpcode::Op6);
+ assert!(document.commands()[1].raw_body.is_empty());
+ }
+
+ #[test]
+ fn header_is_exactly_sixty_bytes_and_command_sizes_are_fixed() {
+ let mut bytes = header(10);
+ for opcode in 1..=10_u32 {
+ bytes.extend_from_slice(&command(0x0100 | opcode, opcode_size(opcode)));
+ }
+ let document = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx");
+
+ assert_eq!(header(0).len(), HEADER_SIZE);
+ assert_eq!(document.commands().len(), 10);
+ for (index, command) in document.commands().iter().enumerate() {
+ let opcode = u32::try_from(index + 1).expect("opcode");
+ assert_eq!(command.raw_body.len() + 4, opcode_size(opcode));
+ }
+ }
+
+ #[test]
+ fn opcode6_four_byte_command_is_accepted() {
+ let mut bytes = header(1);
+ bytes.extend_from_slice(&command(0x0106, 4));
+ let document = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx");
+
+ assert_eq!(document.commands()[0].opcode, FxOpcode::Op6);
+ assert!(document.commands()[0].raw_body.is_empty());
+ }
+
+ #[test]
+ fn rejects_unknown_opcode_at_command_index() {
+ let mut bytes = header(1);
+ bytes.extend_from_slice(&99_u32.to_le_bytes());
+ let err = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect_err("unknown opcode");
+
+ assert!(matches!(
+ err,
+ FxError::UnknownOpcode {
+ index: 0,
+ opcode: 99
+ }
+ ));
+ }
+
+ #[test]
+ fn rejects_command_count_that_exceeds_payload() {
+ let mut bytes = header(2);
+ bytes.extend_from_slice(&command(0x0106, 4));
+ let err = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect_err("out of bounds");
+
+ assert!(matches!(
+ err,
+ FxError::Decode(DecodeError::UnexpectedEof { .. }) | FxError::CommandOutOfBounds { .. }
+ ));
+ }
+
+ #[test]
+ fn rejects_trailing_bytes_after_command_stream() {
+ let mut bytes = header(0);
+ bytes.push(0);
+ let err = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect_err("trailing");
+
+ assert!(matches!(
+ err,
+ FxError::Decode(DecodeError::TrailingBytes { .. })
+ ));
+ }
+
+ #[test]
+ fn fixed_resource_refs_preserve_tails() {
+ let mut bytes = header(1);
+ let mut command = command_with_ref(0x0102, 148, b"sounds.lib", b"boom.wav");
+ command[4 + 20] = 0xAB;
+ command[36 + 20] = 0xCD;
+ bytes.extend_from_slice(&command);
+ let document = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx");
+
+ let reference = &document.commands()[0].resource_refs[0];
+ assert_eq!(reference.archive_name(), b"sounds.lib");
+ assert_eq!(reference.resource_name(), b"boom.wav");
+ assert_eq!(reference.archive_raw[20], 0xAB);
+ assert_eq!(reference.name_raw[20], 0xCD);
+ }
+
+ #[test]
+ fn missing_dependency_error_contains_effect_command_archive_and_name() {
+ let mut bytes = header(1);
+ bytes.extend_from_slice(&command_with_ref(
+ 0x0102,
+ 148,
+ b"sounds.lib",
+ b"missing.wav",
+ ));
+ let document = decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx");
+
+ let err = validate_dependencies(&document, "spark", |_, _| false)
+ .expect_err("missing dependency");
+
+ assert!(matches!(
+ err,
+ FxError::MissingDependency {
+ ref effect,
+ command_index: 0,
+ ref archive,
+ ref name,
+ } if effect == "spark" && archive == "sounds.lib" && name == "missing.wav"
+ ));
+ assert!(err.to_string().contains("spark"));
+ assert!(err.to_string().contains("missing.wav"));
+ }
+
+ #[test]
+ fn update_and_emit_are_separate() {
+ let mut bytes = header(1);
+ bytes.extend_from_slice(&command(0x0101, 224));
+ let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"));
+ let mut state = create_instance(document, FxSeed(7), Transform::default()).expect("state");
+ update(&mut state, GameTime(42)).expect("update");
+ let before = state.time;
+ let mut out = Vec::new();
+
+ emit(&state, &mut out).expect("emit");
+
+ assert_eq!(state.time, before);
+ assert_eq!(out.len(), 1);
+ }
+
+ #[test]
+ fn create_records_seed_transform_and_start_time() {
+ let bytes = header(0);
+ let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"));
+ let transform = Transform {
+ translation: [1.0, 2.0, 3.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ scale: [4.0, 5.0, 6.0],
+ };
+
+ let state = create_instance(document, FxSeed(77), transform).expect("state");
+
+ assert_eq!(state.id, FxInstanceId(77));
+ assert_eq!(state.seed, FxSeed(77));
+ assert_eq!(state.transform, transform);
+ assert_eq!(state.time, GameTime(0));
+ assert_eq!(state.rng_calls, 0);
+ assert_eq!(state.lifecycle, FxLifecycle::Running);
+ }
+
+ #[test]
+ fn stable_command_order_and_emission_capture_are_seed_stable() {
+ let mut bytes = header(3);
+ bytes.extend_from_slice(&command(0x0101, 224));
+ bytes.extend_from_slice(&command(0x0102, 148));
+ bytes.extend_from_slice(&command(0x0106, 4));
+ let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"));
+ let mut first =
+ create_instance(document.clone(), FxSeed(5), Transform::default()).expect("first");
+ let mut second =
+ create_instance(document, FxSeed(5), Transform::default()).expect("second");
+
+ update(&mut first, GameTime(9)).expect("update");
+ update(&mut second, GameTime(9)).expect("update");
+
+ assert_eq!(
+ canonical_emission_capture(&first).expect("first capture"),
+ canonical_emission_capture(&second).expect("second capture")
+ );
+ assert_eq!(
+ canonical_emission_capture(&first).expect("capture"),
+ b"P,0, Op1\nS,1\nP,2, Op6\n"
+ );
+ }
+
+ #[test]
+ fn stop_restart_end_lifecycle_controls_emission() {
+ let mut bytes = header(1);
+ bytes.extend_from_slice(&command(0x0101, 224));
+ let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"));
+ let mut state = create_instance(document, FxSeed(1), Transform::default()).expect("state");
+
+ assert!(!canonical_emission_capture(&state)
+ .expect("running")
+ .is_empty());
+ stop(&mut state);
+ assert_eq!(state.lifecycle, FxLifecycle::Stopped);
+ assert!(canonical_emission_capture(&state)
+ .expect("stopped")
+ .is_empty());
+ restart(&mut state, GameTime(12));
+ assert_eq!(state.lifecycle, FxLifecycle::Running);
+ assert_eq!(state.time, GameTime(12));
+ assert!(!canonical_emission_capture(&state)
+ .expect("restarted")
+ .is_empty());
+ end(&mut state);
+ assert_eq!(state.lifecycle, FxLifecycle::Ended);
+ assert!(canonical_emission_capture(&state)
+ .expect("ended")
+ .is_empty());
+ }
+
+ #[test]
+ fn unrelated_rng_stream_use_does_not_perturb_fx_capture() {
+ let mut bytes = header(1);
+ bytes.extend_from_slice(&command(0x0101, 224));
+ let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"));
+ let state = create_instance(document, FxSeed(3), Transform::default()).expect("state");
+ let before = canonical_emission_capture(&state).expect("before");
+
+ let mut unrelated = 0x1234_u64;
+ for _ in 0..32 {
+ unrelated = unrelated.rotate_left(7).wrapping_mul(17);
+ }
+
+ assert_ne!(unrelated, 0);
+ assert_eq!(canonical_emission_capture(&state).expect("after"), before);
+ }
+
+ #[test]
+ fn arbitrary_command_streams_are_bounded_and_panic_free() {
+ for len in 0..256usize {
+ let mut bytes = vec![0xA5; len];
+ if len >= HEADER_SIZE {
+ bytes[0..4].copy_from_slice(&1_u32.to_le_bytes());
+ }
+ let result = std::panic::catch_unwind(|| {
+ let _ = decode_fxid(Arc::from(bytes.into_boxed_slice()));
+ });
+ assert!(result.is_ok());
+ }
+ }
+
+ #[test]
+ fn licensed_corpus_fxid_exact_eof_and_distribution() {
+ for (corpus, expected_count) in [("IS", 923_usize), ("IS2", 1065_usize)] {
+ let Some(root) = corpus_root(corpus) else {
+ continue;
+ };
+ let mut count = 0usize;
+ let mut opcodes = BTreeMap::<FxOpcode, usize>::new();
+ let mut time_modes = BTreeMap::<u32, usize>::new();
+ for path in files_under(&root) {
+ let Ok(bytes) = std::fs::read(&path) else {
+ continue;
+ };
+ let Ok(archive) = fparkan_nres::decode(
+ Arc::from(bytes.into_boxed_slice()),
+ ReadProfile::Compatible,
+ ) else {
+ continue;
+ };
+ for entry in archive
+ .entries()
+ .iter()
+ .filter(|entry| entry.meta().type_id == FXID_KIND)
+ {
+ let payload = archive.payload(entry.id()).expect("payload");
+ let document = decode_fxid(Arc::from(payload.to_vec().into_boxed_slice()))
+ .unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
+ });
+ count += 1;
+ *time_modes.entry(document.header().time_mode).or_insert(0) += 1;
+ for command in document.commands() {
+ *opcodes.entry(command.opcode).or_insert(0) += 1;
+ }
+ }
+ }
+
+ assert_eq!(count, expected_count, "{corpus} FXID count");
+ assert!(!opcodes.contains_key(&FxOpcode::Op6), "{corpus} opcode 6");
+ for mode in time_modes.keys() {
+ assert!(
+ matches!(*mode, 0 | 1 | 2 | 4 | 5 | 14 | 15 | 16 | 17),
+ "{corpus} unexpected time mode {mode}"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn licensed_corpus_fxid_emission_captures_are_approved() {
+ for (corpus, expected_count, expected_emitting, expected_hash) in [
+ ("IS", 923_usize, 467_usize, 10_553_431_922_547_057_702_u64),
+ ("IS2", 1065_usize, 532_usize, 9_217_284_592_334_143_531_u64),
+ ] {
+ let Some(root) = corpus_root(corpus) else {
+ continue;
+ };
+ let mut count = 0usize;
+ let mut emitting = 0usize;
+ let mut hash = FNV_OFFSET;
+ for path in files_under(&root) {
+ let Ok(bytes) = std::fs::read(&path) else {
+ continue;
+ };
+ let Ok(archive) = fparkan_nres::decode(
+ Arc::from(bytes.into_boxed_slice()),
+ ReadProfile::Compatible,
+ ) else {
+ continue;
+ };
+ for entry in archive
+ .entries()
+ .iter()
+ .filter(|entry| entry.meta().type_id == FXID_KIND)
+ {
+ let payload = archive.payload(entry.id()).expect("payload");
+ let document = Arc::new(
+ decode_fxid(Arc::from(payload.to_vec().into_boxed_slice())).unwrap_or_else(
+ |err| panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()),
+ ),
+ );
+ let state =
+ create_instance(document, FxSeed(count as u64), Transform::default())
+ .expect("fx state");
+ let capture = canonical_emission_capture(&state).expect("capture");
+ if !capture.is_empty() {
+ emitting += 1;
+ }
+ hash_bytes(&mut hash, entry.name_bytes());
+ hash_bytes(&mut hash, &capture);
+ count += 1;
+ }
+ }
+
+ assert_eq!(count, expected_count, "{corpus} FXID count");
+ assert_eq!(emitting, expected_emitting, "{corpus} emitting FXID count");
+ assert_eq!(hash, expected_hash, "{corpus} FXID capture hash");
+ }
+ }
+
+ fn header(command_count: u32) -> Vec<u8> {
+ let mut out = Vec::with_capacity(HEADER_SIZE);
+ out.extend_from_slice(&command_count.to_le_bytes());
+ out.extend_from_slice(&1_u32.to_le_bytes());
+ out.extend_from_slice(&1.0_f32.to_bits().to_le_bytes());
+ out.extend_from_slice(&0.0_f32.to_bits().to_le_bytes());
+ out.extend_from_slice(&0_u32.to_le_bytes());
+ out.extend_from_slice(&0_u32.to_le_bytes());
+ for _ in 0..9 {
+ out.extend_from_slice(&0.0_f32.to_bits().to_le_bytes());
+ }
+ assert_eq!(out.len(), HEADER_SIZE);
+ out
+ }
+
+ fn command(word: u32, size: usize) -> Vec<u8> {
+ let mut out = Vec::with_capacity(size);
+ out.extend_from_slice(&word.to_le_bytes());
+ out.resize(size, 0);
+ out
+ }
+
+ fn command_with_ref(word: u32, size: usize, archive: &[u8], name: &[u8]) -> Vec<u8> {
+ let mut out = command(word, size);
+ copy_cstr(&mut out[4..36], archive);
+ copy_cstr(&mut out[36..68], name);
+ out
+ }
+
+ fn opcode_size(opcode: u32) -> usize {
+ match opcode {
+ 1 => 224,
+ 2 => 148,
+ 3 => 200,
+ 4 => 204,
+ 5 => 112,
+ 6 => 4,
+ 7 | 9 | 10 => 208,
+ 8 => 248,
+ _ => unreachable!("test opcode"),
+ }
+ }
+
+ fn copy_cstr(dst: &mut [u8], src: &[u8]) {
+ let len = dst.len().saturating_sub(1).min(src.len());
+ dst[..len].copy_from_slice(&src[..len]);
+ }
+
+ fn corpus_root(name: &str) -> Option<PathBuf> {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("testdata")
+ .join(name);
+ root.is_dir().then_some(root)
+ }
+
+ fn files_under(root: &Path) -> Vec<PathBuf> {
+ let mut out = Vec::new();
+ let mut stack = vec![root.to_path_buf()];
+ while let Some(path) = stack.pop() {
+ let Ok(read_dir) = std::fs::read_dir(path) else {
+ continue;
+ };
+ for entry in read_dir.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ stack.push(path);
+ } else {
+ out.push(path);
+ }
+ }
+ }
+ out.sort();
+ out
+ }
+
+ const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
+ const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
+
+ fn hash_bytes(hash: &mut u64, bytes: &[u8]) {
+ for byte in bytes {
+ *hash ^= u64::from(*byte);
+ *hash = hash.wrapping_mul(FNV_PRIME);
+ }
+ }
+}
diff --git a/crates/fparkan-material/Cargo.toml b/crates/fparkan-material/Cargo.toml
new file mode 100644
index 0000000..6f5c755
--- /dev/null
+++ b/crates/fparkan-material/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "fparkan-material"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+encoding_rs = "0.8"
+fparkan-path = { path = "../fparkan-path" }
+fparkan-resource = { path = "../fparkan-resource" }
+
+[dev-dependencies]
+fparkan-nres = { path = "../fparkan-nres" }
+fparkan-vfs = { path = "../fparkan-vfs" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-material/src/lib.rs b/crates/fparkan-material/src/lib.rs
new file mode 100644
index 0000000..780a1ae
--- /dev/null
+++ b/crates/fparkan-material/src/lib.rs
@@ -0,0 +1,1272 @@
+#![forbid(unsafe_code)]
+//! WEAR/MAT0 material contracts.
+
+use encoding_rs::WINDOWS_1251;
+use fparkan_path::ResourceName;
+use fparkan_resource::{archive_path, ResourceError, ResourceRepository};
+
+/// `MAT0` `NRes` entry type.
+pub const MAT0_KIND: u32 = 0x3054_414D;
+/// `WEAR` `NRes` entry type.
+pub const WEAR_KIND: u32 = 0x5241_4557;
+
+/// WEAR table.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct WearTable {
+ /// Entries.
+ pub entries: Vec<WearEntry>,
+ /// Lightmap entries.
+ pub lightmaps: Vec<LightmapEntry>,
+}
+
+/// WEAR entry.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct WearEntry {
+ /// Legacy id text.
+ pub legacy_id: LegacyText,
+ /// Material.
+ pub material: ResourceName,
+}
+
+/// Legacy text token.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct LegacyText(pub String);
+
+/// Lightmap entry.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct LightmapEntry {
+ /// Legacy id text.
+ pub legacy_id: LegacyText,
+ /// Lightmap resource.
+ pub lightmap: ResourceName,
+}
+
+/// MAT0 document.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Mat0Document {
+ /// Version/profile supplied by archive metadata.
+ pub version: u32,
+ /// Declared animation block count.
+ pub animation_block_count: u16,
+ /// Phase records.
+ pub phases: Vec<MaterialPhase>,
+ /// Version-gated bytes between header and phase table.
+ pub prefix: Vec<u8>,
+ /// Opaque bytes at offsets 2..4.
+ pub header_opaque: [u8; 2],
+ /// Animation blocks parsed after phases.
+ pub animation_blocks: Vec<MaterialAnimationBlock>,
+}
+
+/// Material phase.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MaterialPhase {
+ /// Parameters.
+ pub parameters: [u8; 18],
+ /// Texture raw.
+ pub texture_raw: [u8; 16],
+}
+
+/// Material animation block.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MaterialAnimationBlock {
+ /// Raw block header.
+ pub header_raw: u32,
+ /// Parsed keys.
+ pub keys: Vec<MaterialKey>,
+ /// Raw block bytes.
+ pub bytes: Vec<u8>,
+}
+
+/// Material key.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub struct MaterialKey {
+ /// Key part.
+ pub k0: u16,
+ /// Key part.
+ pub k1: u16,
+ /// Key part.
+ pub k2: u16,
+}
+
+/// Material fallback.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum MaterialFallback {
+ /// Exact.
+ Exact,
+ /// Default.
+ Default,
+ /// First entry.
+ FirstEntry,
+}
+
+/// Material timeline mode.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum MaterialTimelineMode {
+ /// Play once from phase zero.
+ OneShot,
+ /// Clamp frame to the last phase.
+ Clamp,
+ /// Loop over all phases.
+ Loop,
+ /// Ping-pong over all phases.
+ PingPong,
+}
+
+/// Material runtime sampling profile.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct MaterialTimelineProfile {
+ /// Timeline mode.
+ pub mode: MaterialTimelineMode,
+ /// Apply deterministic material-only random offset.
+ pub random_offset: bool,
+}
+
+/// Sampled material phase.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MaterialPhaseSample {
+ /// Selected phase index.
+ pub phase_index: usize,
+ /// Effective frame after mode and random offset.
+ pub effective_frame: u32,
+ /// Sampled parameter bytes.
+ pub parameters: [u8; 18],
+ /// Sampled texture bytes.
+ pub texture_raw: [u8; 16],
+}
+
+/// Resolved material.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ResolvedMaterial {
+ /// Resolved material name.
+ pub name: ResourceName,
+ /// Fallback path.
+ pub fallback: MaterialFallback,
+ /// Decoded document.
+ pub document: Mat0Document,
+}
+
+/// Material parse or resolution error.
+#[derive(Debug)]
+pub enum MaterialError {
+ /// Text payload is empty.
+ EmptyWear,
+ /// Count line is invalid.
+ InvalidWearCount(String),
+ /// Count is zero.
+ ZeroWearCount,
+ /// A material row is missing.
+ MissingWearRow {
+ /// Row index.
+ index: usize,
+ /// Expected row count.
+ count: usize,
+ },
+ /// A material row is malformed.
+ InvalidWearRow {
+ /// Row index.
+ index: usize,
+ /// Original line.
+ line: String,
+ },
+ /// Required blank line before `LIGHTMAPS` is missing.
+ MissingLightmapSeparator,
+ /// `LIGHTMAPS` marker is missing.
+ MissingLightmapMarker,
+ /// Lightmap count line is invalid.
+ InvalidLightmapCount(String),
+ /// Lightmap row is missing.
+ MissingLightmapRow {
+ /// Row index.
+ index: usize,
+ /// Expected row count.
+ count: usize,
+ },
+ /// Lightmap row is malformed.
+ InvalidLightmapRow {
+ /// Row index.
+ index: usize,
+ /// Original line.
+ line: String,
+ },
+ /// MAT0 payload is too small.
+ Mat0TooSmall {
+ /// Payload size.
+ size: usize,
+ },
+ /// MAT0 phase count is unsupported.
+ InvalidPhaseCount {
+ /// Phase count.
+ count: usize,
+ },
+ /// MAT0 range is outside payload.
+ Mat0OutOfBounds,
+ /// MAT0 has trailing bytes not accounted for by the current grammar.
+ Mat0TrailingBytes {
+ /// Expected EOF.
+ expected: usize,
+ /// Actual payload size.
+ actual: usize,
+ },
+ /// Material index is outside WEAR table.
+ WearIndexOutOfBounds {
+ /// Requested index.
+ index: u16,
+ /// Entry count.
+ count: usize,
+ },
+ /// Repository error.
+ Resource(String),
+ /// Material archive or entry is missing.
+ MissingMaterial(String),
+ /// A material document has no phases for runtime sampling.
+ EmptyMaterial,
+}
+
+impl From<ResourceError> for MaterialError {
+ fn from(value: ResourceError) -> Self {
+ Self::Resource(value.to_string())
+ }
+}
+
+impl std::fmt::Display for MaterialError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::EmptyWear => write!(f, "WEAR payload is empty"),
+ Self::InvalidWearCount(line) => write!(f, "invalid WEAR count line: {line}"),
+ Self::ZeroWearCount => write!(f, "WEAR count must be greater than zero"),
+ Self::MissingWearRow { index, count } => {
+ write!(f, "missing WEAR row {index} of {count}")
+ }
+ Self::InvalidWearRow { index, line } => {
+ write!(f, "invalid WEAR row {index}: {line}")
+ }
+ Self::MissingLightmapSeparator => {
+ write!(f, "missing blank separator before LIGHTMAPS")
+ }
+ Self::MissingLightmapMarker => write!(f, "missing LIGHTMAPS marker"),
+ Self::InvalidLightmapCount(line) => {
+ write!(f, "invalid LIGHTMAPS count line: {line}")
+ }
+ Self::MissingLightmapRow { index, count } => {
+ write!(f, "missing LIGHTMAPS row {index} of {count}")
+ }
+ Self::InvalidLightmapRow { index, line } => {
+ write!(f, "invalid LIGHTMAPS row {index}: {line}")
+ }
+ Self::Mat0TooSmall { size } => write!(f, "MAT0 payload too small: {size}"),
+ Self::InvalidPhaseCount { count } => {
+ write!(f, "invalid MAT0 phase count: {count}")
+ }
+ Self::Mat0OutOfBounds => write!(f, "MAT0 data out of bounds"),
+ Self::Mat0TrailingBytes { expected, actual } => {
+ write!(
+ f,
+ "MAT0 trailing bytes: expected EOF {expected}, actual {actual}"
+ )
+ }
+ Self::WearIndexOutOfBounds { index, count } => {
+ write!(f, "WEAR index {index} outside {count} entries")
+ }
+ Self::Resource(message) => write!(f, "{message}"),
+ Self::MissingMaterial(name) => write!(f, "missing material: {name}"),
+ Self::EmptyMaterial => write!(f, "material has no phases"),
+ }
+ }
+}
+
+impl std::error::Error for MaterialError {}
+
+/// Decodes WEAR material/lightmap table.
+///
+/// # Errors
+///
+/// Returns [`MaterialError`] when count lines, rows, or the `LIGHTMAPS`
+/// section framing are malformed.
+pub fn decode_wear(bytes: &[u8]) -> Result<WearTable, MaterialError> {
+ let text = decode_cp1251(bytes).replace('\r', "");
+ let mut lines = text.lines();
+ let Some(first) = lines.next() else {
+ return Err(MaterialError::EmptyWear);
+ };
+ let count = parse_count(first).map_err(|_| MaterialError::InvalidWearCount(first.into()))?;
+ if count == 0 {
+ return Err(MaterialError::ZeroWearCount);
+ }
+
+ let mut entries = Vec::with_capacity(count);
+ for index in 0..count {
+ let line = lines
+ .next()
+ .ok_or(MaterialError::MissingWearRow { index, count })?;
+ let (legacy_id, material) =
+ parse_pair(line).ok_or_else(|| MaterialError::InvalidWearRow {
+ index,
+ line: line.to_string(),
+ })?;
+ entries.push(WearEntry {
+ legacy_id,
+ material,
+ });
+ }
+
+ let remainder = lines.collect::<Vec<_>>();
+ let lightmaps = parse_lightmaps(&remainder)?;
+ Ok(WearTable { entries, lightmaps })
+}
+
+/// Decodes MAT0 material phase data.
+///
+/// # Errors
+///
+/// Returns [`MaterialError`] when version-gated prefix bytes, phase records, or
+/// EOF framing are malformed.
+pub fn decode_mat0(bytes: &[u8], version: u32) -> Result<Mat0Document, MaterialError> {
+ if bytes.len() < 4 {
+ return Err(MaterialError::Mat0TooSmall { size: bytes.len() });
+ }
+ let phase_count = usize::from(u16::from_le_bytes([bytes[0], bytes[1]]));
+ let animation_block_count = u16::from_le_bytes([bytes[2], bytes[3]]);
+ if animation_block_count >= 20 {
+ return Err(MaterialError::InvalidPhaseCount {
+ count: usize::from(animation_block_count),
+ });
+ }
+ let header_opaque = [bytes[2], bytes[3]];
+ let prefix_len = mat0_prefix_len(version);
+ let phase_start = 4usize
+ .checked_add(prefix_len)
+ .ok_or(MaterialError::Mat0OutOfBounds)?;
+ let phase_bytes = phase_count
+ .checked_mul(34)
+ .ok_or(MaterialError::Mat0OutOfBounds)?;
+ let phase_end = phase_start
+ .checked_add(phase_bytes)
+ .ok_or(MaterialError::Mat0OutOfBounds)?;
+ if phase_end > bytes.len() {
+ return Err(MaterialError::Mat0OutOfBounds);
+ }
+
+ let mut phases = Vec::with_capacity(phase_count);
+ for index in 0..phase_count {
+ let offset = phase_start
+ .checked_add(
+ index
+ .checked_mul(34)
+ .ok_or(MaterialError::Mat0OutOfBounds)?,
+ )
+ .ok_or(MaterialError::Mat0OutOfBounds)?;
+ let record = bytes
+ .get(offset..offset + 34)
+ .ok_or(MaterialError::Mat0OutOfBounds)?;
+ let mut parameters = [0; 18];
+ let mut texture_raw = [0; 16];
+ parameters.copy_from_slice(&record[..18]);
+ texture_raw.copy_from_slice(&record[18..34]);
+ phases.push(MaterialPhase {
+ parameters,
+ texture_raw,
+ });
+ }
+
+ let animation_blocks = parse_animation_blocks(&bytes[phase_end..], animation_block_count)?;
+ Ok(Mat0Document {
+ version,
+ animation_block_count,
+ phases,
+ prefix: bytes[4..phase_start].to_vec(),
+ header_opaque,
+ animation_blocks,
+ })
+}
+
+/// Resolves a material selected by WEAR index.
+///
+/// # Errors
+///
+/// Returns [`MaterialError`] when the WEAR index is invalid, `material.lib` is
+/// missing, or no exact/DEFAULT material can be found.
+pub fn resolve_material(
+ repository: &dyn ResourceRepository,
+ table: &WearTable,
+ index: u16,
+) -> Result<ResolvedMaterial, MaterialError> {
+ let entry =
+ table
+ .entries
+ .get(usize::from(index))
+ .ok_or(MaterialError::WearIndexOutOfBounds {
+ index,
+ count: table.entries.len(),
+ })?;
+ let archive = repository.open_archive(
+ &archive_path(b"material.lib").map_err(|err| MaterialError::Resource(err.to_string()))?,
+ )?;
+
+ if let Some(resolved) = load_material_entry(
+ repository,
+ archive,
+ &entry.material,
+ MaterialFallback::Exact,
+ )? {
+ return Ok(resolved);
+ }
+ let default = ResourceName(b"DEFAULT".to_vec());
+ if let Some(resolved) =
+ load_material_entry(repository, archive, &default, MaterialFallback::Default)?
+ {
+ return Ok(resolved);
+ }
+ if let Some(first) = table.entries.first() {
+ if let Some(resolved) = load_material_entry(
+ repository,
+ archive,
+ &first.material,
+ MaterialFallback::FirstEntry,
+ )? {
+ return Ok(resolved);
+ }
+ }
+ Err(MaterialError::MissingMaterial(
+ String::from_utf8_lossy(&entry.material.0).into_owned(),
+ ))
+}
+
+/// Samples a material phase with deterministic runtime timeline semantics.
+///
+/// # Errors
+///
+/// Returns [`MaterialError::EmptyMaterial`] when the MAT0 document has no
+/// phases.
+pub fn sample_material_phase(
+ document: &Mat0Document,
+ profile: MaterialTimelineProfile,
+ frame: u32,
+ seed: u64,
+) -> Result<MaterialPhaseSample, MaterialError> {
+ if document.phases.is_empty() {
+ return Err(MaterialError::EmptyMaterial);
+ }
+ let phase_count = document.phases.len();
+ let offset = if profile.random_offset {
+ material_random_offset(seed, phase_count)
+ } else {
+ 0
+ };
+ let effective_frame = frame.wrapping_add(offset);
+ let phase_index = select_phase_index(profile.mode, effective_frame, phase_count);
+ let phase = &document.phases[phase_index];
+ Ok(MaterialPhaseSample {
+ phase_index,
+ effective_frame,
+ parameters: phase.parameters,
+ texture_raw: phase.texture_raw,
+ })
+}
+
+/// Interpolates selected parameter bytes according to a bit mask.
+///
+/// Unmasked fields are copied from `left`; masked fields are linearly blended
+/// and rounded to nearest integer.
+#[must_use]
+pub fn interpolate_parameter_bytes(
+ left: [u8; 18],
+ right: [u8; 18],
+ interpolation_mask: u32,
+ t: f32,
+) -> [u8; 18] {
+ let mut out = left;
+ for (index, value) in out.iter_mut().enumerate() {
+ if interpolation_mask & (1_u32 << index) == 0 {
+ continue;
+ }
+ let blended =
+ f32::from(left[index]) + (f32::from(right[index]) - f32::from(left[index])) * t;
+ *value = rounded_clamped_byte(blended);
+ }
+ out
+}
+
+fn rounded_clamped_byte(value: f32) -> u8 {
+ let rounded = value.round();
+ if !rounded.is_finite() || rounded <= 0.0 {
+ return 0;
+ }
+ if rounded >= f32::from(u8::MAX) {
+ return u8::MAX;
+ }
+ (0_u8..=u8::MAX)
+ .find(|candidate| f32::from(*candidate) >= rounded)
+ .unwrap_or(u8::MAX)
+}
+
+/// Builds a deterministic capture for material phase sampling.
+///
+/// # Errors
+///
+/// Returns [`MaterialError`] when sampling fails.
+pub fn material_phase_capture(
+ document: &Mat0Document,
+ profile: MaterialTimelineProfile,
+ frames: &[u32],
+ seed: u64,
+) -> Result<Vec<u8>, MaterialError> {
+ let mut out = Vec::new();
+ for frame in frames {
+ let sample = sample_material_phase(document, profile, *frame, seed)?;
+ out.extend_from_slice(
+ format!(
+ "M,{},{},{}\n",
+ frame, sample.effective_frame, sample.phase_index
+ )
+ .as_bytes(),
+ );
+ }
+ Ok(out)
+}
+
+impl Mat0Document {
+ /// Returns the first non-empty texture name from material phases.
+ #[must_use]
+ pub fn primary_texture(&self) -> Option<ResourceName> {
+ self.phases.iter().find_map(|phase| {
+ let bytes = bounded_cstr(&phase.texture_raw);
+ (!bytes.is_empty()).then(|| ResourceName(bytes.to_vec()))
+ })
+ }
+
+ /// Returns every non-empty texture name from material phases in disk order.
+ #[must_use]
+ pub fn texture_requests(&self) -> Vec<ResourceName> {
+ self.phases
+ .iter()
+ .filter_map(|phase| {
+ let bytes = bounded_cstr(&phase.texture_raw);
+ (!bytes.is_empty()).then(|| ResourceName(bytes.to_vec()))
+ })
+ .collect()
+ }
+}
+
+fn select_phase_index(mode: MaterialTimelineMode, frame: u32, phase_count: usize) -> usize {
+ let count = u32::try_from(phase_count).unwrap_or(u32::MAX).max(1);
+ let index = match mode {
+ MaterialTimelineMode::OneShot | MaterialTimelineMode::Clamp => frame.min(count - 1),
+ MaterialTimelineMode::Loop => frame % count,
+ MaterialTimelineMode::PingPong => {
+ if count == 1 {
+ 0
+ } else {
+ let period = count.saturating_mul(2).saturating_sub(2);
+ let local = frame % period;
+ if local < count {
+ local
+ } else {
+ period - local
+ }
+ }
+ }
+ };
+ usize::try_from(index).unwrap_or(phase_count.saturating_sub(1))
+}
+
+fn material_random_offset(seed: u64, phase_count: usize) -> u32 {
+ let count = u64::try_from(phase_count).unwrap_or(u64::MAX).max(1);
+ let mut state = 0xa076_1d64_78bd_642f_u64 ^ seed;
+ for byte in b"material" {
+ state ^= u64::from(*byte);
+ state = splitmix64(state);
+ }
+ u32::try_from(splitmix64(state) % count).unwrap_or(0)
+}
+
+fn splitmix64(mut value: u64) -> u64 {
+ value = value.wrapping_add(0x9e37_79b9_7f4a_7c15);
+ let mut mixed = value;
+ mixed = (mixed ^ (mixed >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
+ mixed = (mixed ^ (mixed >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
+ mixed ^ (mixed >> 31)
+}
+
+fn load_material_entry(
+ repository: &dyn ResourceRepository,
+ archive: fparkan_resource::ArchiveId,
+ name: &ResourceName,
+ fallback: MaterialFallback,
+) -> Result<Option<ResolvedMaterial>, MaterialError> {
+ let Some(handle) = repository.find(archive, name)? else {
+ return Ok(None);
+ };
+ let info = repository.entry_info(handle)?;
+ if info.key.type_id != Some(MAT0_KIND) {
+ return Ok(None);
+ }
+ let bytes = repository.read(handle)?.into_owned();
+ let document = decode_mat0(&bytes, info.attr2)?;
+ Ok(Some(ResolvedMaterial {
+ name: info.key.name,
+ fallback,
+ document,
+ }))
+}
+
+fn parse_lightmaps(lines: &[&str]) -> Result<Vec<LightmapEntry>, MaterialError> {
+ if lines.is_empty() || lines.iter().all(|line| line.trim().is_empty()) {
+ return Ok(Vec::new());
+ }
+ let mut cursor = 0usize;
+ if !lines[cursor].trim().is_empty() {
+ return Err(MaterialError::MissingLightmapSeparator);
+ }
+ cursor += 1;
+ if lines.get(cursor).map(|line| line.trim()) != Some("LIGHTMAPS") {
+ return Err(MaterialError::MissingLightmapMarker);
+ }
+ cursor += 1;
+ let count_line = lines
+ .get(cursor)
+ .ok_or_else(|| MaterialError::InvalidLightmapCount(String::new()))?;
+ let count = parse_count(count_line)
+ .map_err(|_| MaterialError::InvalidLightmapCount((*count_line).to_string()))?;
+ cursor += 1;
+ let mut lightmaps = Vec::with_capacity(count);
+ for index in 0..count {
+ let line = lines
+ .get(cursor)
+ .ok_or(MaterialError::MissingLightmapRow { index, count })?;
+ let (legacy_id, lightmap) =
+ parse_pair(line).ok_or_else(|| MaterialError::InvalidLightmapRow {
+ index,
+ line: (*line).to_string(),
+ })?;
+ lightmaps.push(LightmapEntry {
+ legacy_id,
+ lightmap,
+ });
+ cursor += 1;
+ }
+ if lines[cursor..].iter().any(|line| !line.trim().is_empty()) {
+ return Err(MaterialError::InvalidLightmapRow {
+ index: count,
+ line: lines[cursor..].join("\n"),
+ });
+ }
+ Ok(lightmaps)
+}
+
+fn parse_pair(line: &str) -> Option<(LegacyText, ResourceName)> {
+ let mut parts = line.split_whitespace();
+ let legacy = parts.next()?;
+ let resource = parts.next()?;
+ Some((
+ LegacyText(legacy.to_string()),
+ ResourceName(resource.as_bytes().to_vec()),
+ ))
+}
+
+fn parse_count(line: &str) -> Result<usize, std::num::ParseIntError> {
+ line.trim().parse::<usize>()
+}
+
+fn mat0_prefix_len(version: u32) -> usize {
+ let mut len = 0usize;
+ if version >= 2 {
+ len += 2;
+ }
+ if version >= 3 {
+ len += 4;
+ }
+ if version >= 4 {
+ len += 4;
+ }
+ len
+}
+
+fn parse_animation_blocks(
+ bytes: &[u8],
+ block_count: u16,
+) -> Result<Vec<MaterialAnimationBlock>, MaterialError> {
+ if block_count == 0 && bytes.is_empty() {
+ return Ok(Vec::new());
+ }
+ let mut cursor = 0usize;
+ let mut out = Vec::with_capacity(usize::from(block_count));
+ for _ in 0..block_count {
+ let start = cursor;
+ let header_end = cursor
+ .checked_add(6)
+ .ok_or(MaterialError::Mat0OutOfBounds)?;
+ let header = bytes
+ .get(cursor..header_end)
+ .ok_or(MaterialError::Mat0OutOfBounds)?;
+ let header_raw = u32::from_le_bytes(
+ header[0..4]
+ .try_into()
+ .map_err(|_| MaterialError::Mat0OutOfBounds)?,
+ );
+ let key_count = usize::from(u16::from_le_bytes([header[4], header[5]]));
+ cursor = header_end;
+ let keys_bytes = key_count
+ .checked_mul(6)
+ .ok_or(MaterialError::Mat0OutOfBounds)?;
+ let keys_end = cursor
+ .checked_add(keys_bytes)
+ .ok_or(MaterialError::Mat0OutOfBounds)?;
+ if keys_end > bytes.len() {
+ return Err(MaterialError::Mat0OutOfBounds);
+ }
+ let mut keys = Vec::with_capacity(key_count);
+ for chunk in bytes[cursor..keys_end].chunks_exact(6) {
+ keys.push(MaterialKey {
+ k0: u16::from_le_bytes([chunk[0], chunk[1]]),
+ k1: u16::from_le_bytes([chunk[2], chunk[3]]),
+ k2: u16::from_le_bytes([chunk[4], chunk[5]]),
+ });
+ }
+ cursor = keys_end;
+ out.push(MaterialAnimationBlock {
+ header_raw,
+ keys,
+ bytes: bytes[start..cursor].to_vec(),
+ });
+ }
+ if cursor != bytes.len() {
+ return Err(MaterialError::Mat0TrailingBytes {
+ expected: cursor,
+ actual: bytes.len(),
+ });
+ }
+ Ok(out)
+}
+
+fn bounded_cstr(bytes: &[u8]) -> &[u8] {
+ let len = bytes
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(bytes.len());
+ trim_ascii(&bytes[..len])
+}
+
+fn trim_ascii(bytes: &[u8]) -> &[u8] {
+ let mut start = 0usize;
+ let mut end = bytes.len();
+ while start < end && bytes[start].is_ascii_whitespace() {
+ start += 1;
+ }
+ while end > start && bytes[end - 1].is_ascii_whitespace() {
+ end -= 1;
+ }
+ &bytes[start..end]
+}
+
+fn decode_cp1251(bytes: &[u8]) -> String {
+ let (decoded, _, _) = WINDOWS_1251.decode(bytes);
+ decoded.into_owned()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_nres::ReadProfile;
+ use fparkan_resource::CachedResourceRepository;
+ use fparkan_vfs::MemoryVfs;
+ use std::path::{Path, PathBuf};
+ use std::sync::Arc;
+
+ #[test]
+ fn wear_preserves_legacy_id_but_selects_by_index() {
+ let table = decode_wear(b"2\r\n100 MAT_A\r\n5 MAT_B\r\n").expect("wear");
+
+ assert_eq!(table.entries[0].legacy_id, LegacyText("100".to_string()));
+ assert_eq!(table.entries[1].material.0, b"MAT_B");
+ }
+
+ #[test]
+ fn wear_requires_declared_rows() {
+ let err = decode_wear(b"2\n0 ONLY_ONE\n").expect_err("missing row");
+ assert!(matches!(err, MaterialError::MissingWearRow { .. }));
+ }
+
+ #[test]
+ fn wear_requires_blank_separator_before_lightmaps() {
+ let err = decode_wear(b"1\n0 MAT\nLIGHTMAPS\n1\n0 LM\n").expect_err("separator");
+ assert!(matches!(err, MaterialError::MissingLightmapSeparator));
+ }
+
+ #[test]
+ fn wear_parses_lightmaps() {
+ let table = decode_wear(b"1\n0 MAT\n\nLIGHTMAPS\n1\n0 LM_A\n").expect("wear");
+ assert_eq!(table.lightmaps.len(), 1);
+ assert_eq!(table.lightmaps[0].lightmap.0, b"LM_A");
+ }
+
+ #[test]
+ fn mat0_version_prefix_and_primary_texture() {
+ let mut bytes = vec![0; 4 + 10 + 68];
+ bytes[0..2].copy_from_slice(&2_u16.to_le_bytes());
+ bytes[4 + 10 + 18..4 + 10 + 25].copy_from_slice(b"TEXMAIN");
+ bytes[4 + 10 + 34 + 18..4 + 10 + 34 + 24].copy_from_slice(b"TEXALT");
+ let document = decode_mat0(&bytes, 4).expect("mat0");
+
+ assert_eq!(document.prefix.len(), 10);
+ assert_eq!(document.phases.len(), 2);
+ assert_eq!(document.primary_texture().expect("texture").0, b"TEXMAIN");
+ let textures = document.texture_requests();
+ assert_eq!(textures.len(), 2);
+ assert_eq!(textures[0].0, b"TEXMAIN");
+ assert_eq!(textures[1].0, b"TEXALT");
+ }
+
+ #[test]
+ fn mat0_accepts_zero_phase_material() {
+ let document = decode_mat0(&[0, 0, 0, 0], 0).expect("zero phase");
+
+ assert!(document.phases.is_empty());
+ assert!(document.texture_requests().is_empty());
+ }
+
+ #[test]
+ fn mat0_phase34_exact_framing_and_full_texture_name() {
+ let mut bytes = vec![0; 4 + 34];
+ bytes[0..2].copy_from_slice(&1_u16.to_le_bytes());
+ bytes[4..22].copy_from_slice(&[0xAB; 18]);
+ bytes[22..38].copy_from_slice(b"1234567890ABCDEF");
+
+ let document = decode_mat0(&bytes, 0).expect("mat0");
+
+ assert_eq!(document.phases.len(), 1);
+ assert_eq!(document.phases[0].parameters, [0xAB; 18]);
+ assert_eq!(
+ document.primary_texture().expect("texture").0,
+ b"1234567890ABCDEF"
+ );
+ }
+
+ #[test]
+ fn mat0_animation_block_has_no_implicit_padding() {
+ let mut bytes = vec![0, 0, 1, 0];
+ bytes.extend_from_slice(&0xAABB_CCDD_u32.to_le_bytes());
+ bytes.extend_from_slice(&1_u16.to_le_bytes());
+ bytes.extend_from_slice(&7_u16.to_le_bytes());
+ bytes.extend_from_slice(&8_u16.to_le_bytes());
+ bytes.extend_from_slice(&9_u16.to_le_bytes());
+
+ let document = decode_mat0(&bytes, 0).expect("animation block");
+
+ assert_eq!(document.animation_blocks.len(), 1);
+ assert_eq!(document.animation_blocks[0].header_raw, 0xAABB_CCDD);
+ assert_eq!(
+ document.animation_blocks[0].keys,
+ vec![MaterialKey {
+ k0: 7,
+ k1: 8,
+ k2: 9,
+ }]
+ );
+ assert_eq!(document.animation_blocks[0].bytes.len(), 12);
+ }
+
+ #[test]
+ fn mat0_rejects_animation_block_count_limit() {
+ let err = decode_mat0(&[0, 0, 20, 0], 0).expect_err("animation block count");
+
+ assert!(matches!(
+ err,
+ MaterialError::InvalidPhaseCount { count: 20 }
+ ));
+ }
+
+ #[test]
+ fn mat0_rejects_trailing_bytes() {
+ let bytes = vec![0, 0, 0, 0, 1];
+ let err = decode_mat0(&bytes, 0).expect_err("trailing byte");
+ assert!(matches!(err, MaterialError::Mat0TrailingBytes { .. }));
+ }
+
+ #[test]
+ fn resolve_material_uses_exact_match() {
+ let repo = material_repo(&[
+ material_entry(b"MAT_A", &mat0_with_texture(b"TEX_A")),
+ material_entry(b"DEFAULT", &mat0_with_texture(b"TEX_DEFAULT")),
+ ]);
+ let table = decode_wear(b"1\n0 MAT_A\n").expect("wear");
+
+ let resolved = resolve_material(&repo, &table, 0).expect("resolved");
+
+ assert_eq!(resolved.name.0, b"MAT_A");
+ assert_eq!(resolved.fallback, MaterialFallback::Exact);
+ assert_eq!(
+ resolved.document.primary_texture().expect("texture").0,
+ b"TEX_A"
+ );
+ }
+
+ #[test]
+ fn resolve_material_falls_back_to_default() {
+ let repo = material_repo(&[material_entry(
+ b"DEFAULT",
+ &mat0_with_texture(b"TEX_DEFAULT"),
+ )]);
+ let table = decode_wear(b"1\n0 MISSING\n").expect("wear");
+
+ let resolved = resolve_material(&repo, &table, 0).expect("resolved");
+
+ assert_eq!(resolved.name.0, b"DEFAULT");
+ assert_eq!(resolved.fallback, MaterialFallback::Default);
+ }
+
+ #[test]
+ fn resolve_material_uses_first_entry_only_after_missing_default() {
+ let repo = material_repo(&[material_entry(b"MAT_FIRST", &mat0_with_texture(b"TEX_A"))]);
+ let table = decode_wear(b"2\n0 MAT_FIRST\n1 MISSING\n").expect("wear");
+
+ let resolved = resolve_material(&repo, &table, 1).expect("resolved");
+
+ assert_eq!(resolved.name.0, b"MAT_FIRST");
+ assert_eq!(resolved.fallback, MaterialFallback::FirstEntry);
+ }
+
+ #[test]
+ fn resolve_material_empty_texture_means_untextured() {
+ let repo = material_repo(&[material_entry(b"MAT_EMPTY", &mat0_with_texture(b""))]);
+ let table = decode_wear(b"1\n0 MAT_EMPTY\n").expect("wear");
+
+ let resolved = resolve_material(&repo, &table, 0).expect("resolved");
+
+ assert!(resolved.document.primary_texture().is_none());
+ assert!(resolved.document.texture_requests().is_empty());
+ }
+
+ #[test]
+ fn resolve_material_without_lightmap_keeps_lightmap_absent() {
+ let repo = material_repo(&[material_entry(b"MAT_A", &mat0_with_texture(b"TEX_A"))]);
+ let table = decode_wear(b"1\n0 MAT_A\n").expect("wear");
+
+ let resolved = resolve_material(&repo, &table, 0).expect("resolved");
+
+ assert_eq!(resolved.fallback, MaterialFallback::Exact);
+ assert!(table.lightmaps.is_empty());
+ }
+
+ #[test]
+ fn material_modes_zero_to_three_choose_stable_phases() {
+ let document =
+ decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0");
+
+ let cases = [
+ (MaterialTimelineMode::OneShot, 9, 2),
+ (MaterialTimelineMode::Clamp, 9, 2),
+ (MaterialTimelineMode::Loop, 4, 1),
+ (MaterialTimelineMode::PingPong, 3, 1),
+ ];
+ for (mode, frame, expected_phase) in cases {
+ let sample = sample_material_phase(
+ &document,
+ MaterialTimelineProfile {
+ mode,
+ random_offset: false,
+ },
+ frame,
+ 0,
+ )
+ .expect("sample");
+ assert_eq!(sample.phase_index, expected_phase, "{mode:?}");
+ }
+ }
+
+ #[test]
+ fn material_exact_key_boundary_selects_exact_phase() {
+ let document =
+ decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0");
+
+ let sample = sample_material_phase(
+ &document,
+ MaterialTimelineProfile {
+ mode: MaterialTimelineMode::Clamp,
+ random_offset: false,
+ },
+ 1,
+ 0,
+ )
+ .expect("sample");
+
+ assert_eq!(sample.phase_index, 1);
+ assert_eq!(&sample.texture_raw[..1], b"B");
+ }
+
+ #[test]
+ fn material_interpolation_mask_affects_only_selected_fields() {
+ let mut left = [10_u8; 18];
+ let mut right = [20_u8; 18];
+ left[1] = 100;
+ right[1] = 200;
+
+ let out = interpolate_parameter_bytes(left, right, 0b101, 0.5);
+
+ assert_eq!(out[0], 15);
+ assert_eq!(out[1], 100);
+ assert_eq!(out[2], 15);
+ assert_eq!(out[3], 10);
+ }
+
+ #[test]
+ fn material_timeline_profile_cases_are_evidence_labeled() {
+ let document =
+ decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0");
+
+ assert_eq!(
+ material_phase_capture(
+ &document,
+ MaterialTimelineProfile {
+ mode: MaterialTimelineMode::OneShot,
+ random_offset: false,
+ },
+ &[0, 1, 4],
+ 0,
+ )
+ .expect("one-shot"),
+ b"M,0,0,0\nM,1,1,1\nM,4,4,2\n"
+ );
+ assert_eq!(
+ material_phase_capture(
+ &document,
+ MaterialTimelineProfile {
+ mode: MaterialTimelineMode::Loop,
+ random_offset: false,
+ },
+ &[0, 1, 4],
+ 0,
+ )
+ .expect("loop"),
+ b"M,0,0,0\nM,1,1,1\nM,4,4,1\n"
+ );
+ assert_eq!(
+ material_phase_capture(
+ &document,
+ MaterialTimelineProfile {
+ mode: MaterialTimelineMode::PingPong,
+ random_offset: false,
+ },
+ &[0, 1, 3],
+ 0,
+ )
+ .expect("ping-pong"),
+ b"M,0,0,0\nM,1,1,1\nM,3,3,1\n"
+ );
+ }
+
+ #[test]
+ fn material_random_offset_uses_material_stream_only() {
+ let document =
+ decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0");
+ let profile = MaterialTimelineProfile {
+ mode: MaterialTimelineMode::Loop,
+ random_offset: true,
+ };
+ let before = material_phase_capture(&document, profile, &[0, 1, 2], 99).expect("capture");
+ let mut unrelated = 0x5555_u64;
+ for _ in 0..16 {
+ unrelated = unrelated.rotate_left(11).wrapping_mul(31);
+ }
+
+ assert_ne!(unrelated, 0);
+ assert_eq!(
+ material_phase_capture(&document, profile, &[0, 1, 2], 99).expect("capture"),
+ before
+ );
+ }
+
+ #[test]
+ fn material_same_seed_and_timeline_produces_same_phase_capture() {
+ let document =
+ decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0");
+ let profile = MaterialTimelineProfile {
+ mode: MaterialTimelineMode::Loop,
+ random_offset: true,
+ };
+
+ assert_eq!(
+ material_phase_capture(&document, profile, &[0, 4, 7], 123).expect("first"),
+ material_phase_capture(&document, profile, &[0, 4, 7], 123).expect("second")
+ );
+ }
+
+ #[test]
+ fn licensed_corpus_mat0_and_wear_parse() {
+ for (corpus, expected_mat0, expected_archive_wear, expected_standalone_wear) in [
+ ("IS", 905_usize, 439_usize, 95_usize),
+ ("IS2", 1127_usize, 515_usize, 95_usize),
+ ] {
+ let Some(root) = corpus_root(corpus) else {
+ continue;
+ };
+ let mut mat0_count = 0usize;
+ let mut archive_wear_count = 0usize;
+ let mut standalone_wear_count = 0usize;
+ for path in files_under(&root) {
+ let Ok(bytes) = std::fs::read(&path) else {
+ continue;
+ };
+ if path
+ .extension()
+ .is_some_and(|extension| extension.eq_ignore_ascii_case("wea"))
+ {
+ decode_wear(&bytes)
+ .unwrap_or_else(|err| panic!("{corpus} standalone {path:?}: {err}"));
+ standalone_wear_count += 1;
+ continue;
+ }
+ let Ok(archive) = fparkan_nres::decode(
+ Arc::from(bytes.into_boxed_slice()),
+ ReadProfile::Compatible,
+ ) else {
+ continue;
+ };
+ for entry in archive.entries() {
+ let payload = archive.payload(entry.id()).expect("payload");
+ match entry.meta().type_id {
+ MAT0_KIND => {
+ decode_mat0(payload, entry.meta().attr2).unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
+ });
+ mat0_count += 1;
+ }
+ WEAR_KIND => {
+ decode_wear(payload).unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
+ });
+ archive_wear_count += 1;
+ }
+ _ => {}
+ }
+ }
+ }
+ assert_eq!(mat0_count, expected_mat0, "{corpus} MAT0 count");
+ assert_eq!(
+ archive_wear_count, expected_archive_wear,
+ "{corpus} archive WEAR count"
+ );
+ assert_eq!(
+ standalone_wear_count, expected_standalone_wear,
+ "{corpus} standalone WEAR count"
+ );
+ }
+ }
+
+ fn corpus_root(name: &str) -> Option<PathBuf> {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("testdata")
+ .join(name);
+ root.is_dir().then_some(root)
+ }
+
+ fn files_under(root: &Path) -> Vec<PathBuf> {
+ let mut out = Vec::new();
+ let mut stack = vec![root.to_path_buf()];
+ while let Some(path) = stack.pop() {
+ let Ok(read_dir) = std::fs::read_dir(path) else {
+ continue;
+ };
+ for entry in read_dir.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ stack.push(path);
+ } else {
+ out.push(path);
+ }
+ }
+ }
+ out.sort();
+ out
+ }
+
+ struct TestMaterialEntry<'a> {
+ name: &'a [u8],
+ type_id: u32,
+ attr2: u32,
+ payload: &'a [u8],
+ }
+
+ fn material_entry<'a>(name: &'a [u8], payload: &'a [u8]) -> TestMaterialEntry<'a> {
+ TestMaterialEntry {
+ name,
+ type_id: MAT0_KIND,
+ attr2: 0,
+ payload,
+ }
+ }
+
+ fn material_repo(entries: &[TestMaterialEntry<'_>]) -> CachedResourceRepository {
+ let path = archive_path(b"material.lib").expect("material path");
+ let mut vfs = MemoryVfs::default();
+ vfs.insert(
+ path,
+ Arc::from(build_material_nres(entries).into_boxed_slice()),
+ );
+ CachedResourceRepository::new(Arc::new(vfs))
+ }
+
+ fn mat0_with_texture(texture: &[u8]) -> Vec<u8> {
+ let mut bytes = vec![0; 4 + 34];
+ bytes[0..2].copy_from_slice(&1_u16.to_le_bytes());
+ let len = texture.len().min(16);
+ bytes[22..22 + len].copy_from_slice(&texture[..len]);
+ bytes
+ }
+
+ fn mat0_with_phase_textures(textures: &[&[u8]]) -> Vec<u8> {
+ let mut bytes = vec![0; 4 + textures.len() * 34];
+ bytes[0..2].copy_from_slice(
+ &u16::try_from(textures.len())
+ .expect("phase count")
+ .to_le_bytes(),
+ );
+ for (index, texture) in textures.iter().enumerate() {
+ let offset = 4 + index * 34;
+ bytes[offset] = u8::try_from(index).expect("index");
+ let len = texture.len().min(16);
+ bytes[offset + 18..offset + 18 + len].copy_from_slice(&texture[..len]);
+ }
+ bytes
+ }
+
+ fn build_material_nres(entries: &[TestMaterialEntry<'_>]) -> Vec<u8> {
+ let mut out = vec![0; 16];
+ let mut offsets = Vec::with_capacity(entries.len());
+ for entry in entries {
+ offsets.push(u32::try_from(out.len()).expect("offset"));
+ out.extend_from_slice(entry.payload);
+ let padding = (8 - (out.len() % 8)) % 8;
+ out.resize(out.len() + padding, 0);
+ }
+ let mut order: Vec<usize> = (0..entries.len()).collect();
+ order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name));
+ for (idx, entry) in entries.iter().enumerate() {
+ push_u32(&mut out, entry.type_id);
+ push_u32(&mut out, 0);
+ push_u32(&mut out, entry.attr2);
+ push_u32(
+ &mut out,
+ u32::try_from(entry.payload.len()).expect("payload"),
+ );
+ push_u32(&mut out, 0);
+ let mut name_raw = [0; 36];
+ let len = name_raw.len().saturating_sub(1).min(entry.name.len());
+ name_raw[..len].copy_from_slice(&entry.name[..len]);
+ out.extend_from_slice(&name_raw);
+ push_u32(&mut out, offsets[idx]);
+ push_u32(&mut out, u32::try_from(order[idx]).expect("sort index"));
+ }
+ out[0..4].copy_from_slice(b"NRes");
+ out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
+ out[8..12].copy_from_slice(&u32::try_from(entries.len()).expect("count").to_le_bytes());
+ let total_size = u32::try_from(out.len()).expect("total size");
+ out[12..16].copy_from_slice(&total_size.to_le_bytes());
+ out
+ }
+
+ fn push_u32(out: &mut Vec<u8>, value: u32) {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+}
diff --git a/crates/fparkan-mission-format/Cargo.toml b/crates/fparkan-mission-format/Cargo.toml
new file mode 100644
index 0000000..52103f8
--- /dev/null
+++ b/crates/fparkan-mission-format/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "fparkan-mission-format"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+encoding_rs = "0.8"
+fparkan-binary = { path = "../fparkan-binary" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-mission-format/src/lib.rs b/crates/fparkan-mission-format/src/lib.rs
new file mode 100644
index 0000000..edbe908
--- /dev/null
+++ b/crates/fparkan-mission-format/src/lib.rs
@@ -0,0 +1,1172 @@
+#![forbid(unsafe_code)]
+//! Count-driven mission format primitives.
+
+use encoding_rs::WINDOWS_1251;
+use fparkan_binary::{checked_count_bytes, read_lp_bytes, Cursor, DecodeError};
+use std::sync::Arc;
+
+const FORMAT_VERSION: u32 = 1;
+const CLAN_SECTION_VERSION: u32 = 6;
+const OBJECT_SECTION_VERSION: u32 = 10;
+const PROPERTY_SCHEMA_VERSION: u32 = 1;
+const EXTRA_SECTION_VERSION: u32 = 1;
+const OBJECT_CLASS_OR_FLAGS: u32 = 0x8000_0002;
+const MAX_PATHS: u32 = 16_384;
+const MAX_POINTS: u32 = 1_000_000;
+const MAX_CLANS: u32 = 16_384;
+const MAX_RELATIONS: u32 = 65_536;
+const MAX_SPATIAL_GROUPS: u32 = 65_536;
+const MAX_SPATIAL_RECORDS: u32 = 1_000_000;
+const MAX_OBJECTS: u32 = 1_000_000;
+const MAX_PROPERTIES: u32 = 1_000_000;
+const MAX_EXTRAS: u32 = 1_000_000;
+const MAX_STRING_BYTES: u32 = 64 * 1024;
+
+/// Mission document.
+#[derive(Clone, Debug, PartialEq)]
+pub struct MissionDocument {
+ /// Top-level format version.
+ pub format_version: u32,
+ /// Clan section version.
+ pub clan_section_version: u32,
+ /// Object section version.
+ pub object_section_version: u32,
+ /// Extra section version.
+ pub extra_section_version: u32,
+ /// Version words preserved for compact compatibility checks.
+ pub versions: Vec<u32>,
+ /// Paths.
+ pub paths: Vec<MissionPath>,
+ /// Clans.
+ pub clans: Vec<ClanRecord>,
+ /// Placed objects.
+ pub objects: Vec<PlacedObject>,
+ /// Landscape path.
+ pub land_path: LpString,
+ /// Mission flag.
+ pub mission_flag: u32,
+ /// Raw mission description.
+ pub description_raw: LpString,
+ /// Extras.
+ pub extras: Vec<ExtraRecord28>,
+ /// Original bytes.
+ pub raw: Arc<[u8]>,
+}
+
+/// Length-prefixed string with decoded CP1251 helper text.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct LpString {
+ /// Raw bytes from the file.
+ pub raw: Vec<u8>,
+ /// Decoded text.
+ pub decoded: String,
+}
+
+/// Mission path.
+#[derive(Clone, Debug, PartialEq)]
+pub struct MissionPath {
+ /// Path id.
+ pub id: i32,
+ /// Points.
+ pub points: Vec<[f32; 3]>,
+}
+
+/// Clan record.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ClanRecord {
+ /// Clan name.
+ pub name: LpString,
+ /// Raw id, usually `-1` in checked corpora.
+ pub raw_id: i32,
+ /// Two-dimensional clan anchor.
+ pub anchor: [f32; 2],
+ /// Mode selector.
+ pub mode: u32,
+ /// Mode-dependent payload.
+ pub body: ClanBody,
+ /// Relation table.
+ pub relations: Vec<ClanRelation>,
+}
+
+/// Clan mode-dependent body.
+#[derive(Clone, Debug, PartialEq)]
+pub enum ClanBody {
+ /// Standard modes 1..=3.
+ Standard {
+ /// First tagged resource.
+ first_resource: TaggedResource,
+ /// Second tagged resource.
+ second_resource: TaggedResource,
+ },
+ /// Mode 0 spatial body.
+ Spatial {
+ /// First untagged resource.
+ first_resource: LpString,
+ /// Spatial groups.
+ spatial_groups: Vec<SpatialGroup>,
+ /// Second tagged resource.
+ second_resource: TaggedResource,
+ },
+}
+
+/// Tagged clan resource reference.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TaggedResource {
+ /// Resource path.
+ pub path: LpString,
+ /// Raw tag.
+ pub tag: i32,
+}
+
+/// Mode 0 spatial group.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SpatialGroup {
+ /// Raw spatial records, five floats each.
+ pub records: Vec<[f32; 5]>,
+}
+
+/// Clan relation entry.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ClanRelation {
+ /// Other clan name.
+ pub other_clan_name: LpString,
+ /// Raw relation value.
+ pub relation_value: i32,
+}
+
+/// Placed object.
+#[derive(Clone, Debug, PartialEq)]
+pub struct PlacedObject {
+ /// Raw object kind.
+ pub raw_kind: u32,
+ /// Class/flags word.
+ pub class_or_flags: u32,
+ /// Resource reference.
+ pub resource_name: LpString,
+ /// Raw resource bytes retained for older callers.
+ pub resource_raw: Vec<u8>,
+ /// Raw word after resource.
+ pub raw_after_resource: u32,
+ /// Raw identity/clan word.
+ pub identity_or_clan_raw: u32,
+ /// Position.
+ pub position: [f32; 3],
+ /// Orientation.
+ pub orientation: [f32; 3],
+ /// Scale.
+ pub scale: [f32; 3],
+ /// Instance name.
+ pub instance_name: LpString,
+ /// Raw word after instance name.
+ pub raw_after_name: u32,
+ /// First link word.
+ pub link0: i32,
+ /// Second link word.
+ pub link1: i32,
+ /// Property schema version.
+ pub property_schema_version: u32,
+ /// Ordered properties.
+ pub properties: Vec<OrderedProperty>,
+}
+
+/// Ordered property.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct OrderedProperty {
+ /// Raw words.
+ pub raw_value: [u32; 4],
+ /// Property name.
+ pub name: LpString,
+ /// Raw name bytes retained for older callers.
+ pub name_raw: Vec<u8>,
+}
+
+/// Mission epilogue marker.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct MissionEpilogue;
+
+/// 28-byte extra record.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ExtraRecord28 {
+ /// Raw 28-byte record.
+ pub raw: [u8; 28],
+ /// Position.
+ pub position: [f32; 3],
+ /// Preserved trailing words.
+ pub raw_words: [u32; 4],
+}
+
+/// TMA profile.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum TmaProfile {
+ /// Strict profile.
+ Strict,
+}
+
+/// Mission error.
+#[derive(Debug)]
+pub enum MissionError {
+ /// Decode error.
+ Decode(DecodeError),
+ /// Unsupported branch.
+ Unsupported(&'static str),
+ /// Invalid section version.
+ InvalidVersion {
+ /// Section name.
+ section: &'static str,
+ /// Expected version.
+ expected: u32,
+ /// Observed version.
+ got: u32,
+ },
+ /// Unknown clan mode.
+ UnknownClanMode {
+ /// Clan index.
+ clan: usize,
+ /// Observed mode.
+ mode: u32,
+ },
+ /// Invalid placed object flags.
+ InvalidObjectFlags {
+ /// Object index.
+ object: usize,
+ /// Observed flags.
+ flags: u32,
+ },
+ /// Non-finite transform field.
+ NonFiniteTransform {
+ /// Object index.
+ object: usize,
+ },
+}
+
+impl From<DecodeError> for MissionError {
+ fn from(value: DecodeError) -> Self {
+ Self::Decode(value)
+ }
+}
+
+impl std::fmt::Display for MissionError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Decode(source) => write!(f, "{source}"),
+ Self::Unsupported(reason) => write!(f, "unsupported TMA branch: {reason}"),
+ Self::InvalidVersion {
+ section,
+ expected,
+ got,
+ } => write!(
+ f,
+ "invalid TMA {section} version {got}, expected {expected}"
+ ),
+ Self::UnknownClanMode { clan, mode } => {
+ write!(f, "unknown TMA clan mode {mode} at clan {clan}")
+ }
+ Self::InvalidObjectFlags { object, flags } => {
+ write!(f, "invalid TMA object {object} flags {flags:#x}")
+ }
+ Self::NonFiniteTransform { object } => {
+ write!(f, "TMA object {object} contains non-finite transform")
+ }
+ }
+ }
+}
+
+impl std::error::Error for MissionError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Decode(source) => Some(source),
+ Self::Unsupported(_)
+ | Self::InvalidVersion { .. }
+ | Self::UnknownClanMode { .. }
+ | Self::InvalidObjectFlags { .. }
+ | Self::NonFiniteTransform { .. } => None,
+ }
+ }
+}
+
+/// Decodes an exact, count-driven TMA document.
+///
+/// # Errors
+///
+/// Returns [`MissionError`] when a count/length is out of bounds, a known
+/// section version does not match strict expectations, a mode-dependent branch
+/// is unknown, object transforms are invalid, or the cursor does not end at EOF.
+pub fn decode_tma(bytes: Arc<[u8]>, profile: TmaProfile) -> Result<MissionDocument, MissionError> {
+ let mut cursor = Cursor::new(&bytes);
+ let format_version = cursor.read_u32_le()?;
+ require_version("format", format_version, FORMAT_VERSION, profile)?;
+
+ let paths = parse_paths(&mut cursor)?;
+
+ let clan_section_version = cursor.read_u32_le()?;
+ require_version(
+ "clan section",
+ clan_section_version,
+ CLAN_SECTION_VERSION,
+ profile,
+ )?;
+ let clans = parse_clans(&mut cursor)?;
+
+ let object_section_version = cursor.read_u32_le()?;
+ require_version(
+ "object section",
+ object_section_version,
+ OBJECT_SECTION_VERSION,
+ profile,
+ )?;
+ let objects = parse_objects(&mut cursor, profile)?;
+
+ let land_path = read_lp_string(&mut cursor)?;
+ let mission_flag = cursor.read_u32_le()?;
+ let description_raw = read_lp_string(&mut cursor)?;
+
+ let extra_section_version = cursor.read_u32_le()?;
+ require_version(
+ "extra section",
+ extra_section_version,
+ EXTRA_SECTION_VERSION,
+ profile,
+ )?;
+ let extras = parse_extras(&mut cursor)?;
+ cursor.require_eof()?;
+
+ Ok(MissionDocument {
+ format_version,
+ clan_section_version,
+ object_section_version,
+ extra_section_version,
+ versions: vec![
+ format_version,
+ clan_section_version,
+ object_section_version,
+ extra_section_version,
+ ],
+ paths,
+ clans,
+ objects,
+ land_path,
+ mission_flag,
+ description_raw,
+ extras,
+ raw: bytes,
+ })
+}
+
+/// Decodes only the TMA landscape path needed to load terrain before the full
+/// mission document is materialized.
+///
+/// # Errors
+///
+/// Returns [`MissionError`] when any section preceding the landscape path is
+/// malformed or unsupported.
+pub fn decode_tma_land_path(bytes: &[u8], profile: TmaProfile) -> Result<LpString, MissionError> {
+ let mut cursor = Cursor::new(bytes);
+ let format_version = cursor.read_u32_le()?;
+ require_version("format", format_version, FORMAT_VERSION, profile)?;
+ let _paths = parse_paths(&mut cursor)?;
+
+ let clan_section_version = cursor.read_u32_le()?;
+ require_version(
+ "clan section",
+ clan_section_version,
+ CLAN_SECTION_VERSION,
+ profile,
+ )?;
+ let _clans = parse_clans(&mut cursor)?;
+
+ let object_section_version = cursor.read_u32_le()?;
+ require_version(
+ "object section",
+ object_section_version,
+ OBJECT_SECTION_VERSION,
+ profile,
+ )?;
+ let _objects = parse_objects(&mut cursor, profile)?;
+ read_lp_string(&mut cursor)
+}
+
+fn require_version(
+ section: &'static str,
+ got: u32,
+ expected: u32,
+ _profile: TmaProfile,
+) -> Result<(), MissionError> {
+ if got == expected {
+ Ok(())
+ } else {
+ Err(MissionError::InvalidVersion {
+ section,
+ expected,
+ got,
+ })
+ }
+}
+
+fn parse_paths(cursor: &mut Cursor<'_>) -> Result<Vec<MissionPath>, MissionError> {
+ let count = checked_count(cursor.read_u32_le()?, MAX_PATHS)?;
+ let mut paths = Vec::with_capacity(count);
+ for _ in 0..count {
+ let id = cursor.read_i32_le()?;
+ let point_count = cursor.read_u32_le()?;
+ checked_count_bytes(u64::from(point_count), 12, cursor.remaining() as u64)?;
+ let point_count = checked_count(point_count, MAX_POINTS)?;
+ let mut points = Vec::with_capacity(point_count);
+ for _ in 0..point_count {
+ points.push(read_vec3(cursor)?);
+ }
+ paths.push(MissionPath { id, points });
+ }
+ Ok(paths)
+}
+
+fn parse_clans(cursor: &mut Cursor<'_>) -> Result<Vec<ClanRecord>, MissionError> {
+ let count = checked_count(cursor.read_u32_le()?, MAX_CLANS)?;
+ let mut clans = Vec::with_capacity(count);
+ for clan_index in 0..count {
+ let name = read_lp_string(cursor)?;
+ let raw_id = cursor.read_i32_le()?;
+ let anchor = [cursor.read_f32_le()?, cursor.read_f32_le()?];
+ let mode = cursor.read_u32_le()?;
+ let (body, relations) = match mode {
+ 0 => parse_spatial_clan(cursor)?,
+ 1..=3 => parse_standard_clan(cursor)?,
+ _ => {
+ return Err(MissionError::UnknownClanMode {
+ clan: clan_index,
+ mode,
+ })
+ }
+ };
+ clans.push(ClanRecord {
+ name,
+ raw_id,
+ anchor,
+ mode,
+ body,
+ relations,
+ });
+ }
+ Ok(clans)
+}
+
+fn parse_standard_clan(
+ cursor: &mut Cursor<'_>,
+) -> Result<(ClanBody, Vec<ClanRelation>), MissionError> {
+ let first_resource = parse_tagged_resource(cursor)?;
+ let second_resource = parse_tagged_resource(cursor)?;
+ let relations = parse_relations(cursor)?;
+ Ok((
+ ClanBody::Standard {
+ first_resource,
+ second_resource,
+ },
+ relations,
+ ))
+}
+
+fn parse_spatial_clan(
+ cursor: &mut Cursor<'_>,
+) -> Result<(ClanBody, Vec<ClanRelation>), MissionError> {
+ let first_resource = read_lp_string(cursor)?;
+ let group_count = checked_count(cursor.read_u32_le()?, MAX_SPATIAL_GROUPS)?;
+ let mut spatial_groups = Vec::with_capacity(group_count);
+ for _ in 0..group_count {
+ let record_count = cursor.read_u32_le()?;
+ checked_count_bytes(u64::from(record_count), 20, cursor.remaining() as u64)?;
+ let record_count = checked_count(record_count, MAX_SPATIAL_RECORDS)?;
+ let mut records = Vec::with_capacity(record_count);
+ for _ in 0..record_count {
+ records.push([
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ ]);
+ }
+ spatial_groups.push(SpatialGroup { records });
+ }
+ let second_resource = parse_tagged_resource(cursor)?;
+ let relations = parse_relations(cursor)?;
+ Ok((
+ ClanBody::Spatial {
+ first_resource,
+ spatial_groups,
+ second_resource,
+ },
+ relations,
+ ))
+}
+
+fn parse_tagged_resource(cursor: &mut Cursor<'_>) -> Result<TaggedResource, MissionError> {
+ Ok(TaggedResource {
+ path: read_lp_string(cursor)?,
+ tag: cursor.read_i32_le()?,
+ })
+}
+
+fn parse_relations(cursor: &mut Cursor<'_>) -> Result<Vec<ClanRelation>, MissionError> {
+ let count = checked_count(cursor.read_u32_le()?, MAX_RELATIONS)?;
+ let mut relations = Vec::with_capacity(count);
+ for _ in 0..count {
+ relations.push(ClanRelation {
+ other_clan_name: read_lp_string(cursor)?,
+ relation_value: cursor.read_i32_le()?,
+ });
+ }
+ Ok(relations)
+}
+
+fn parse_objects(
+ cursor: &mut Cursor<'_>,
+ profile: TmaProfile,
+) -> Result<Vec<PlacedObject>, MissionError> {
+ let count = checked_count(cursor.read_u32_le()?, MAX_OBJECTS)?;
+ let mut objects = Vec::with_capacity(count);
+ for object_index in 0..count {
+ let raw_kind = cursor.read_u32_le()?;
+ let class_or_flags = cursor.read_u32_le()?;
+ if profile == TmaProfile::Strict && class_or_flags != OBJECT_CLASS_OR_FLAGS {
+ return Err(MissionError::InvalidObjectFlags {
+ object: object_index,
+ flags: class_or_flags,
+ });
+ }
+ let resource_name = read_lp_string(cursor)?;
+ let resource_raw = resource_name.raw.clone();
+ let raw_after_resource = cursor.read_u32_le()?;
+ let identity_or_clan_raw = cursor.read_u32_le()?;
+ let position = read_vec3(cursor)?;
+ let orientation = read_vec3(cursor)?;
+ let scale = read_vec3(cursor)?;
+ if !all_finite(&position) || !all_finite(&orientation) || !all_finite(&scale) {
+ return Err(MissionError::NonFiniteTransform {
+ object: object_index,
+ });
+ }
+ let instance_name = read_lp_string(cursor)?;
+ let raw_after_name = cursor.read_u32_le()?;
+ let link0 = cursor.read_i32_le()?;
+ let link1 = cursor.read_i32_le()?;
+ let property_schema_version = cursor.read_u32_le()?;
+ require_version(
+ "property schema",
+ property_schema_version,
+ PROPERTY_SCHEMA_VERSION,
+ profile,
+ )?;
+ let properties = parse_properties(cursor)?;
+ objects.push(PlacedObject {
+ raw_kind,
+ class_or_flags,
+ resource_name,
+ resource_raw,
+ raw_after_resource,
+ identity_or_clan_raw,
+ position,
+ orientation,
+ scale,
+ instance_name,
+ raw_after_name,
+ link0,
+ link1,
+ property_schema_version,
+ properties,
+ });
+ }
+ Ok(objects)
+}
+
+fn parse_properties(cursor: &mut Cursor<'_>) -> Result<Vec<OrderedProperty>, MissionError> {
+ let count = checked_count(cursor.read_u32_le()?, MAX_PROPERTIES)?;
+ let mut properties = Vec::with_capacity(count);
+ for _ in 0..count {
+ let raw_value = [
+ cursor.read_u32_le()?,
+ cursor.read_u32_le()?,
+ cursor.read_u32_le()?,
+ cursor.read_u32_le()?,
+ ];
+ let name = read_lp_string(cursor)?;
+ let name_raw = name.raw.clone();
+ properties.push(OrderedProperty {
+ raw_value,
+ name,
+ name_raw,
+ });
+ }
+ Ok(properties)
+}
+
+fn parse_extras(cursor: &mut Cursor<'_>) -> Result<Vec<ExtraRecord28>, MissionError> {
+ let count = checked_count(cursor.read_u32_le()?, MAX_EXTRAS)?;
+ checked_count_bytes(count as u64, 28, cursor.remaining() as u64)?;
+ let mut extras = Vec::with_capacity(count);
+ for _ in 0..count {
+ let chunk = cursor.read_exact(28)?;
+ let mut raw = [0; 28];
+ raw.copy_from_slice(chunk);
+ extras.push(ExtraRecord28 {
+ raw,
+ position: [
+ read_f32_from(chunk, 0)?,
+ read_f32_from(chunk, 4)?,
+ read_f32_from(chunk, 8)?,
+ ],
+ raw_words: [
+ read_u32_from(chunk, 12)?,
+ read_u32_from(chunk, 16)?,
+ read_u32_from(chunk, 20)?,
+ read_u32_from(chunk, 24)?,
+ ],
+ });
+ }
+ Ok(extras)
+}
+
+fn read_lp_string(cursor: &mut Cursor<'_>) -> Result<LpString, MissionError> {
+ let raw = read_lp_bytes(cursor, MAX_STRING_BYTES)?;
+ let (decoded, _, _) = WINDOWS_1251.decode(&raw);
+ let decoded = decoded.into_owned();
+ Ok(LpString { raw, decoded })
+}
+
+fn read_vec3(cursor: &mut Cursor<'_>) -> Result<[f32; 3], MissionError> {
+ Ok([
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ ])
+}
+
+fn all_finite(value: &[f32; 3]) -> bool {
+ value.iter().all(|component| component.is_finite())
+}
+
+fn checked_count(count: u32, limit: u32) -> Result<usize, MissionError> {
+ if count > limit {
+ return Err(DecodeError::LimitExceeded {
+ count: u64::from(count),
+ limit: u64::from(limit),
+ }
+ .into());
+ }
+ usize::try_from(count).map_err(|_| DecodeError::IntegerOverflow.into())
+}
+
+fn read_u32_from(bytes: &[u8], offset: usize) -> Result<u32, MissionError> {
+ let raw = bytes
+ .get(offset..offset + 4)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ Ok(u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]))
+}
+
+fn read_f32_from(bytes: &[u8], offset: usize) -> Result<f32, MissionError> {
+ Ok(f32::from_bits(read_u32_from(bytes, offset)?))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::path::{Path, PathBuf};
+
+ #[test]
+ fn minimal_synthetic_exact_eof() {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, CLAN_SECTION_VERSION);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, OBJECT_SECTION_VERSION);
+ push_u32(&mut bytes, 0);
+ push_lp(&mut bytes, b"DATA\\MAPS\\Tut_1\\land");
+ push_u32(&mut bytes, 0);
+ push_lp(&mut bytes, b"");
+ push_u32(&mut bytes, EXTRA_SECTION_VERSION);
+ push_u32(&mut bytes, 0);
+
+ let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma");
+ assert_eq!(
+ doc.versions,
+ vec![
+ FORMAT_VERSION,
+ CLAN_SECTION_VERSION,
+ OBJECT_SECTION_VERSION,
+ EXTRA_SECTION_VERSION
+ ]
+ );
+ assert_eq!(doc.land_path.decoded, "DATA\\MAPS\\Tut_1\\land");
+ }
+
+ #[test]
+ fn land_path_prefix_decode_matches_full_document() {
+ let bytes = minimal_tma_bytes();
+ let prefix = decode_tma_land_path(&bytes, TmaProfile::Strict).expect("land path prefix");
+ let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma");
+
+ assert_eq!(prefix, doc.land_path);
+ }
+
+ #[test]
+ fn lp_string_does_not_consume_implicit_nul() {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, CLAN_SECTION_VERSION);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, OBJECT_SECTION_VERSION);
+ push_u32(&mut bytes, 0);
+ push_lp(&mut bytes, b"A\0B");
+ push_u32(&mut bytes, 0x55aa);
+ push_lp(&mut bytes, b"");
+ push_u32(&mut bytes, EXTRA_SECTION_VERSION);
+ push_u32(&mut bytes, 0);
+
+ let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma");
+ assert_eq!(doc.land_path.raw, b"A\0B");
+ assert_eq!(doc.mission_flag, 0x55aa);
+ }
+
+ #[test]
+ fn synthetic_standard_clan_and_object_preserve_ordered_properties() {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 1);
+ push_i32(&mut bytes, 42);
+ push_u32(&mut bytes, 1);
+ push_f32(&mut bytes, 1.0);
+ push_f32(&mut bytes, 2.0);
+ push_f32(&mut bytes, 3.0);
+ push_u32(&mut bytes, CLAN_SECTION_VERSION);
+ push_u32(&mut bytes, 1);
+ push_lp(&mut bytes, b"Alpha");
+ push_i32(&mut bytes, -1);
+ push_f32(&mut bytes, 10.0);
+ push_f32(&mut bytes, 20.0);
+ push_u32(&mut bytes, 1);
+ push_lp(&mut bytes, b"Scripts\\a");
+ push_i32(&mut bytes, 7);
+ push_lp(&mut bytes, b"");
+ push_i32(&mut bytes, 8);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, OBJECT_SECTION_VERSION);
+ push_u32(&mut bytes, 1);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, OBJECT_CLASS_OR_FLAGS);
+ push_lp(&mut bytes, b"s_tree_04");
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, 0);
+ for value in [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0] {
+ push_f32(&mut bytes, value);
+ }
+ push_lp(&mut bytes, b"tree_01");
+ push_u32(&mut bytes, 0);
+ push_i32(&mut bytes, -1);
+ push_i32(&mut bytes, -1);
+ push_u32(&mut bytes, PROPERTY_SCHEMA_VERSION);
+ push_u32(&mut bytes, 2);
+ for name in [b"Life state".as_slice(), b"Life state".as_slice()] {
+ push_u32(&mut bytes, 1);
+ push_u32(&mut bytes, 2);
+ push_u32(&mut bytes, 3);
+ push_u32(&mut bytes, 4);
+ push_lp(&mut bytes, name);
+ }
+ push_lp(&mut bytes, b"DATA\\MAPS\\Tut_1\\land");
+ push_u32(&mut bytes, 0);
+ push_lp(&mut bytes, b"");
+ push_u32(&mut bytes, EXTRA_SECTION_VERSION);
+ push_u32(&mut bytes, 0);
+
+ let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma");
+ assert_eq!(doc.paths[0].id, 42);
+ assert_eq!(doc.clans[0].name.decoded, "Alpha");
+ assert_eq!(doc.objects[0].resource_name.decoded, "s_tree_04");
+ assert_eq!(doc.objects[0].properties.len(), 2);
+ assert_eq!(doc.objects[0].properties[0].raw_value, [1, 2, 3, 4]);
+ assert_eq!(doc.objects[0].properties[0].name.decoded, "Life state");
+ }
+
+ #[test]
+ fn path_ids_retain_nonsequential_order_and_truncated_points_fail() {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 3);
+ for id in [30, -5, 10] {
+ push_i32(&mut bytes, id);
+ push_u32(&mut bytes, 0);
+ }
+ push_empty_tail(&mut bytes);
+
+ let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma");
+ assert_eq!(
+ doc.paths.iter().map(|path| path.id).collect::<Vec<_>>(),
+ vec![30, -5, 10]
+ );
+
+ let mut truncated = Vec::new();
+ push_u32(&mut truncated, FORMAT_VERSION);
+ push_u32(&mut truncated, 1);
+ push_i32(&mut truncated, 1);
+ push_u32(&mut truncated, 1);
+ assert!(decode_tma(Arc::from(truncated.into_boxed_slice()), TmaProfile::Strict).is_err());
+ }
+
+ #[test]
+ fn clan_modes_one_to_three_and_spatial_mode_zero_decode() {
+ for mode in 1..=3 {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, CLAN_SECTION_VERSION);
+ push_u32(&mut bytes, 1);
+ push_standard_clan(&mut bytes, mode);
+ push_object_section_and_tail(&mut bytes, 0, b"", &[]);
+
+ let doc =
+ decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma");
+ assert_eq!(doc.clans[0].mode, mode);
+ assert!(matches!(doc.clans[0].body, ClanBody::Standard { .. }));
+ }
+
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, CLAN_SECTION_VERSION);
+ push_u32(&mut bytes, 1);
+ push_lp(&mut bytes, b"Spatial");
+ push_i32(&mut bytes, -1);
+ push_f32(&mut bytes, 0.0);
+ push_f32(&mut bytes, 0.0);
+ push_u32(&mut bytes, 0);
+ push_lp(&mut bytes, b"first");
+ push_u32(&mut bytes, 1);
+ push_u32(&mut bytes, 1);
+ for value in [1.0, 2.0, 3.0, 4.0, 5.0] {
+ push_f32(&mut bytes, value);
+ }
+ push_lp(&mut bytes, b"second");
+ push_i32(&mut bytes, 9);
+ push_u32(&mut bytes, 0);
+ push_object_section_and_tail(&mut bytes, 0, b"", &[]);
+
+ let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma");
+ let ClanBody::Spatial { spatial_groups, .. } = &doc.clans[0].body else {
+ panic!("spatial body");
+ };
+ assert_eq!(spatial_groups[0].records[0], [1.0, 2.0, 3.0, 4.0, 5.0]);
+ }
+
+ #[test]
+ fn unknown_clan_mode_nonfinite_transform_and_trailing_bytes_are_rejected() {
+ let mut unknown_mode = Vec::new();
+ push_u32(&mut unknown_mode, FORMAT_VERSION);
+ push_u32(&mut unknown_mode, 0);
+ push_u32(&mut unknown_mode, CLAN_SECTION_VERSION);
+ push_u32(&mut unknown_mode, 1);
+ push_lp(&mut unknown_mode, b"Bad");
+ push_i32(&mut unknown_mode, -1);
+ push_f32(&mut unknown_mode, 0.0);
+ push_f32(&mut unknown_mode, 0.0);
+ push_u32(&mut unknown_mode, 99);
+ let err = decode_tma(
+ Arc::from(unknown_mode.into_boxed_slice()),
+ TmaProfile::Strict,
+ )
+ .expect_err("mode");
+ assert!(matches!(
+ err,
+ MissionError::UnknownClanMode { mode: 99, .. }
+ ));
+
+ let mut nonfinite = Vec::new();
+ push_u32(&mut nonfinite, FORMAT_VERSION);
+ push_u32(&mut nonfinite, 0);
+ push_u32(&mut nonfinite, CLAN_SECTION_VERSION);
+ push_u32(&mut nonfinite, 0);
+ push_u32(&mut nonfinite, OBJECT_SECTION_VERSION);
+ push_u32(&mut nonfinite, 1);
+ push_object(&mut nonfinite, f32::NAN, &[]);
+ push_epilogue(&mut nonfinite, b"DATA\\MAPS\\Tut_1\\land", b"", &[]);
+ let err = decode_tma(Arc::from(nonfinite.into_boxed_slice()), TmaProfile::Strict)
+ .expect_err("nan");
+ assert!(matches!(
+ err,
+ MissionError::NonFiniteTransform { object: 0 }
+ ));
+
+ let mut trailing = minimal_tma_bytes();
+ trailing.push(0);
+ assert!(decode_tma(Arc::from(trailing.into_boxed_slice()), TmaProfile::Strict).is_err());
+ }
+
+ #[test]
+ fn description_and_extras_are_exact_raw_records() {
+ let mut extra = Vec::new();
+ for value in 0_u8..28 {
+ extra.push(value);
+ }
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 0);
+ push_empty_tail_with_description(&mut bytes, b"A\x00B", &[extra.as_slice()]);
+
+ let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma");
+ assert_eq!(doc.description_raw.raw, b"A\x00B");
+ assert_eq!(doc.extras.len(), 1);
+ assert_eq!(doc.extras[0].raw[27], 27);
+
+ let mut truncated_extra = Vec::new();
+ push_u32(&mut truncated_extra, FORMAT_VERSION);
+ push_u32(&mut truncated_extra, 0);
+ push_empty_tail_with_description(&mut truncated_extra, b"", &[&extra[..27]]);
+ assert!(decode_tma(
+ Arc::from(truncated_extra.into_boxed_slice()),
+ TmaProfile::Strict
+ )
+ .is_err());
+ }
+
+ #[test]
+ fn signatures_inside_strings_do_not_create_records_and_truncations_are_bounded() {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 0);
+ push_empty_tail_with_description(&mut bytes, &[1, 0, 0, 0, 6, 0, 0, 0], &[]);
+
+ let doc = decode_tma(
+ Arc::from(bytes.clone().into_boxed_slice()),
+ TmaProfile::Strict,
+ )
+ .expect("tma");
+ assert!(doc.paths.is_empty());
+ assert_eq!(doc.description_raw.raw, [1, 0, 0, 0, 6, 0, 0, 0]);
+
+ for len in 0..bytes.len() {
+ let _ = decode_tma(
+ Arc::from(bytes[..len].to_vec().into_boxed_slice()),
+ TmaProfile::Strict,
+ );
+ }
+ }
+
+ #[test]
+ fn generated_valid_documents_and_arbitrary_inputs_are_bounded() {
+ for seed in 0_u32..64 {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 1);
+ push_i32(&mut bytes, i32::try_from(seed).expect("seed"));
+ push_u32(&mut bytes, 1);
+ push_f32(&mut bytes, seed as f32);
+ push_f32(&mut bytes, 1.0);
+ push_f32(&mut bytes, 2.0);
+ push_empty_tail_with_description(&mut bytes, &[seed as u8, 0, 1], &[]);
+
+ let doc = decode_tma(
+ Arc::from(bytes.clone().into_boxed_slice()),
+ TmaProfile::Strict,
+ )
+ .expect("generated");
+ assert_eq!(doc.raw.as_ref(), bytes.as_slice());
+ assert_eq!(doc.paths[0].id, i32::try_from(seed).expect("seed"));
+
+ let arbitrary = (0..seed % 31)
+ .map(|offset| seed.wrapping_mul(17).wrapping_add(offset) as u8)
+ .collect::<Vec<_>>();
+ let _ = decode_tma(Arc::from(arbitrary.into_boxed_slice()), TmaProfile::Strict);
+ }
+ }
+
+ #[test]
+ fn licensed_corpus_tma_validate() {
+ for (
+ corpus,
+ expected_files,
+ expected_paths,
+ expected_clans,
+ expected_objects,
+ expected_extras,
+ ) in [
+ ("IS", 29_usize, 34_usize, 101_usize, 864_usize, 28_usize),
+ ("IS2", 31_usize, 61_usize, 91_usize, 885_usize, 41_usize),
+ ] {
+ let Some(root) = corpus_root(corpus) else {
+ continue;
+ };
+ let mut files = 0usize;
+ let mut paths = 0usize;
+ let mut clans = 0usize;
+ let mut objects = 0usize;
+ let mut extras = 0usize;
+ for path in files_under(&root) {
+ if !path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.eq_ignore_ascii_case("data.tma"))
+ {
+ continue;
+ }
+ let bytes = std::fs::read(&path).expect("read data.tma");
+ let document = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict)
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ files += 1;
+ paths += document.paths.len();
+ clans += document.clans.len();
+ objects += document.objects.len();
+ extras += document.extras.len();
+ assert_eq!(document.format_version, FORMAT_VERSION, "{corpus} {path:?}");
+ assert_eq!(
+ document.clan_section_version, CLAN_SECTION_VERSION,
+ "{corpus} {path:?}"
+ );
+ assert_eq!(
+ document.object_section_version, OBJECT_SECTION_VERSION,
+ "{corpus} {path:?}"
+ );
+ assert_eq!(
+ document.extra_section_version, EXTRA_SECTION_VERSION,
+ "{corpus} {path:?}"
+ );
+ assert!(
+ document
+ .land_path
+ .decoded
+ .to_ascii_uppercase()
+ .contains("DATA\\MAPS\\"),
+ "{corpus} {path:?} land path"
+ );
+ }
+
+ assert_eq!(files, expected_files, "{corpus} TMA count");
+ assert_eq!(paths, expected_paths, "{corpus} path count");
+ assert_eq!(clans, expected_clans, "{corpus} clan count");
+ assert_eq!(objects, expected_objects, "{corpus} object count");
+ assert_eq!(extras, expected_extras, "{corpus} extra count");
+ }
+ }
+
+ fn push_lp(out: &mut Vec<u8>, bytes: &[u8]) {
+ push_u32(out, u32::try_from(bytes.len()).expect("lp len"));
+ out.extend_from_slice(bytes);
+ }
+
+ fn push_u32(out: &mut Vec<u8>, value: u32) {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn push_i32(out: &mut Vec<u8>, value: i32) {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn push_f32(out: &mut Vec<u8>, value: f32) {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn minimal_tma_bytes() -> Vec<u8> {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 0);
+ push_empty_tail(&mut bytes);
+ bytes
+ }
+
+ fn push_empty_tail(out: &mut Vec<u8>) {
+ push_empty_tail_with_description(out, b"", &[]);
+ }
+
+ fn push_empty_tail_with_description(out: &mut Vec<u8>, description: &[u8], extras: &[&[u8]]) {
+ push_u32(out, CLAN_SECTION_VERSION);
+ push_u32(out, 0);
+ push_object_section_and_tail(out, 0, description, extras);
+ }
+
+ fn push_object_section_and_tail(
+ out: &mut Vec<u8>,
+ object_count: u32,
+ description: &[u8],
+ extras: &[&[u8]],
+ ) {
+ push_u32(out, OBJECT_SECTION_VERSION);
+ push_u32(out, object_count);
+ push_epilogue(out, b"DATA\\MAPS\\Tut_1\\land", description, extras);
+ }
+
+ fn push_epilogue(out: &mut Vec<u8>, land_path: &[u8], description: &[u8], extras: &[&[u8]]) {
+ push_lp(out, land_path);
+ push_u32(out, 0);
+ push_lp(out, description);
+ push_u32(out, EXTRA_SECTION_VERSION);
+ push_u32(out, u32::try_from(extras.len()).expect("extra count"));
+ for extra in extras {
+ out.extend_from_slice(extra);
+ }
+ }
+
+ fn push_standard_clan(out: &mut Vec<u8>, mode: u32) {
+ push_lp(out, b"Clan");
+ push_i32(out, -1);
+ push_f32(out, 0.0);
+ push_f32(out, 0.0);
+ push_u32(out, mode);
+ push_lp(out, b"first");
+ push_i32(out, 1);
+ push_lp(out, b"second");
+ push_i32(out, 2);
+ push_u32(out, 0);
+ }
+
+ fn push_object(out: &mut Vec<u8>, first_position: f32, properties: &[(&[u8], [u32; 4])]) {
+ push_u32(out, 0);
+ push_u32(out, OBJECT_CLASS_OR_FLAGS);
+ push_lp(out, b"s_tree_04");
+ push_u32(out, 0);
+ push_u32(out, 0);
+ for value in [first_position, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0] {
+ push_f32(out, value);
+ }
+ push_lp(out, b"tree_01");
+ push_u32(out, 0);
+ push_i32(out, -1);
+ push_i32(out, -1);
+ push_u32(out, PROPERTY_SCHEMA_VERSION);
+ push_u32(
+ out,
+ u32::try_from(properties.len()).expect("property count"),
+ );
+ for (name, raw) in properties {
+ for value in raw {
+ push_u32(out, *value);
+ }
+ push_lp(out, name);
+ }
+ }
+
+ fn corpus_root(name: &str) -> Option<PathBuf> {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("testdata")
+ .join(name);
+ root.is_dir().then_some(root)
+ }
+
+ fn files_under(root: &Path) -> Vec<PathBuf> {
+ let mut out = Vec::new();
+ let mut stack = vec![root.to_path_buf()];
+ while let Some(path) = stack.pop() {
+ let Ok(read_dir) = std::fs::read_dir(path) else {
+ continue;
+ };
+ for entry in read_dir.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ stack.push(path);
+ } else {
+ out.push(path);
+ }
+ }
+ }
+ out.sort();
+ out
+ }
+}
diff --git a/crates/fparkan-msh/Cargo.toml b/crates/fparkan-msh/Cargo.toml
new file mode 100644
index 0000000..01cd53b
--- /dev/null
+++ b/crates/fparkan-msh/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "fparkan-msh"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+encoding_rs = "0.8"
+fparkan-nres = { path = "../fparkan-nres" }
+
+[dev-dependencies]
+fparkan-animation = { path = "../fparkan-animation" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-msh/src/lib.rs b/crates/fparkan-msh/src/lib.rs
new file mode 100644
index 0000000..f06c8d6
--- /dev/null
+++ b/crates/fparkan-msh/src/lib.rs
@@ -0,0 +1,1767 @@
+#![forbid(unsafe_code)]
+//! Stage-3 MSH asset contract.
+
+use encoding_rs::WINDOWS_1251;
+use fparkan_nres::{EntryMeta, NresDocument, NresError};
+
+/// Node table stream.
+pub const STREAM_NODE_TABLE: u32 = 1;
+/// Slot stream.
+pub const STREAM_SLOTS: u32 = 2;
+/// Position stream.
+pub const STREAM_POSITIONS: u32 = 3;
+/// Normal stream.
+pub const STREAM_NORMALS: u32 = 4;
+/// Texture coordinate stream.
+pub const STREAM_UV0: u32 = 5;
+/// Triangle index stream.
+pub const STREAM_INDICES: u32 = 6;
+/// Animation key stream.
+pub const STREAM_ANIMATION_KEYS: u32 = 8;
+/// Node names stream.
+pub const STREAM_NAMES: u32 = 10;
+/// Batch stream.
+pub const STREAM_BATCHES: u32 = 13;
+/// Animation frame map stream.
+pub const STREAM_ANIMATION_FRAME_MAP: u32 = 19;
+
+const REQUIRED_STREAMS: &[(u32, &str)] = &[
+ (STREAM_NODE_TABLE, "Res1"),
+ (STREAM_SLOTS, "Res2"),
+ (STREAM_POSITIONS, "Res3"),
+ (STREAM_INDICES, "Res6"),
+ (STREAM_BATCHES, "Res13"),
+];
+
+/// MSH document backed by a lossless nested `NRes` archive.
+#[derive(Clone, Debug)]
+pub struct MshDocument {
+ nres: NresDocument,
+ streams: Vec<StreamDescriptor>,
+}
+
+/// Stream descriptor in original archive order.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct StreamDescriptor {
+ /// Stream type identifier.
+ pub type_id: u32,
+ /// Opaque stream attributes.
+ pub attributes: EntryAttributes,
+ /// Raw stream name bytes before the first NUL terminator.
+ pub name: Vec<u8>,
+ /// Payload size in bytes.
+ pub size: u32,
+}
+
+/// Opaque `NRes` entry attributes preserved for roundtrip.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct EntryAttributes {
+ /// Opaque attribute 1.
+ pub attr1: u32,
+ /// Opaque attribute 2.
+ pub attr2: u32,
+ /// Opaque attribute 3.
+ pub attr3: u32,
+}
+
+/// MSH variant id.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct MshVariantId(pub u32);
+
+/// Validated model asset.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ModelAsset {
+ /// Original node stride.
+ pub node_stride: usize,
+ /// Number of nodes.
+ pub node_count: usize,
+ /// Raw node table.
+ pub nodes_raw: Vec<u8>,
+ /// Slot table.
+ pub slots: Vec<Slot>,
+ /// Vertex positions.
+ pub positions: Vec<[f32; 3]>,
+ /// Optional normals.
+ pub normals: Option<Vec<[i8; 4]>>,
+ /// Optional texture coordinates.
+ pub uv0: Option<Vec<[i16; 2]>>,
+ /// Triangle indices.
+ pub indices: Vec<u16>,
+ /// Draw batches.
+ pub batches: Vec<Batch>,
+ /// Optional decoded node names.
+ pub node_names: Option<Vec<Option<String>>>,
+}
+
+/// Node id.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct NodeId(pub u32);
+
+/// Slot id.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct SlotId(pub u32);
+
+/// Raw node view.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Node {
+ /// Raw node bytes.
+ pub raw: Vec<u8>,
+}
+
+/// Slot descriptor.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Slot {
+ /// First triangle descriptor.
+ pub tri_start: u16,
+ /// Triangle descriptor count.
+ pub tri_count: u16,
+ /// First batch index.
+ pub batch_start: u16,
+ /// Batch count.
+ pub batch_count: u16,
+ /// AABB minimum.
+ pub aabb_min: [f32; 3],
+ /// AABB maximum.
+ pub aabb_max: [f32; 3],
+ /// Bounding sphere center.
+ pub sphere_center: [f32; 3],
+ /// Bounding sphere radius.
+ pub sphere_radius: f32,
+ /// Opaque slot tail.
+ pub opaque: [u32; 5],
+}
+
+/// Draw batch.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Batch {
+ /// Batch flags.
+ pub batch_flags: u16,
+ /// Material index.
+ pub material_index: u16,
+ /// Opaque field.
+ pub opaque4: u16,
+ /// Opaque field.
+ pub opaque6: u16,
+ /// Index count.
+ pub index_count: u16,
+ /// First index offset.
+ pub index_start: u32,
+ /// Opaque field.
+ pub opaque14: u16,
+ /// Base vertex.
+ pub base_vertex: u32,
+}
+
+/// Preserved triangle descriptor stream marker.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TriangleDescriptor;
+
+/// Vertex stream view.
+#[derive(Clone, Debug, PartialEq)]
+pub struct VertexStreams {
+ /// Vertex positions.
+ pub positions: Vec<[f32; 3]>,
+ /// Optional normals.
+ pub normals: Option<Vec<[i8; 4]>>,
+ /// Optional texture coordinates.
+ pub uv0: Option<Vec<[i16; 2]>>,
+}
+
+/// Preserved non-core stream.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PreservedStream {
+ /// Stream type id.
+ pub type_id: u32,
+ /// Stream attributes.
+ pub attributes: EntryAttributes,
+ /// Original payload bytes.
+ pub bytes: std::sync::Arc<[u8]>,
+}
+
+/// LOD id.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct Lod(pub u8);
+
+/// Group id.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct Group(pub u8);
+
+/// MSH decode or validation error.
+#[derive(Debug)]
+pub enum MshError {
+ /// Nested `NRes` error.
+ Nres(NresError),
+ /// Required stream is absent.
+ MissingStream {
+ /// Stream type id.
+ type_id: u32,
+ /// Human-readable stream label.
+ label: &'static str,
+ },
+ /// Required stream appears more than once.
+ DuplicateStream {
+ /// Stream type id.
+ type_id: u32,
+ /// Human-readable stream label.
+ label: &'static str,
+ },
+ /// Legacy compatibility backend rejected the geometry.
+ InvalidGeometry(String),
+ /// Slot id is outside the validated model.
+ SlotOutOfBounds {
+ /// Requested slot id.
+ slot: u32,
+ /// Slot count.
+ slot_count: usize,
+ },
+ /// Batch range is outside the validated model.
+ BatchRangeOutOfBounds {
+ /// First requested batch.
+ start: usize,
+ /// Exclusive end.
+ end: usize,
+ /// Batch count.
+ batch_count: usize,
+ },
+ /// Batch references a vertex outside position stream.
+ VertexIndexOutOfBounds {
+ /// Batch index.
+ batch: usize,
+ /// Resolved vertex index.
+ vertex: u64,
+ /// Position count.
+ position_count: usize,
+ },
+ /// Non-finite or inverted bounds.
+ InvalidBounds {
+ /// Slot index.
+ slot: usize,
+ },
+}
+
+impl From<NresError> for MshError {
+ fn from(value: NresError) -> Self {
+ Self::Nres(value)
+ }
+}
+
+impl std::fmt::Display for MshError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Nres(source) => write!(f, "{source}"),
+ Self::MissingStream { type_id, label } => {
+ write!(f, "missing MSH stream {label} ({type_id})")
+ }
+ Self::DuplicateStream { type_id, label } => {
+ write!(f, "duplicate MSH stream {label} ({type_id})")
+ }
+ Self::InvalidGeometry(message) => write!(f, "{message}"),
+ Self::SlotOutOfBounds { slot, slot_count } => {
+ write!(f, "slot {slot} is outside slot table of {slot_count}")
+ }
+ Self::BatchRangeOutOfBounds {
+ start,
+ end,
+ batch_count,
+ } => write!(
+ f,
+ "batch range {start}..{end} is outside batch table of {batch_count}"
+ ),
+ Self::VertexIndexOutOfBounds {
+ batch,
+ vertex,
+ position_count,
+ } => write!(
+ f,
+ "batch {batch} references vertex {vertex}, position_count={position_count}"
+ ),
+ Self::InvalidBounds { slot } => write!(f, "slot {slot} has invalid bounds"),
+ }
+ }
+}
+
+impl std::error::Error for MshError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Nres(source) => Some(source),
+ Self::MissingStream { .. }
+ | Self::DuplicateStream { .. }
+ | Self::InvalidGeometry(_)
+ | Self::SlotOutOfBounds { .. }
+ | Self::BatchRangeOutOfBounds { .. }
+ | Self::VertexIndexOutOfBounds { .. }
+ | Self::InvalidBounds { .. } => None,
+ }
+ }
+}
+
+/// Decodes a nested MSH `NRes` document.
+///
+/// # Errors
+///
+/// Returns [`MshError`] when required streams are absent or duplicated.
+pub fn decode_msh(document: &NresDocument) -> Result<MshDocument, MshError> {
+ for (type_id, label) in REQUIRED_STREAMS {
+ let count = document
+ .entries()
+ .iter()
+ .filter(|entry| entry.meta().type_id == *type_id)
+ .count();
+ if count == 0 {
+ return Err(MshError::MissingStream {
+ type_id: *type_id,
+ label,
+ });
+ }
+ if count > 1 {
+ return Err(MshError::DuplicateStream {
+ type_id: *type_id,
+ label,
+ });
+ }
+ }
+
+ let streams = document
+ .entries()
+ .iter()
+ .map(|entry| stream_descriptor(entry.meta(), entry.name_bytes()))
+ .collect();
+
+ Ok(MshDocument {
+ nres: document.clone(),
+ streams,
+ })
+}
+
+/// Validates static geometry and returns a backend-neutral model asset.
+///
+/// # Errors
+///
+/// Returns [`MshError`] when stream sizes, slot ranges, batch ranges, bounds,
+/// or indexed vertices are invalid.
+pub fn validate_msh(document: &MshDocument) -> Result<ModelAsset, MshError> {
+ let model = parse_model_document(&document.nres)?;
+ validate_bounds(&model)?;
+ validate_vertex_indices(&model)?;
+ Ok(model)
+}
+
+/// Returns the selected slot for a node/lod/group tuple.
+#[must_use]
+pub fn selected_slot(model: &ModelAsset, node: NodeId, lod: Lod, group: Group) -> Option<SlotId> {
+ if model.node_stride != 38 || lod.0 >= 3 || group.0 >= 5 {
+ return None;
+ }
+ let node_index = usize::try_from(node.0).ok()?;
+ if node_index >= model.node_count {
+ return None;
+ }
+ let node_off = node_index.checked_mul(model.node_stride)?;
+ let slot_off = node_off
+ .checked_add(8)?
+ .checked_add((usize::from(lod.0) * 5 + usize::from(group.0)) * 2)?;
+ let raw = read_u16(&model.nodes_raw, slot_off)?;
+ if raw == u16::MAX {
+ return None;
+ }
+ let slot = usize::from(raw);
+ (slot < model.slots.len()).then_some(SlotId(u32::from(raw)))
+}
+
+/// Returns draw batches for a validated slot.
+///
+/// # Errors
+///
+/// Returns [`MshError`] when the slot id or its batch range is invalid.
+pub fn draw_batches(model: &ModelAsset, slot: SlotId) -> Result<&[Batch], MshError> {
+ let slot_index = usize::try_from(slot.0).map_err(|_| MshError::SlotOutOfBounds {
+ slot: slot.0,
+ slot_count: model.slots.len(),
+ })?;
+ let slot_ref = model
+ .slots
+ .get(slot_index)
+ .ok_or(MshError::SlotOutOfBounds {
+ slot: slot.0,
+ slot_count: model.slots.len(),
+ })?;
+ let start = usize::from(slot_ref.batch_start);
+ let end = start.checked_add(usize::from(slot_ref.batch_count)).ok_or(
+ MshError::BatchRangeOutOfBounds {
+ start,
+ end: usize::MAX,
+ batch_count: model.batches.len(),
+ },
+ )?;
+ model
+ .batches
+ .get(start..end)
+ .ok_or(MshError::BatchRangeOutOfBounds {
+ start,
+ end,
+ batch_count: model.batches.len(),
+ })
+}
+
+impl MshDocument {
+ /// Returns original stream descriptors.
+ #[must_use]
+ pub fn streams(&self) -> &[StreamDescriptor] {
+ &self.streams
+ }
+
+ /// Returns the recognized MSH variant id.
+ #[must_use]
+ pub fn variant_id(&self) -> MshVariantId {
+ if self
+ .streams
+ .iter()
+ .any(|stream| stream.name.eq_ignore_ascii_case(b"MTCHECK"))
+ {
+ MshVariantId(1)
+ } else {
+ MshVariantId(0)
+ }
+ }
+
+ /// Returns preserved non-core streams.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MshError`] when the underlying `NRes` payload lookup fails.
+ pub fn preserved_streams(&self) -> Result<Vec<PreservedStream>, MshError> {
+ let mut preserved = Vec::new();
+ for entry in self.nres.entries() {
+ let type_id = entry.meta().type_id;
+ if REQUIRED_STREAMS
+ .iter()
+ .any(|(required, _)| *required == type_id)
+ {
+ continue;
+ }
+ preserved.push(PreservedStream {
+ type_id,
+ attributes: attributes(entry.meta()),
+ bytes: std::sync::Arc::from(
+ self.nres.payload(entry.id())?.to_vec().into_boxed_slice(),
+ ),
+ });
+ }
+ Ok(preserved)
+ }
+}
+
+fn stream_descriptor(meta: &EntryMeta, name: &[u8]) -> StreamDescriptor {
+ StreamDescriptor {
+ type_id: meta.type_id,
+ attributes: attributes(meta),
+ name: name.to_vec(),
+ size: meta.data_size,
+ }
+}
+
+fn attributes(meta: &EntryMeta) -> EntryAttributes {
+ EntryAttributes {
+ attr1: meta.attr1,
+ attr2: meta.attr2,
+ attr3: meta.attr3,
+ }
+}
+
+fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError> {
+ let nodes_stream = read_required_stream(document, STREAM_NODE_TABLE, "Res1")?;
+ let slots_stream = read_required_stream(document, STREAM_SLOTS, "Res2")?;
+ let positions_stream = read_required_stream(document, STREAM_POSITIONS, "Res3")?;
+ let indices_stream = read_required_stream(document, STREAM_INDICES, "Res6")?;
+ let batches_stream = read_required_stream(document, STREAM_BATCHES, "Res13")?;
+
+ let node_stride = usize::try_from(nodes_stream.attributes.attr3)
+ .map_err(|_| MshError::InvalidGeometry("MSH node stride does not fit usize".to_string()))?;
+ if node_stride != 38 && node_stride != 24 {
+ return Err(MshError::InvalidGeometry(format!(
+ "unsupported MSH node stride: {node_stride}"
+ )));
+ }
+ if !nodes_stream.bytes.len().is_multiple_of(node_stride) {
+ return Err(invalid_resource_size(
+ "Res1",
+ nodes_stream.bytes.len(),
+ node_stride,
+ ));
+ }
+ let node_count = nodes_stream.bytes.len() / node_stride;
+
+ let slots = parse_slots(&slots_stream.bytes)?;
+ let positions = parse_positions(&positions_stream.bytes)?;
+ let indices = parse_u16_array(&indices_stream.bytes, "Res6")?;
+ let batches = parse_batches(&batches_stream.bytes)?;
+ validate_slot_batch_ranges(&slots, batches.len())?;
+ validate_batch_index_ranges(&batches, indices.len())?;
+
+ let normals = read_optional_stream(document, STREAM_NORMALS)?
+ .map(|raw| parse_i8x4_array(&raw.bytes, "Res4"))
+ .transpose()?;
+ let uv0 = read_optional_stream(document, STREAM_UV0)?
+ .map(|raw| parse_i16x2_array(&raw.bytes, "Res5"))
+ .transpose()?;
+ let node_names = read_optional_stream(document, STREAM_NAMES)?
+ .map(|raw| parse_res10_names(&raw.bytes, node_count))
+ .transpose()?;
+
+ Ok(ModelAsset {
+ node_stride,
+ node_count,
+ nodes_raw: nodes_stream.bytes,
+ slots,
+ positions,
+ normals,
+ uv0,
+ indices,
+ batches,
+ node_names,
+ })
+}
+
+struct RawStream {
+ attributes: EntryAttributes,
+ bytes: Vec<u8>,
+}
+
+fn read_required_stream(
+ document: &NresDocument,
+ type_id: u32,
+ label: &'static str,
+) -> Result<RawStream, MshError> {
+ let entry = document
+ .entries()
+ .iter()
+ .find(|entry| entry.meta().type_id == type_id)
+ .ok_or(MshError::MissingStream { type_id, label })?;
+ Ok(RawStream {
+ attributes: attributes(entry.meta()),
+ bytes: document.payload(entry.id())?.to_vec(),
+ })
+}
+
+fn read_optional_stream(
+ document: &NresDocument,
+ type_id: u32,
+) -> Result<Option<RawStream>, MshError> {
+ let Some(entry) = document
+ .entries()
+ .iter()
+ .find(|entry| entry.meta().type_id == type_id)
+ else {
+ return Ok(None);
+ };
+ Ok(Some(RawStream {
+ attributes: attributes(entry.meta()),
+ bytes: document.payload(entry.id())?.to_vec(),
+ }))
+}
+
+fn parse_slots(data: &[u8]) -> Result<Vec<Slot>, MshError> {
+ if data.len() < 0x8C {
+ return Err(MshError::InvalidGeometry(format!(
+ "invalid Res2 size: {}",
+ data.len()
+ )));
+ }
+ let slot_bytes = data
+ .len()
+ .checked_sub(0x8C)
+ .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?;
+ if !slot_bytes.is_multiple_of(68) {
+ return Err(invalid_resource_size("Res2.slots", slot_bytes, 68));
+ }
+ let count = slot_bytes / 68;
+ let mut slots = Vec::with_capacity(count);
+ for index in 0..count {
+ let offset = 0x8Cusize
+ .checked_add(
+ index
+ .checked_mul(68)
+ .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?,
+ )
+ .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?;
+ slots.push(Slot {
+ tri_start: read_u16_required(data, offset)?,
+ tri_count: read_u16_required(data, offset + 2)?,
+ batch_start: read_u16_required(data, offset + 4)?,
+ batch_count: read_u16_required(data, offset + 6)?,
+ aabb_min: [
+ read_f32(data, offset + 8)?,
+ read_f32(data, offset + 12)?,
+ read_f32(data, offset + 16)?,
+ ],
+ aabb_max: [
+ read_f32(data, offset + 20)?,
+ read_f32(data, offset + 24)?,
+ read_f32(data, offset + 28)?,
+ ],
+ sphere_center: [
+ read_f32(data, offset + 32)?,
+ read_f32(data, offset + 36)?,
+ read_f32(data, offset + 40)?,
+ ],
+ sphere_radius: read_f32(data, offset + 44)?,
+ opaque: [
+ read_u32(data, offset + 48)?,
+ read_u32(data, offset + 52)?,
+ read_u32(data, offset + 56)?,
+ read_u32(data, offset + 60)?,
+ read_u32(data, offset + 64)?,
+ ],
+ });
+ }
+ Ok(slots)
+}
+
+fn parse_positions(data: &[u8]) -> Result<Vec<[f32; 3]>, MshError> {
+ if !data.len().is_multiple_of(12) {
+ return Err(invalid_resource_size("Res3", data.len(), 12));
+ }
+ let mut out = Vec::with_capacity(data.len() / 12);
+ for offset in (0..data.len()).step_by(12) {
+ out.push([
+ read_f32(data, offset)?,
+ read_f32(data, offset + 4)?,
+ read_f32(data, offset + 8)?,
+ ]);
+ }
+ Ok(out)
+}
+
+fn parse_batches(data: &[u8]) -> Result<Vec<Batch>, MshError> {
+ if !data.len().is_multiple_of(20) {
+ return Err(invalid_resource_size("Res13", data.len(), 20));
+ }
+ let mut out = Vec::with_capacity(data.len() / 20);
+ for offset in (0..data.len()).step_by(20) {
+ out.push(Batch {
+ batch_flags: read_u16_required(data, offset)?,
+ material_index: read_u16_required(data, offset + 2)?,
+ opaque4: read_u16_required(data, offset + 4)?,
+ opaque6: read_u16_required(data, offset + 6)?,
+ index_count: read_u16_required(data, offset + 8)?,
+ index_start: read_u32(data, offset + 10)?,
+ opaque14: read_u16_required(data, offset + 14)?,
+ base_vertex: read_u32(data, offset + 16)?,
+ });
+ }
+ Ok(out)
+}
+
+fn parse_u16_array(data: &[u8], label: &'static str) -> Result<Vec<u16>, MshError> {
+ if !data.len().is_multiple_of(2) {
+ return Err(invalid_resource_size(label, data.len(), 2));
+ }
+ let mut out = Vec::with_capacity(data.len() / 2);
+ for offset in (0..data.len()).step_by(2) {
+ out.push(read_u16_required(data, offset)?);
+ }
+ Ok(out)
+}
+
+fn parse_i8x4_array(data: &[u8], label: &'static str) -> Result<Vec<[i8; 4]>, MshError> {
+ if !data.len().is_multiple_of(4) {
+ return Err(invalid_resource_size(label, data.len(), 4));
+ }
+ let mut out = Vec::with_capacity(data.len() / 4);
+ for offset in (0..data.len()).step_by(4) {
+ out.push([
+ read_i8(data, offset)?,
+ read_i8(data, offset + 1)?,
+ read_i8(data, offset + 2)?,
+ read_i8(data, offset + 3)?,
+ ]);
+ }
+ Ok(out)
+}
+
+fn parse_i16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[i16; 2]>, MshError> {
+ if !data.len().is_multiple_of(4) {
+ return Err(invalid_resource_size(label, data.len(), 4));
+ }
+ let mut out = Vec::with_capacity(data.len() / 4);
+ for offset in (0..data.len()).step_by(4) {
+ out.push([read_i16(data, offset)?, read_i16(data, offset + 2)?]);
+ }
+ Ok(out)
+}
+
+fn parse_res10_names(data: &[u8], node_count: usize) -> Result<Vec<Option<String>>, MshError> {
+ let mut out = Vec::with_capacity(node_count);
+ let mut offset = 0usize;
+ for _ in 0..node_count {
+ let len = usize::try_from(read_u32(data, offset)?)
+ .map_err(|_| MshError::InvalidGeometry("integer overflow".to_string()))?;
+ offset = offset
+ .checked_add(4)
+ .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?;
+ if len == 0 {
+ out.push(None);
+ continue;
+ }
+ let need = len
+ .checked_add(1)
+ .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?;
+ let end = offset
+ .checked_add(need)
+ .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?;
+ let slice = data
+ .get(offset..end)
+ .ok_or_else(|| invalid_resource_size("Res10", data.len(), 1))?;
+ let text = if slice.last().copied() == Some(0) {
+ &slice[..slice.len().saturating_sub(1)]
+ } else {
+ slice
+ };
+ let (decoded, _, _) = WINDOWS_1251.decode(text);
+ out.push(Some(decoded.into_owned()));
+ offset = end;
+ }
+ Ok(out)
+}
+
+fn validate_slot_batch_ranges(slots: &[Slot], batch_count: usize) -> Result<(), MshError> {
+ for slot in slots {
+ let start = usize::from(slot.batch_start);
+ let end = start
+ .checked_add(usize::from(slot.batch_count))
+ .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?;
+ if end > batch_count {
+ return Err(MshError::BatchRangeOutOfBounds {
+ start,
+ end,
+ batch_count,
+ });
+ }
+ }
+ Ok(())
+}
+
+fn validate_batch_index_ranges(batches: &[Batch], index_count: usize) -> Result<(), MshError> {
+ for (batch_index, batch) in batches.iter().enumerate() {
+ let start = usize::try_from(batch.index_start)
+ .map_err(|_| MshError::InvalidGeometry("integer overflow".to_string()))?;
+ let end = start
+ .checked_add(usize::from(batch.index_count))
+ .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?;
+ if end > index_count {
+ return Err(MshError::VertexIndexOutOfBounds {
+ batch: batch_index,
+ vertex: u64::try_from(end).unwrap_or(u64::MAX),
+ position_count: index_count,
+ });
+ }
+ }
+ Ok(())
+}
+
+fn invalid_resource_size(label: &'static str, size: usize, stride: usize) -> MshError {
+ MshError::InvalidGeometry(format!(
+ "invalid {label} size: size={size}, stride={stride}"
+ ))
+}
+
+fn validate_bounds(model: &ModelAsset) -> Result<(), MshError> {
+ for (index, slot) in model.slots.iter().enumerate() {
+ let ordered = slot
+ .aabb_min
+ .iter()
+ .zip(slot.aabb_max.iter())
+ .all(|(min, max)| min.is_finite() && max.is_finite() && min <= max);
+ let sphere = slot.sphere_center.iter().all(|value| value.is_finite())
+ && slot.sphere_radius.is_finite()
+ && slot.sphere_radius >= 0.0;
+ if !ordered || !sphere {
+ return Err(MshError::InvalidBounds { slot: index });
+ }
+ }
+ Ok(())
+}
+
+fn validate_vertex_indices(model: &ModelAsset) -> Result<(), MshError> {
+ let position_count =
+ u64::try_from(model.positions.len()).map_err(|_| MshError::VertexIndexOutOfBounds {
+ batch: usize::MAX,
+ vertex: u64::MAX,
+ position_count: model.positions.len(),
+ })?;
+ for (batch_index, batch) in model.batches.iter().enumerate() {
+ let start =
+ usize::try_from(batch.index_start).map_err(|_| MshError::VertexIndexOutOfBounds {
+ batch: batch_index,
+ vertex: u64::MAX,
+ position_count: model.positions.len(),
+ })?;
+ let end = start.checked_add(usize::from(batch.index_count)).ok_or(
+ MshError::VertexIndexOutOfBounds {
+ batch: batch_index,
+ vertex: u64::MAX,
+ position_count: model.positions.len(),
+ },
+ )?;
+ for raw in &model.indices[start..end] {
+ let vertex = u64::from(batch.base_vertex) + u64::from(*raw);
+ if vertex >= position_count {
+ return Err(MshError::VertexIndexOutOfBounds {
+ batch: batch_index,
+ vertex,
+ position_count: model.positions.len(),
+ });
+ }
+ }
+ }
+ Ok(())
+}
+
+fn read_u16(bytes: &[u8], offset: usize) -> Option<u16> {
+ let raw = bytes.get(offset..offset.checked_add(2)?)?;
+ let arr: [u8; 2] = raw.try_into().ok()?;
+ Some(u16::from_le_bytes(arr))
+}
+
+fn read_u16_required(bytes: &[u8], offset: usize) -> Result<u16, MshError> {
+ let raw = bytes
+ .get(offset..offset.saturating_add(2))
+ .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?;
+ let arr: [u8; 2] = raw
+ .try_into()
+ .map_err(|_| MshError::InvalidGeometry("integer overflow".to_string()))?;
+ Ok(u16::from_le_bytes(arr))
+}
+
+fn read_i16(bytes: &[u8], offset: usize) -> Result<i16, MshError> {
+ let raw = bytes
+ .get(offset..offset.saturating_add(2))
+ .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?;
+ let arr: [u8; 2] = raw
+ .try_into()
+ .map_err(|_| MshError::InvalidGeometry("integer overflow".to_string()))?;
+ Ok(i16::from_le_bytes(arr))
+}
+
+fn read_i8(bytes: &[u8], offset: usize) -> Result<i8, MshError> {
+ let byte = bytes
+ .get(offset)
+ .copied()
+ .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?;
+ Ok(i8::from_le_bytes([byte]))
+}
+
+fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, MshError> {
+ let raw = bytes
+ .get(offset..offset.saturating_add(4))
+ .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?;
+ let arr: [u8; 4] = raw
+ .try_into()
+ .map_err(|_| MshError::InvalidGeometry("integer overflow".to_string()))?;
+ Ok(u32::from_le_bytes(arr))
+}
+
+fn read_f32(bytes: &[u8], offset: usize) -> Result<f32, MshError> {
+ Ok(f32::from_bits(read_u32(bytes, offset)?))
+}
+
+/// Returns migration status.
+#[must_use]
+pub fn migration_facade_ready() -> bool {
+ true
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_animation::{
+ canonical_timed_pose_capture, AnimKey24, AnimationTime, TimedPoseKey, TimedPoseTrack,
+ };
+ use fparkan_nres::ReadProfile;
+ use std::path::{Path, PathBuf};
+ use std::sync::Arc;
+
+ #[test]
+ fn validates_minimal_msh_document() {
+ let document = decode_nested(&minimal_msh_bytes()).expect("nested NRes");
+ let msh = decode_msh(&document).expect("msh document");
+ let model = validate_msh(&msh).expect("model");
+
+ assert_eq!(model.node_stride, 38);
+ assert_eq!(model.node_count, 0);
+ assert!(model.slots.is_empty());
+ }
+
+ #[test]
+ fn missing_required_stream_is_error() {
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &[0; 0x8c]),
+ ]))
+ .expect("nested NRes");
+
+ let err = decode_msh(&document).expect_err("missing stream");
+ assert!(matches!(
+ err,
+ MshError::MissingStream {
+ type_id: STREAM_POSITIONS,
+ ..
+ }
+ ));
+ }
+
+ #[test]
+ fn base_vertex_plus_index_must_reference_position() {
+ let mut indices = Vec::new();
+ indices.extend_from_slice(&1_u16.to_le_bytes());
+ let mut batch = Vec::new();
+ push_u16(&mut batch, 0);
+ push_u16(&mut batch, 0);
+ push_u16(&mut batch, 0);
+ push_u16(&mut batch, 0);
+ push_u16(&mut batch, 1);
+ push_u32(&mut batch, 0);
+ push_u16(&mut batch, 0);
+ push_u32(&mut batch, 0);
+ let bytes = build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &[0; 0x8c]),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &indices),
+ stream(STREAM_BATCHES, 0, b"Res13", &batch),
+ ]);
+ let document = decode_nested(&bytes).expect("nested NRes");
+ let msh = decode_msh(&document).expect("msh document");
+
+ let err = validate_msh(&msh).expect_err("invalid vertex");
+ assert!(matches!(err, MshError::VertexIndexOutOfBounds { .. }));
+ }
+
+ #[test]
+ fn canonical_stream_set_is_independent_of_entry_order() {
+ let slots = slots_payload(&[]);
+ let ordered = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ ]))
+ .expect("ordered");
+ let reversed = decode_nested(&build_nres(&[
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots),
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ ]))
+ .expect("reversed");
+
+ assert_eq!(
+ validate_msh(&decode_msh(&ordered).expect("ordered msh")).expect("ordered model"),
+ validate_msh(&decode_msh(&reversed).expect("reversed msh")).expect("reversed model")
+ );
+ }
+
+ #[test]
+ fn duplicate_required_stream_type_is_error() {
+ let slots = slots_payload(&[]);
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_NODE_TABLE, 38, b"Res1Dup", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ ]))
+ .expect("nested NRes");
+
+ assert!(matches!(
+ decode_msh(&document),
+ Err(MshError::DuplicateStream {
+ type_id: STREAM_NODE_TABLE,
+ ..
+ })
+ ));
+ }
+
+ #[test]
+ fn node38_stride_is_exact() {
+ let slots = slots_payload(&[]);
+ let valid_node = node38([u16::MAX; 15]);
+ let valid = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &valid_node),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ ]))
+ .expect("valid");
+ let model = validate_msh(&decode_msh(&valid).expect("msh")).expect("model");
+ assert_eq!(model.node_stride, 38);
+ assert_eq!(model.node_count, 1);
+
+ let invalid = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &valid_node[..37]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ ]))
+ .expect("invalid");
+ let err = validate_msh(&decode_msh(&invalid).expect("msh")).expect_err("stride");
+ assert!(matches!(err, MshError::InvalidGeometry(_)));
+ }
+
+ #[test]
+ fn node38_uses_three_by_five_slot_mapping_and_absent_marker() {
+ let mut mapping = [u16::MAX; 15];
+ mapping[0] = 0;
+ mapping[7] = 1;
+ let node = node38(mapping);
+ let slots = slots_payload(&[
+ slot_record(0, 0, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0], 1.0),
+ slot_record(0, 0, [0.0, 0.0, 0.0], [2.0, 2.0, 2.0], 1.0),
+ ]);
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &node),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ ]))
+ .expect("nested");
+ let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
+
+ assert_eq!(
+ selected_slot(&model, NodeId(0), Lod(0), Group(0)),
+ Some(SlotId(0))
+ );
+ assert_eq!(
+ selected_slot(&model, NodeId(0), Lod(1), Group(2)),
+ Some(SlotId(1))
+ );
+ assert_eq!(selected_slot(&model, NodeId(0), Lod(2), Group(4)), None);
+ }
+
+ #[test]
+ fn type2_header_and_slot_tail_framing_are_exact() {
+ let too_small = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &[0; 0x8b]),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ ]))
+ .expect("nested");
+ let err = validate_msh(&decode_msh(&too_small).expect("msh")).expect_err("header");
+ assert!(matches!(err, MshError::InvalidGeometry(_)));
+
+ let not_divisible = vec![0; 0x8c + 67];
+ let bad_tail = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &not_divisible),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ ]))
+ .expect("nested");
+ let err = validate_msh(&decode_msh(&bad_tail).expect("msh")).expect_err("tail");
+ assert!(matches!(err, MshError::InvalidGeometry(_)));
+ }
+
+ #[test]
+ fn slot_batch_range_out_of_bounds_is_error() {
+ let slots = slots_payload(&[slot_record(1, 1, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0], 1.0)]);
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ ]))
+ .expect("nested");
+
+ assert!(matches!(
+ validate_msh(&decode_msh(&document).expect("msh")),
+ Err(MshError::BatchRangeOutOfBounds {
+ start: 1,
+ end: 2,
+ batch_count: 0
+ })
+ ));
+ }
+
+ #[test]
+ fn vertex_stream_strides_are_exact() {
+ for (stream_type, name, payload) in [
+ (STREAM_POSITIONS, b"Res3".as_slice(), vec![0; 11]),
+ (STREAM_NORMALS, b"Res4".as_slice(), vec![0; 3]),
+ (STREAM_UV0, b"Res5".as_slice(), vec![0; 3]),
+ (STREAM_INDICES, b"Res6".as_slice(), vec![0; 1]),
+ ] {
+ let slots = slots_payload(&[]);
+ let mut entries = vec![
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ ];
+ if stream_type == STREAM_POSITIONS {
+ entries[2] = stream(stream_type, 0, name, &payload);
+ } else if stream_type == STREAM_INDICES {
+ entries[3] = stream(stream_type, 0, name, &payload);
+ } else {
+ entries.push(stream(stream_type, 0, name, &payload));
+ }
+ let document = decode_nested(&build_nres(&entries)).expect("nested");
+ let err = validate_msh(&decode_msh(&document).expect("msh")).expect_err("stride");
+ assert!(matches!(err, MshError::InvalidGeometry(_)));
+ }
+ }
+
+ #[test]
+ fn batch20_uses_unaligned_field_offsets() {
+ let positions = positions_payload(&[[0.0, 0.0, 0.0]]);
+ let mut indices = Vec::new();
+ push_u16(&mut indices, 0);
+ let batch = batch_record(0x1100, 0x2200, 0x3300, 0x4400, 1, 0, 0x5500, 0);
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
+ stream(STREAM_POSITIONS, 0, b"Res3", &positions),
+ stream(STREAM_INDICES, 0, b"Res6", &indices),
+ stream(STREAM_BATCHES, 0, b"Res13", &batch),
+ ]))
+ .expect("nested");
+ let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
+
+ assert_eq!(model.batches[0].batch_flags, 0x1100);
+ assert_eq!(model.batches[0].material_index, 0x2200);
+ assert_eq!(model.batches[0].opaque4, 0x3300);
+ assert_eq!(model.batches[0].opaque6, 0x4400);
+ assert_eq!(model.batches[0].index_count, 1);
+ assert_eq!(model.batches[0].index_start, 0);
+ assert_eq!(model.batches[0].opaque14, 0x5500);
+ assert_eq!(model.batches[0].base_vertex, 0);
+ }
+
+ #[test]
+ fn auxiliary_and_extended_streams_are_preserved() {
+ let aux = [1, 2, 3, 4];
+ let ext18 = [5, 6];
+ let ext20 = [7, 8, 9];
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ stream(99, 0, b"Aux", &aux),
+ stream(18, 0, b"Res18", &ext18),
+ stream(20, 0, b"Res20", &ext20),
+ ]))
+ .expect("nested");
+ let msh = decode_msh(&document).expect("msh");
+ let preserved = msh.preserved_streams().expect("preserved");
+
+ assert_eq!(
+ preserved
+ .iter()
+ .map(|stream| (stream.type_id, stream.bytes.as_ref().to_vec()))
+ .collect::<Vec<_>>(),
+ vec![
+ (99, aux.to_vec()),
+ (18, ext18.to_vec()),
+ (20, ext20.to_vec())
+ ]
+ );
+ }
+
+ #[test]
+ fn mtcheck_variant_is_preserved_and_recognized() {
+ let marker = [0x4D, 0x54];
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ stream(21, 0, b"MTCHECK", &marker),
+ ]))
+ .expect("nested");
+ let msh = decode_msh(&document).expect("msh");
+
+ assert_eq!(msh.variant_id(), MshVariantId(1));
+ assert_eq!(msh.streams().last().expect("marker").name, b"MTCHECK");
+ }
+
+ #[test]
+ fn invalid_bounds_are_rejected() {
+ let slots = slots_payload(&[slot_record(0, 0, [2.0, 0.0, 0.0], [1.0, 1.0, 1.0], 1.0)]);
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ ]))
+ .expect("nested");
+
+ assert!(matches!(
+ validate_msh(&decode_msh(&document).expect("msh")),
+ Err(MshError::InvalidBounds { slot: 0 })
+ ));
+ }
+
+ #[test]
+ fn arbitrary_nested_payloads_are_bounded_and_panic_free() {
+ for payload in [
+ Vec::new(),
+ vec![0; 16],
+ build_nres(&[stream(STREAM_NODE_TABLE, 38, b"Res1", &[1, 2, 3])]),
+ build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &[0; 0x8b]),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[1]),
+ stream(STREAM_INDICES, 0, b"Res6", &[2]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[3]),
+ ]),
+ ] {
+ if let Ok(document) = decode_nested(&payload) {
+ let _ = decode_msh(&document).and_then(|msh| validate_msh(&msh).map(|_| ()));
+ }
+ }
+ }
+
+ #[test]
+ fn licensed_corpus_msh_assets_validate() {
+ for (corpus, expected) in [("IS", 435_usize), ("IS2", 511_usize)] {
+ let Some(root) = corpus_root(corpus) else {
+ continue;
+ };
+ let mut count = 0usize;
+ for path in files_under(&root) {
+ let Ok(bytes) = std::fs::read(&path) else {
+ continue;
+ };
+ let Ok(archive) = fparkan_nres::decode(
+ Arc::from(bytes.into_boxed_slice()),
+ ReadProfile::Compatible,
+ ) else {
+ continue;
+ };
+ for entry in archive
+ .entries()
+ .iter()
+ .filter(|entry| has_msh_extension(entry.name_bytes()))
+ {
+ let payload = archive.payload(entry.id()).expect("payload");
+ let nested = fparkan_nres::decode(
+ Arc::from(payload.to_vec().into_boxed_slice()),
+ ReadProfile::Compatible,
+ )
+ .unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
+ });
+ let msh = decode_msh(&nested).unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
+ });
+ validate_msh(&msh).unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
+ });
+ count += 1;
+ }
+ }
+ assert_eq!(count, expected, "{corpus} MSH count");
+ }
+ }
+
+ #[test]
+ fn licensed_corpus_animation_streams_sample_approved_pose_captures() {
+ for (
+ corpus,
+ expected_models,
+ expected_animated_models,
+ expected_node_samples,
+ expected_hash,
+ ) in [
+ (
+ "IS",
+ 435_usize,
+ 157_usize,
+ 3_469_usize,
+ 7_119_731_908_371_799_613_u64,
+ ),
+ (
+ "IS2",
+ 511_usize,
+ 200_usize,
+ 5_233_usize,
+ 13_040_438_305_408_523_893_u64,
+ ),
+ ] {
+ let Some(root) = corpus_root(corpus) else {
+ continue;
+ };
+ let mut models = 0usize;
+ let mut animated_models = 0usize;
+ let mut node_samples = 0usize;
+ let mut hash = FNV_OFFSET;
+ for path in files_under(&root) {
+ let Ok(bytes) = std::fs::read(&path) else {
+ continue;
+ };
+ let Ok(archive) = fparkan_nres::decode(
+ Arc::from(bytes.into_boxed_slice()),
+ ReadProfile::Compatible,
+ ) else {
+ continue;
+ };
+ for entry in archive
+ .entries()
+ .iter()
+ .filter(|entry| has_msh_extension(entry.name_bytes()))
+ {
+ let payload = archive.payload(entry.id()).expect("payload");
+ let nested = fparkan_nres::decode(
+ Arc::from(payload.to_vec().into_boxed_slice()),
+ ReadProfile::Compatible,
+ )
+ .unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
+ });
+ let msh = decode_msh(&nested).unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
+ });
+ let model = validate_msh(&msh).unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
+ });
+ let preserved = msh.preserved_streams().unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
+ });
+ let keys_stream = preserved
+ .iter()
+ .find(|stream| stream.type_id == STREAM_ANIMATION_KEYS)
+ .unwrap_or_else(|| {
+ panic!("{corpus} {path:?} {:?}: missing type 8", entry.name_bytes())
+ });
+ let frame_map_stream = preserved
+ .iter()
+ .find(|stream| stream.type_id == STREAM_ANIMATION_FRAME_MAP)
+ .unwrap_or_else(|| {
+ panic!(
+ "{corpus} {path:?} {:?}: missing type 19",
+ entry.name_bytes()
+ )
+ });
+ if !keys_stream.bytes.len().is_multiple_of(24)
+ || !frame_map_stream.bytes.len().is_multiple_of(2)
+ {
+ panic!(
+ "{corpus} {path:?} {:?}: invalid animation stream size",
+ entry.name_bytes()
+ );
+ }
+
+ let keys = decode_anim_keys(&keys_stream.bytes).unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: type 8: {err}", entry.name_bytes())
+ });
+ let frame_map = decode_frame_map_words(&frame_map_stream.bytes);
+ let frame_count = usize::try_from(frame_map_stream.attributes.attr2)
+ .expect("frame count fits usize");
+ models += 1;
+ hash_bytes(&mut hash, entry.name_bytes());
+ hash_usize(&mut hash, keys.len());
+ hash_usize(&mut hash, frame_map.len());
+
+ let mut model_is_animated = false;
+ if model.node_stride == 38 {
+ for node_index in 0..model.node_count {
+ let offset = node_index * model.node_stride;
+ let anim_map_start =
+ read_u16(&model.nodes_raw, offset + 4).expect("anim map");
+ let fallback_key =
+ read_u16(&model.nodes_raw, offset + 6).expect("fallback key");
+ let fallback_index = usize::from(fallback_key);
+ assert!(
+ fallback_index < keys.len(),
+ "{corpus} {path:?} {:?}: fallback key out of range",
+ entry.name_bytes()
+ );
+ let sample_frames = representative_frames(frame_count, anim_map_start);
+ if anim_map_start != u16::MAX {
+ let start = usize::from(anim_map_start);
+ assert!(
+ start
+ .checked_add(frame_count)
+ .is_some_and(|end| end <= frame_map.len()),
+ "{corpus} {path:?} {:?}: frame map range out of bounds",
+ entry.name_bytes()
+ );
+ model_is_animated = true;
+ }
+ for frame in sample_frames {
+ let pose = sample_node_pose(
+ &keys,
+ &frame_map,
+ frame_count,
+ anim_map_start,
+ fallback_index,
+ frame,
+ )
+ .unwrap_or_else(|err| {
+ let selected = selected_animation_key(
+ &frame_map,
+ frame_count,
+ anim_map_start,
+ fallback_index,
+ frame,
+ );
+ let selected_key = &keys[selected];
+ let next_key = keys.get(selected.saturating_add(1));
+ let fallback_key = &keys[fallback_index];
+ panic!(
+ "{corpus} {path:?} {:?}: node {node_index} frame {frame}: {err}; map_start={anim_map_start} fallback={fallback_index} selected={selected:?} frame_count={frame_count} selected_time={:?} selected_rot={:?} next={:?} fallback_time={:?} fallback_rot={:?}",
+ entry.name_bytes(),
+ selected_key.time,
+ selected_key.pose.rotation,
+ next_key.map(|key| (key.time, key.pose.rotation)),
+ fallback_key.time,
+ fallback_key.pose.rotation
+ )
+ });
+ let track = TimedPoseTrack::new(
+ pose,
+ vec![TimedPoseKey {
+ time: AnimationTime(frame as f32),
+ pose,
+ }],
+ )
+ .expect("single pose track");
+ let capture = canonical_timed_pose_capture(
+ &track,
+ &[AnimationTime(frame as f32)],
+ )
+ .expect("pose capture");
+ hash_usize(&mut hash, node_index);
+ hash_usize(&mut hash, frame);
+ hash_bytes(&mut hash, &capture);
+ node_samples += 1;
+ }
+ }
+ }
+ if model_is_animated {
+ animated_models += 1;
+ }
+ }
+ }
+
+ assert_eq!(
+ models, expected_models,
+ "{corpus} animated stream model count"
+ );
+ assert_eq!(
+ animated_models, expected_animated_models,
+ "{corpus} animated model count"
+ );
+ assert_eq!(node_samples, expected_node_samples, "{corpus} node samples");
+ assert_eq!(hash, expected_hash, "{corpus} animation capture hash");
+ }
+ }
+
+ fn decode_anim_keys(bytes: &[u8]) -> Result<Vec<AnimKey24>, fparkan_animation::AnimationError> {
+ bytes.chunks_exact(24).map(AnimKey24::decode).collect()
+ }
+
+ fn decode_frame_map_words(bytes: &[u8]) -> Vec<u16> {
+ bytes
+ .chunks_exact(2)
+ .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
+ .collect()
+ }
+
+ fn representative_frames(frame_count: usize, anim_map_start: u16) -> Vec<usize> {
+ if anim_map_start == u16::MAX || frame_count == 0 {
+ return vec![0];
+ }
+ let mut frames = vec![0, frame_count / 2, frame_count - 1];
+ frames.sort_unstable();
+ frames.dedup();
+ frames
+ }
+
+ fn sample_node_pose(
+ keys: &[AnimKey24],
+ frame_map: &[u16],
+ frame_count: usize,
+ anim_map_start: u16,
+ fallback_index: usize,
+ frame: usize,
+ ) -> Result<fparkan_animation::Pose, fparkan_animation::AnimationError> {
+ let key_index = selected_animation_key(
+ frame_map,
+ frame_count,
+ anim_map_start,
+ fallback_index,
+ frame,
+ );
+ sample_key_pair(keys, key_index, fallback_index, frame)
+ }
+
+ fn selected_animation_key(
+ frame_map: &[u16],
+ frame_count: usize,
+ anim_map_start: u16,
+ fallback_index: usize,
+ frame: usize,
+ ) -> usize {
+ if anim_map_start == u16::MAX || frame >= frame_count {
+ return fallback_index;
+ }
+ let mapped = frame_map[usize::from(anim_map_start) + frame];
+ if usize::from(mapped) >= fallback_index {
+ fallback_index
+ } else {
+ usize::from(mapped)
+ }
+ }
+
+ fn sample_key_pair(
+ keys: &[AnimKey24],
+ key_index: usize,
+ fallback_index: usize,
+ frame: usize,
+ ) -> Result<fparkan_animation::Pose, fparkan_animation::AnimationError> {
+ if key_index == fallback_index {
+ return Ok(keys[fallback_index].sampling_pose());
+ }
+ let next_index = key_index.saturating_add(1);
+ if next_index >= keys.len() || keys[next_index].time.0 <= keys[key_index].time.0 {
+ return Ok(keys[key_index].sampling_pose());
+ }
+ let track = TimedPoseTrack::new(
+ keys[key_index].sampling_pose(),
+ vec![
+ TimedPoseKey {
+ time: keys[key_index].time,
+ pose: keys[key_index].sampling_pose(),
+ },
+ TimedPoseKey {
+ time: keys[next_index].time,
+ pose: keys[next_index].sampling_pose(),
+ },
+ ],
+ )?;
+ track.sample(AnimationTime(frame as f32))
+ }
+
+ fn decode_nested(bytes: &[u8]) -> Result<NresDocument, NresError> {
+ fparkan_nres::decode(
+ Arc::from(bytes.to_vec().into_boxed_slice()),
+ ReadProfile::Compatible,
+ )
+ }
+
+ fn minimal_msh_bytes() -> Vec<u8> {
+ build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ ])
+ }
+
+ fn stream<'a>(type_id: u32, attr3: u32, name: &'a [u8], payload: &'a [u8]) -> TestEntry<'a> {
+ TestEntry {
+ type_id,
+ attr3,
+ name,
+ payload,
+ }
+ }
+
+ struct TestEntry<'a> {
+ type_id: u32,
+ attr3: u32,
+ name: &'a [u8],
+ payload: &'a [u8],
+ }
+
+ fn build_nres(entries: &[TestEntry<'_>]) -> Vec<u8> {
+ let mut out = vec![0; 16];
+ let mut offsets = Vec::with_capacity(entries.len());
+ for entry in entries {
+ offsets.push(u32::try_from(out.len()).expect("offset"));
+ out.extend_from_slice(entry.payload);
+ let padding = (8 - (out.len() % 8)) % 8;
+ out.resize(out.len() + padding, 0);
+ }
+ let mut order: Vec<usize> = (0..entries.len()).collect();
+ order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name));
+ for (idx, entry) in entries.iter().enumerate() {
+ push_u32(&mut out, entry.type_id);
+ push_u32(&mut out, 0);
+ push_u32(&mut out, 0);
+ push_u32(
+ &mut out,
+ u32::try_from(entry.payload.len()).expect("payload"),
+ );
+ push_u32(&mut out, entry.attr3);
+ let mut name_raw = [0; 36];
+ copy_cstr(&mut name_raw, entry.name);
+ out.extend_from_slice(&name_raw);
+ push_u32(&mut out, offsets[idx]);
+ push_u32(&mut out, u32::try_from(order[idx]).expect("sort index"));
+ }
+ out[0..4].copy_from_slice(b"NRes");
+ out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
+ out[8..12].copy_from_slice(&u32::try_from(entries.len()).expect("count").to_le_bytes());
+ let total_size = u32::try_from(out.len()).expect("total size");
+ out[12..16].copy_from_slice(&total_size.to_le_bytes());
+ out
+ }
+
+ fn push_u16(out: &mut Vec<u8>, value: u16) {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn push_u32(out: &mut Vec<u8>, value: u32) {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn push_f32(out: &mut Vec<u8>, value: f32) {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn node38(slots: [u16; 15]) -> Vec<u8> {
+ let mut out = vec![0; 8];
+ for slot in slots {
+ push_u16(&mut out, slot);
+ }
+ out
+ }
+
+ fn slots_payload(records: &[Vec<u8>]) -> Vec<u8> {
+ let mut out = vec![0; 0x8c];
+ for record in records {
+ assert_eq!(record.len(), 68);
+ out.extend_from_slice(record);
+ }
+ out
+ }
+
+ fn slot_record(
+ batch_start: u16,
+ batch_count: u16,
+ aabb_min: [f32; 3],
+ aabb_max: [f32; 3],
+ sphere_radius: f32,
+ ) -> Vec<u8> {
+ let mut out = Vec::new();
+ push_u16(&mut out, 0);
+ push_u16(&mut out, 0);
+ push_u16(&mut out, batch_start);
+ push_u16(&mut out, batch_count);
+ for value in aabb_min {
+ push_f32(&mut out, value);
+ }
+ for value in aabb_max {
+ push_f32(&mut out, value);
+ }
+ for value in [0.0, 0.0, 0.0] {
+ push_f32(&mut out, value);
+ }
+ push_f32(&mut out, sphere_radius);
+ for _ in 0..5 {
+ push_u32(&mut out, 0);
+ }
+ out
+ }
+
+ fn positions_payload(values: &[[f32; 3]]) -> Vec<u8> {
+ let mut out = Vec::new();
+ for position in values {
+ for value in position {
+ push_f32(&mut out, *value);
+ }
+ }
+ out
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn batch_record(
+ batch_flags: u16,
+ material_index: u16,
+ opaque4: u16,
+ opaque6: u16,
+ index_count: u16,
+ index_start: u32,
+ opaque14: u16,
+ base_vertex: u32,
+ ) -> Vec<u8> {
+ let mut out = Vec::new();
+ push_u16(&mut out, batch_flags);
+ push_u16(&mut out, material_index);
+ push_u16(&mut out, opaque4);
+ push_u16(&mut out, opaque6);
+ push_u16(&mut out, index_count);
+ push_u32(&mut out, index_start);
+ push_u16(&mut out, opaque14);
+ push_u32(&mut out, base_vertex);
+ out
+ }
+
+ fn copy_cstr(dst: &mut [u8], src: &[u8]) {
+ let len = dst.len().saturating_sub(1).min(src.len());
+ dst[..len].copy_from_slice(&src[..len]);
+ }
+
+ fn has_msh_extension(name: &[u8]) -> bool {
+ name.len() >= 4 && name[name.len() - 4..].eq_ignore_ascii_case(b".msh")
+ }
+
+ fn corpus_root(name: &str) -> Option<PathBuf> {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("testdata")
+ .join(name);
+ root.is_dir().then_some(root)
+ }
+
+ fn files_under(root: &Path) -> Vec<PathBuf> {
+ let mut out = Vec::new();
+ let mut stack = vec![root.to_path_buf()];
+ while let Some(path) = stack.pop() {
+ let Ok(read_dir) = std::fs::read_dir(path) else {
+ continue;
+ };
+ for entry in read_dir.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ stack.push(path);
+ } else {
+ out.push(path);
+ }
+ }
+ }
+ out.sort();
+ out
+ }
+
+ const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
+ const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
+
+ fn hash_bytes(hash: &mut u64, bytes: &[u8]) {
+ for byte in bytes {
+ *hash ^= u64::from(*byte);
+ *hash = hash.wrapping_mul(FNV_PRIME);
+ }
+ }
+
+ fn hash_usize(hash: &mut u64, value: usize) {
+ hash_bytes(hash, &value.to_le_bytes());
+ }
+}
diff --git a/crates/fparkan-nres/Cargo.toml b/crates/fparkan-nres/Cargo.toml
new file mode 100644
index 0000000..c0433eb
--- /dev/null
+++ b/crates/fparkan-nres/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "fparkan-nres"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+fparkan-binary = { path = "../fparkan-binary" }
+fparkan-path = { path = "../fparkan-path" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-nres/src/lib.rs b/crates/fparkan-nres/src/lib.rs
new file mode 100644
index 0000000..f2bd106
--- /dev/null
+++ b/crates/fparkan-nres/src/lib.rs
@@ -0,0 +1,1935 @@
+#![forbid(unsafe_code)]
+//! Strict and lossless `NRes` archive support.
+
+use fparkan_binary::{Cursor, DecodeError};
+use fparkan_path::{ascii_lookup_key, LookupKey};
+use std::cmp::Ordering;
+use std::fmt;
+use std::ops::Range;
+use std::sync::Arc;
+
+const HEADER_LEN: usize = 16;
+const HEADER_LEN_U32: u32 = 16;
+const ENTRY_LEN: usize = 64;
+const NAME_LEN: usize = 36;
+const VERSION_0100: u32 = 0x100;
+
+/// Read profile.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ReadProfile {
+ /// Reject malformed lookup tables and directory invariants.
+ Strict,
+ /// Keep the document readable when the lookup table is invalid.
+ Compatible,
+}
+
+/// Write profile.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum WriteProfile {
+ /// Return the original byte image when no edit model is active.
+ Lossless,
+ /// Repack active payloads and rebuild the lookup table.
+ CanonicalCompact,
+}
+
+/// `NRes` archive header.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct NresHeader {
+ /// Archive format version.
+ pub version: u32,
+ /// Number of directory entries.
+ pub entry_count: u32,
+ /// Total byte size declared by the header.
+ pub total_size: u32,
+ /// Directory byte offset.
+ pub directory_offset: u32,
+}
+
+/// `NRes` entry identifier in original directory order.
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct EntryId(pub u32);
+
+/// `NRes` entry metadata.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct EntryMeta {
+ /// Entry type identifier.
+ pub type_id: u32,
+ /// Opaque attribute 1.
+ pub attr1: u32,
+ /// Opaque attribute 2.
+ pub attr2: u32,
+ /// Opaque attribute 3.
+ pub attr3: u32,
+ /// Decoded byte-for-byte ASCII-style resource name.
+ pub name: String,
+ /// Payload byte offset.
+ pub data_offset: u32,
+ /// Payload byte size.
+ pub data_size: u32,
+ /// Lookup table value stored at this sorted position.
+ pub sort_index: u32,
+}
+
+/// `NRes` entry.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct NresEntry {
+ id: EntryId,
+ meta: EntryMeta,
+ name_raw: [u8; NAME_LEN],
+ data_range: Range<usize>,
+}
+
+/// Preserved bytes that are not referenced by any entry.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PreservedRegion {
+ /// Byte range in the original archive.
+ pub range: Range<u32>,
+ /// Whether the whole range consists of zero bytes.
+ pub all_zero: bool,
+}
+
+/// Parsed `NRes` document.
+#[derive(Clone, Debug)]
+pub struct NresDocument {
+ bytes: Arc<[u8]>,
+ header: NresHeader,
+ entries: Vec<NresEntry>,
+ lookup_order_valid: bool,
+ preserved_regions: Vec<PreservedRegion>,
+}
+
+/// Editable `NRes` document.
+#[derive(Clone, Debug)]
+pub struct NresEditor {
+ entries: Vec<EditableEntry>,
+}
+
+#[derive(Clone, Debug)]
+struct EditableEntry {
+ type_id: u32,
+ attr1: u32,
+ attr2: u32,
+ attr3: u32,
+ name_raw: [u8; NAME_LEN],
+ payload: Vec<u8>,
+}
+
+/// `NRes` parse or write error.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum NresError {
+ /// The input is not an `NRes` archive.
+ InvalidMagic {
+ /// First four bytes, padded when the file is shorter.
+ got: [u8; 4],
+ },
+ /// Unsupported format version.
+ UnsupportedVersion {
+ /// Observed version.
+ got: u32,
+ },
+ /// Entry count is negative.
+ InvalidEntryCount {
+ /// Observed signed count.
+ got: i32,
+ },
+ /// Header size does not match the byte slice length.
+ TotalSizeMismatch {
+ /// Header value.
+ header: u32,
+ /// Actual byte length.
+ actual: u64,
+ },
+ /// Directory range is outside the archive.
+ DirectoryOutOfBounds {
+ /// Computed directory offset.
+ offset: u64,
+ /// Computed directory length.
+ len: u64,
+ /// Actual byte length.
+ file_len: u64,
+ },
+ /// Entry payload range is outside the data region.
+ EntryDataOutOfBounds {
+ /// Entry id.
+ id: u32,
+ /// Payload offset.
+ offset: u32,
+ /// Payload size.
+ size: u32,
+ /// Directory offset.
+ directory_offset: u32,
+ },
+ /// Active payload ranges overlap.
+ EntryDataOverlap {
+ /// Earlier entry id.
+ first: u32,
+ /// Later entry id.
+ second: u32,
+ },
+ /// Entry name has no zero terminator inside the fixed field.
+ MissingNameTerminator {
+ /// Entry id.
+ id: u32,
+ },
+ /// Entry name is empty.
+ EmptyName {
+ /// Entry id.
+ id: u32,
+ },
+ /// Lookup value points outside the directory.
+ SortIndexOutOfRange {
+ /// Sorted table position.
+ position: u32,
+ /// Stored index.
+ index: u32,
+ /// Entry count.
+ entry_count: u32,
+ },
+ /// Lookup table is not a permutation.
+ SortIndexDuplicate {
+ /// Duplicated original entry index.
+ index: u32,
+ },
+ /// Lookup table is a permutation but not sorted by ASCII-casefolded names.
+ SortOrderMismatch {
+ /// Sorted table position.
+ position: u32,
+ },
+ /// Entry id is outside this archive.
+ EntryIdOutOfRange {
+ /// Entry id.
+ id: u32,
+ /// Entry count.
+ entry_count: u32,
+ },
+ /// Authoring name is too long for the fixed `NRes` field.
+ AuthoringNameTooLong {
+ /// Observed byte length.
+ len: usize,
+ /// Maximum useful byte length before the required NUL terminator.
+ max: usize,
+ },
+ /// Authoring name contains an embedded NUL byte.
+ AuthoringNameContainsNul {
+ /// Byte offset.
+ offset: usize,
+ },
+ /// Arithmetic overflow or failed bounded read.
+ Binary(DecodeError),
+}
+
+impl fmt::Display for NresError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::InvalidMagic { got } => write!(f, "invalid NRes magic: {got:02X?}"),
+ Self::UnsupportedVersion { got } => {
+ write!(f, "unsupported NRes version: {got:#x}")
+ }
+ Self::InvalidEntryCount { got } => write!(f, "invalid NRes entry count: {got}"),
+ Self::TotalSizeMismatch { header, actual } => {
+ write!(f, "NRes total size mismatch: header={header}, actual={actual}")
+ }
+ Self::DirectoryOutOfBounds {
+ offset,
+ len,
+ file_len,
+ } => write!(
+ f,
+ "NRes directory out of bounds: offset={offset}, len={len}, file={file_len}"
+ ),
+ Self::EntryDataOutOfBounds {
+ id,
+ offset,
+ size,
+ directory_offset,
+ } => write!(
+ f,
+ "NRes entry #{id} data out of bounds: offset={offset}, size={size}, directory={directory_offset}"
+ ),
+ Self::EntryDataOverlap { first, second } => {
+ write!(f, "NRes entries #{first} and #{second} overlap")
+ }
+ Self::MissingNameTerminator { id } => {
+ write!(f, "NRes entry #{id} name has no NUL terminator")
+ }
+ Self::EmptyName { id } => write!(f, "NRes entry #{id} name is empty"),
+ Self::SortIndexOutOfRange {
+ position,
+ index,
+ entry_count,
+ } => write!(
+ f,
+ "NRes sort index out of range at position {position}: {index} >= {entry_count}"
+ ),
+ Self::SortIndexDuplicate { index } => {
+ write!(f, "NRes duplicate sort index: {index}")
+ }
+ Self::SortOrderMismatch { position } => {
+ write!(f, "NRes sort order mismatch at position {position}")
+ }
+ Self::EntryIdOutOfRange { id, entry_count } => {
+ write!(f, "NRes entry id out of range: {id} >= {entry_count}")
+ }
+ Self::AuthoringNameTooLong { len, max } => {
+ write!(f, "NRes authoring name too long: {len} > {max}")
+ }
+ Self::AuthoringNameContainsNul { offset } => {
+ write!(f, "NRes authoring name contains NUL at byte {offset}")
+ }
+ Self::Binary(source) => write!(f, "{source}"),
+ }
+ }
+}
+
+impl std::error::Error for NresError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Binary(source) => Some(source),
+ Self::InvalidMagic { .. }
+ | Self::UnsupportedVersion { .. }
+ | Self::InvalidEntryCount { .. }
+ | Self::TotalSizeMismatch { .. }
+ | Self::DirectoryOutOfBounds { .. }
+ | Self::EntryDataOutOfBounds { .. }
+ | Self::EntryDataOverlap { .. }
+ | Self::MissingNameTerminator { .. }
+ | Self::EmptyName { .. }
+ | Self::SortIndexOutOfRange { .. }
+ | Self::SortIndexDuplicate { .. }
+ | Self::SortOrderMismatch { .. }
+ | Self::EntryIdOutOfRange { .. }
+ | Self::AuthoringNameTooLong { .. }
+ | Self::AuthoringNameContainsNul { .. } => None,
+ }
+ }
+}
+
+impl From<DecodeError> for NresError {
+ fn from(value: DecodeError) -> Self {
+ Self::Binary(value)
+ }
+}
+
+/// Decodes `NRes` bytes.
+///
+/// # Errors
+///
+/// Returns [`NresError`] when the header, directory, payload ranges, or strict
+/// lookup permutation are malformed for the selected [`ReadProfile`].
+pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<NresDocument, NresError> {
+ let header = parse_header(&bytes)?;
+ let entries = parse_entries(&bytes, &header)?;
+ validate_names(&entries)?;
+ validate_payload_ranges(&entries)?;
+ let lookup_order_valid = match validate_lookup_order(&entries) {
+ Ok(valid) => valid,
+ Err(err) if profile == ReadProfile::Strict => return Err(err),
+ Err(_) => false,
+ };
+ let preserved_regions = find_preserved_regions(&bytes, &entries, header.directory_offset)?;
+ Ok(NresDocument {
+ bytes,
+ header,
+ entries,
+ lookup_order_valid,
+ preserved_regions,
+ })
+}
+
+impl NresDocument {
+ /// Returns the archive header.
+ #[must_use]
+ pub fn header(&self) -> &NresHeader {
+ &self.header
+ }
+
+ /// Entry count.
+ #[must_use]
+ pub fn entry_count(&self) -> usize {
+ self.entries.len()
+ }
+
+ /// Returns all entries in original directory order.
+ #[must_use]
+ pub fn entries(&self) -> &[NresEntry] {
+ &self.entries
+ }
+
+ /// Whether the lookup table is valid and sorted.
+ #[must_use]
+ pub fn lookup_order_valid(&self) -> bool {
+ self.lookup_order_valid
+ }
+
+ /// Returns preserved ranges outside active payloads.
+ #[must_use]
+ pub fn preserved_regions(&self) -> &[PreservedRegion] {
+ &self.preserved_regions
+ }
+
+ /// Whether any unindexed preserved region contains non-zero bytes.
+ #[must_use]
+ pub fn has_nonzero_preserved_region(&self) -> bool {
+ self.preserved_regions.iter().any(|region| !region.all_zero)
+ }
+
+ /// Finds an entry by ASCII-case-insensitive name.
+ #[must_use]
+ pub fn find(&self, name: &str) -> Option<EntryId> {
+ self.find_bytes(name.as_bytes())
+ }
+
+ /// Finds an entry by ASCII-case-insensitive raw name bytes.
+ #[must_use]
+ pub fn find_bytes(&self, name: &[u8]) -> Option<EntryId> {
+ if self.lookup_order_valid {
+ return self.find_by_lookup(name);
+ }
+ self.entries
+ .iter()
+ .find(|entry| cmp_ascii_casefold(name, entry.name_bytes()) == Ordering::Equal)
+ .map(NresEntry::id)
+ }
+
+ /// Returns an entry by id.
+ #[must_use]
+ pub fn entry(&self, id: EntryId) -> Option<&NresEntry> {
+ self.entries.get(usize::try_from(id.0).ok()?)
+ }
+
+ /// Returns an entry payload.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`NresError::EntryIdOutOfRange`] when `id` is not present in
+ /// this document.
+ pub fn payload(&self, id: EntryId) -> Result<&[u8], NresError> {
+ let entry = self.entry(id).ok_or_else(|| NresError::EntryIdOutOfRange {
+ id: id.0,
+ entry_count: saturating_u32_len(self.entries.len()),
+ })?;
+ Ok(&self.bytes[entry.data_range.clone()])
+ }
+
+ /// Encodes the document according to the selected write profile.
+ #[must_use]
+ pub fn encode(&self, profile: WriteProfile) -> Vec<u8> {
+ match profile {
+ WriteProfile::Lossless => self.bytes.to_vec(),
+ WriteProfile::CanonicalCompact => self.encode_canonical_compact(),
+ }
+ }
+
+ /// Creates an editor initialized from this document.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`NresError`] if any source payload cannot be copied by id.
+ pub fn editor(&self) -> Result<NresEditor, NresError> {
+ NresEditor::from_document(self)
+ }
+
+ fn find_by_lookup(&self, needle: &[u8]) -> Option<EntryId> {
+ let mut low = 0usize;
+ let mut high = self.entries.len();
+ while low < high {
+ let mid = low + (high - low) / 2;
+ let entry_idx = usize::try_from(self.entries[mid].meta.sort_index).ok()?;
+ let entry = self.entries.get(entry_idx)?;
+ match cmp_ascii_casefold(needle, entry.name_bytes()) {
+ Ordering::Less => high = mid,
+ Ordering::Greater => low = mid.saturating_add(1),
+ Ordering::Equal => {
+ return self
+ .entries
+ .iter()
+ .find(|entry| {
+ cmp_ascii_casefold(needle, entry.name_bytes()) == Ordering::Equal
+ })
+ .map(NresEntry::id);
+ }
+ }
+ }
+ None
+ }
+
+ fn encode_canonical_compact(&self) -> Vec<u8> {
+ let mut out = vec![0; HEADER_LEN];
+ let mut offsets = Vec::with_capacity(self.entries.len());
+ let mut sizes = Vec::with_capacity(self.entries.len());
+ for entry in &self.entries {
+ offsets.push(saturating_u32_len(out.len()));
+ let payload = &self.bytes[entry.data_range.clone()];
+ sizes.push(saturating_u32_len(payload.len()));
+ out.extend_from_slice(payload);
+ let padding = (8 - (out.len() % 8)) % 8;
+ out.resize(out.len() + padding, 0);
+ }
+
+ let sort_order = build_sort_order(&self.entries);
+ for (index, entry) in self.entries.iter().enumerate() {
+ push_u32(&mut out, entry.meta.type_id);
+ push_u32(&mut out, entry.meta.attr1);
+ push_u32(&mut out, entry.meta.attr2);
+ push_u32(&mut out, sizes[index]);
+ push_u32(&mut out, entry.meta.attr3);
+ out.extend_from_slice(&entry.name_raw);
+ push_u32(&mut out, offsets[index]);
+ push_u32(&mut out, saturating_u32_len(sort_order[index]));
+ }
+
+ let total_size = saturating_u32_len(out.len());
+ out[0..4].copy_from_slice(b"NRes");
+ out[4..8].copy_from_slice(&VERSION_0100.to_le_bytes());
+ out[8..12].copy_from_slice(&saturating_u32_len(self.entries.len()).to_le_bytes());
+ out[12..16].copy_from_slice(&total_size.to_le_bytes());
+ out
+ }
+}
+
+impl NresEditor {
+ /// Creates an editor from an existing document.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`NresError`] if any source payload cannot be copied by id.
+ pub fn from_document(document: &NresDocument) -> Result<Self, NresError> {
+ let mut entries = Vec::with_capacity(document.entries.len());
+ for entry in &document.entries {
+ let meta = entry.meta();
+ entries.push(EditableEntry {
+ type_id: meta.type_id,
+ attr1: meta.attr1,
+ attr2: meta.attr2,
+ attr3: meta.attr3,
+ name_raw: entry.name_raw,
+ payload: document.payload(entry.id())?.to_vec(),
+ });
+ }
+ Ok(Self { entries })
+ }
+
+ /// Replaces an entry payload.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`NresError::EntryIdOutOfRange`] when `id` is not present.
+ pub fn set_payload(
+ &mut self,
+ id: EntryId,
+ payload: impl Into<Vec<u8>>,
+ ) -> Result<(), NresError> {
+ let entry = self.entry_mut(id)?;
+ entry.payload = payload.into();
+ Ok(())
+ }
+
+ /// Renames an entry.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`NresError::EntryIdOutOfRange`] when `id` is not present, or
+ /// a name authoring error when `name` cannot be stored in the fixed field.
+ pub fn rename(&mut self, id: EntryId, name: impl AsRef<[u8]>) -> Result<(), NresError> {
+ let name_raw = authoring_name_raw(name.as_ref())?;
+ let entry = self.entry_mut(id)?;
+ entry.name_raw = name_raw;
+ Ok(())
+ }
+
+ /// Encodes the edited document in canonical compact form.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`NresError`] when offsets or sizes exceed the on-disk `u32`
+ /// representation.
+ pub fn encode(&self) -> Result<Vec<u8>, NresError> {
+ let mut out = vec![0; HEADER_LEN];
+ let mut offsets = Vec::with_capacity(self.entries.len());
+ let mut sizes = Vec::with_capacity(self.entries.len());
+ for entry in &self.entries {
+ offsets.push(checked_u32_len(out.len())?);
+ sizes.push(checked_u32_len(entry.payload.len())?);
+ out.extend_from_slice(&entry.payload);
+ let padding = (8 - (out.len() % 8)) % 8;
+ out.resize(
+ out.len()
+ .checked_add(padding)
+ .ok_or(DecodeError::IntegerOverflow)?,
+ 0,
+ );
+ }
+
+ let sort_order = build_edit_sort_order(&self.entries);
+ for (index, entry) in self.entries.iter().enumerate() {
+ push_u32(&mut out, entry.type_id);
+ push_u32(&mut out, entry.attr1);
+ push_u32(&mut out, entry.attr2);
+ push_u32(&mut out, sizes[index]);
+ push_u32(&mut out, entry.attr3);
+ out.extend_from_slice(&entry.name_raw);
+ push_u32(&mut out, offsets[index]);
+ push_u32(&mut out, checked_u32_len(sort_order[index])?);
+ }
+
+ let total_size = checked_u32_len(out.len())?;
+ out[0..4].copy_from_slice(b"NRes");
+ out[4..8].copy_from_slice(&VERSION_0100.to_le_bytes());
+ out[8..12].copy_from_slice(&checked_u32_len(self.entries.len())?.to_le_bytes());
+ out[12..16].copy_from_slice(&total_size.to_le_bytes());
+ Ok(out)
+ }
+
+ fn entry_mut(&mut self, id: EntryId) -> Result<&mut EditableEntry, NresError> {
+ let entry_count = saturating_u32_len(self.entries.len());
+ self.entries
+ .get_mut(
+ usize::try_from(id.0).map_err(|_| NresError::EntryIdOutOfRange {
+ id: id.0,
+ entry_count,
+ })?,
+ )
+ .ok_or(NresError::EntryIdOutOfRange {
+ id: id.0,
+ entry_count,
+ })
+ }
+}
+
+impl NresEntry {
+ /// Entry id in original directory order.
+ #[must_use]
+ pub fn id(&self) -> EntryId {
+ self.id
+ }
+
+ /// Entry metadata.
+ #[must_use]
+ pub fn meta(&self) -> &EntryMeta {
+ &self.meta
+ }
+
+ /// Raw fixed-size name field.
+ #[must_use]
+ pub fn name_raw(&self) -> &[u8; NAME_LEN] {
+ &self.name_raw
+ }
+
+ /// Active payload range in the original archive.
+ #[must_use]
+ pub fn data_range(&self) -> Range<usize> {
+ self.data_range.clone()
+ }
+
+ /// Raw name bytes before the first NUL terminator.
+ #[must_use]
+ pub fn name_bytes(&self) -> &[u8] {
+ let len = name_len(&self.name_raw).unwrap_or(NAME_LEN);
+ &self.name_raw[..len]
+ }
+}
+
+fn parse_header(bytes: &[u8]) -> Result<NresHeader, NresError> {
+ if bytes.len() < HEADER_LEN {
+ let mut got = [0; 4];
+ let copy_len = bytes.len().min(4);
+ got[..copy_len].copy_from_slice(&bytes[..copy_len]);
+ return Err(NresError::InvalidMagic { got });
+ }
+ if &bytes[..4] != b"NRes" {
+ let mut got = [0; 4];
+ got.copy_from_slice(&bytes[..4]);
+ return Err(NresError::InvalidMagic { got });
+ }
+
+ let mut cursor = Cursor::new(bytes);
+ let _magic = cursor.read_exact(4)?;
+ let version = cursor.read_u32_le()?;
+ if version != VERSION_0100 {
+ return Err(NresError::UnsupportedVersion { got: version });
+ }
+ let entry_count_signed = cursor.read_i32_le()?;
+ if entry_count_signed < 0 {
+ return Err(NresError::InvalidEntryCount {
+ got: entry_count_signed,
+ });
+ }
+ let entry_count =
+ u32::try_from(entry_count_signed).map_err(|_| DecodeError::IntegerOverflow)?;
+ let total_size = cursor.read_u32_le()?;
+ let actual = u64::try_from(bytes.len()).map_err(|_| DecodeError::IntegerOverflow)?;
+ if u64::from(total_size) != actual {
+ return Err(NresError::TotalSizeMismatch {
+ header: total_size,
+ actual,
+ });
+ }
+ let directory_len = u64::from(entry_count)
+ .checked_mul(ENTRY_LEN as u64)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let directory_offset = u64::from(total_size).checked_sub(directory_len).ok_or(
+ NresError::DirectoryOutOfBounds {
+ offset: 0,
+ len: directory_len,
+ file_len: actual,
+ },
+ )?;
+ if directory_offset < HEADER_LEN as u64
+ || directory_offset
+ .checked_add(directory_len)
+ .ok_or(DecodeError::IntegerOverflow)?
+ != actual
+ {
+ return Err(NresError::DirectoryOutOfBounds {
+ offset: directory_offset,
+ len: directory_len,
+ file_len: actual,
+ });
+ }
+ Ok(NresHeader {
+ version,
+ entry_count,
+ total_size,
+ directory_offset: u32::try_from(directory_offset)
+ .map_err(|_| DecodeError::IntegerOverflow)?,
+ })
+}
+
+fn parse_entries(bytes: &[u8], header: &NresHeader) -> Result<Vec<NresEntry>, NresError> {
+ let mut entries = Vec::with_capacity(header.entry_count as usize);
+ let directory_offset =
+ usize::try_from(header.directory_offset).map_err(|_| DecodeError::IntegerOverflow)?;
+ for index in 0..header.entry_count {
+ let index_usize = usize::try_from(index).map_err(|_| DecodeError::IntegerOverflow)?;
+ let entry_offset = directory_offset
+ .checked_add(
+ index_usize
+ .checked_mul(ENTRY_LEN)
+ .ok_or(DecodeError::IntegerOverflow)?,
+ )
+ .ok_or(DecodeError::IntegerOverflow)?;
+ entries.push(parse_entry(
+ bytes,
+ entry_offset,
+ index,
+ header.directory_offset,
+ )?);
+ }
+ Ok(entries)
+}
+
+fn parse_entry(
+ bytes: &[u8],
+ offset: usize,
+ id: u32,
+ directory_offset: u32,
+) -> Result<NresEntry, NresError> {
+ let entry_bytes = bytes
+ .get(offset..offset + ENTRY_LEN)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let mut cursor = Cursor::new(entry_bytes);
+ let type_id = cursor.read_u32_le()?;
+ let attr1 = cursor.read_u32_le()?;
+ let attr2 = cursor.read_u32_le()?;
+ let data_size = cursor.read_u32_le()?;
+ let attr3 = cursor.read_u32_le()?;
+ let name_slice = cursor.read_exact(NAME_LEN)?;
+ let mut name_raw = [0; NAME_LEN];
+ name_raw.copy_from_slice(name_slice);
+ let Some(name_len) = name_len(&name_raw) else {
+ return Err(NresError::MissingNameTerminator { id });
+ };
+ let name = name_raw[..name_len]
+ .iter()
+ .map(|byte| char::from(*byte))
+ .collect();
+ let data_offset = cursor.read_u32_le()?;
+ let sort_index = cursor.read_u32_le()?;
+ cursor.require_eof()?;
+
+ let data_end = data_offset
+ .checked_add(data_size)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ if data_offset < HEADER_LEN_U32 || data_end > directory_offset {
+ return Err(NresError::EntryDataOutOfBounds {
+ id,
+ offset: data_offset,
+ size: data_size,
+ directory_offset,
+ });
+ }
+
+ Ok(NresEntry {
+ id: EntryId(id),
+ meta: EntryMeta {
+ type_id,
+ attr1,
+ attr2,
+ attr3,
+ name,
+ data_offset,
+ data_size,
+ sort_index,
+ },
+ name_raw,
+ data_range: usize::try_from(data_offset).map_err(|_| DecodeError::IntegerOverflow)?
+ ..usize::try_from(data_end).map_err(|_| DecodeError::IntegerOverflow)?,
+ })
+}
+
+fn validate_payload_ranges(entries: &[NresEntry]) -> Result<(), NresError> {
+ let mut ranges: Vec<(u32, Range<usize>)> = entries
+ .iter()
+ .map(|entry| (entry.id.0, entry.data_range.clone()))
+ .collect();
+ ranges.sort_by(|left, right| {
+ left.1
+ .start
+ .cmp(&right.1.start)
+ .then_with(|| left.1.end.cmp(&right.1.end))
+ });
+ for pair in ranges.windows(2) {
+ if pair[0].1.end > pair[1].1.start {
+ return Err(NresError::EntryDataOverlap {
+ first: pair[0].0,
+ second: pair[1].0,
+ });
+ }
+ }
+ Ok(())
+}
+
+fn validate_names(entries: &[NresEntry]) -> Result<(), NresError> {
+ for entry in entries {
+ if entry.name_bytes().is_empty() {
+ return Err(NresError::EmptyName { id: entry.id.0 });
+ }
+ }
+ Ok(())
+}
+
+fn validate_lookup_order(entries: &[NresEntry]) -> Result<bool, NresError> {
+ let entry_count = saturating_u32_len(entries.len());
+ let mut seen = vec![false; entries.len()];
+ for (position, entry) in entries.iter().enumerate() {
+ let index = entry.meta.sort_index;
+ if index >= entry_count {
+ return Err(NresError::SortIndexOutOfRange {
+ position: saturating_u32_len(position),
+ index,
+ entry_count,
+ });
+ }
+ let index_usize = usize::try_from(index).map_err(|_| DecodeError::IntegerOverflow)?;
+ if seen[index_usize] {
+ return Err(NresError::SortIndexDuplicate { index });
+ }
+ seen[index_usize] = true;
+ }
+ for pair in entries.windows(2) {
+ let left_index =
+ usize::try_from(pair[0].meta.sort_index).map_err(|_| DecodeError::IntegerOverflow)?;
+ let right_index =
+ usize::try_from(pair[1].meta.sort_index).map_err(|_| DecodeError::IntegerOverflow)?;
+ let left = entries[left_index].name_bytes();
+ let right = entries[right_index].name_bytes();
+ if cmp_ascii_casefold(left, right) == Ordering::Greater {
+ return Ok(false);
+ }
+ }
+ Ok(true)
+}
+
+fn find_preserved_regions(
+ bytes: &[u8],
+ entries: &[NresEntry],
+ directory_offset: u32,
+) -> Result<Vec<PreservedRegion>, NresError> {
+ let mut ranges: Vec<Range<usize>> = entries
+ .iter()
+ .map(|entry| entry.data_range.clone())
+ .collect();
+ ranges.sort_by(|left, right| {
+ left.start
+ .cmp(&right.start)
+ .then_with(|| left.end.cmp(&right.end))
+ });
+
+ let mut cursor = HEADER_LEN;
+ let directory_offset =
+ usize::try_from(directory_offset).map_err(|_| DecodeError::IntegerOverflow)?;
+ let mut preserved = Vec::new();
+ for range in ranges {
+ if cursor < range.start {
+ preserved.push(make_preserved_region(bytes, cursor..range.start)?);
+ }
+ cursor = cursor.max(range.end);
+ }
+ if cursor < directory_offset {
+ preserved.push(make_preserved_region(bytes, cursor..directory_offset)?);
+ }
+ Ok(preserved)
+}
+
+fn make_preserved_region(bytes: &[u8], range: Range<usize>) -> Result<PreservedRegion, NresError> {
+ let all_zero = bytes[range.clone()].iter().all(|byte| *byte == 0);
+ Ok(PreservedRegion {
+ range: u32::try_from(range.start).map_err(|_| DecodeError::IntegerOverflow)?
+ ..u32::try_from(range.end).map_err(|_| DecodeError::IntegerOverflow)?,
+ all_zero,
+ })
+}
+
+fn build_sort_order(entries: &[NresEntry]) -> Vec<usize> {
+ let mut order: Vec<usize> = (0..entries.len()).collect();
+ order.sort_by(|left, right| {
+ cmp_ascii_casefold(entries[*left].name_bytes(), entries[*right].name_bytes())
+ });
+ order
+}
+
+fn build_edit_sort_order(entries: &[EditableEntry]) -> Vec<usize> {
+ let mut order: Vec<usize> = (0..entries.len()).collect();
+ order.sort_by(|left, right| {
+ cmp_ascii_casefold(
+ editable_name_bytes(&entries[*left].name_raw),
+ editable_name_bytes(&entries[*right].name_raw),
+ )
+ });
+ order
+}
+
+fn editable_name_bytes(raw: &[u8; NAME_LEN]) -> &[u8] {
+ let len = name_len(raw).unwrap_or(NAME_LEN);
+ &raw[..len]
+}
+
+fn cmp_ascii_casefold(left: &[u8], right: &[u8]) -> Ordering {
+ let left_key = lookup_key(left);
+ let right_key = lookup_key(right);
+ left_key.0.cmp(&right_key.0)
+}
+
+fn lookup_key(bytes: &[u8]) -> LookupKey {
+ ascii_lookup_key(bytes)
+}
+
+fn name_len(raw: &[u8; NAME_LEN]) -> Option<usize> {
+ raw.iter().position(|byte| *byte == 0)
+}
+
+fn push_u32(out: &mut Vec<u8>, value: u32) {
+ out.extend_from_slice(&value.to_le_bytes());
+}
+
+fn checked_u32_len(len: usize) -> Result<u32, NresError> {
+ u32::try_from(len).map_err(|_| NresError::Binary(DecodeError::IntegerOverflow))
+}
+
+fn saturating_u32_len(len: usize) -> u32 {
+ u32::try_from(len).unwrap_or(u32::MAX)
+}
+
+fn authoring_name_raw(name: &[u8]) -> Result<[u8; NAME_LEN], NresError> {
+ if let Some(offset) = name.iter().position(|byte| *byte == 0) {
+ return Err(NresError::AuthoringNameContainsNul { offset });
+ }
+ let max = NAME_LEN - 1;
+ if name.len() > max {
+ return Err(NresError::AuthoringNameTooLong {
+ len: name.len(),
+ max,
+ });
+ }
+ let mut raw = [0; NAME_LEN];
+ raw[..name.len()].copy_from_slice(name);
+ Ok(raw)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+ use std::path::{Path, PathBuf};
+
+ #[derive(Clone, Copy)]
+ struct SyntheticEntry<'a> {
+ type_id: u32,
+ attr1: u32,
+ attr2: u32,
+ attr3: u32,
+ name: &'a str,
+ payload: &'a [u8],
+ }
+
+ #[test]
+ fn parses_minimal_empty_archive() {
+ let mut bytes = Vec::new();
+ bytes.extend_from_slice(b"NRes");
+ push_u32(&mut bytes, VERSION_0100);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, HEADER_LEN_U32);
+
+ let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("empty nres");
+
+ assert_eq!(doc.header().entry_count, 0);
+ assert_eq!(doc.header().directory_offset, HEADER_LEN_U32);
+ assert!(doc.entries().is_empty());
+ assert!(doc.preserved_regions().is_empty());
+ assert_eq!(doc.encode(WriteProfile::Lossless), bytes);
+ }
+
+ #[test]
+ fn one_entry_archive_uses_8_byte_alignment() {
+ let bytes = build_archive(&[SyntheticEntry {
+ type_id: 7,
+ attr1: 1,
+ attr2: 2,
+ attr3: 3,
+ name: "one",
+ payload: b"x",
+ }]);
+ let doc = decode(arc(bytes), ReadProfile::Strict).expect("one entry nres");
+ let entry = doc.entry(EntryId(0)).expect("entry");
+
+ assert_eq!(doc.entry_count(), 1);
+ assert_eq!(entry.data_range().start, HEADER_LEN);
+ assert_eq!(entry.data_range().end, HEADER_LEN + 1);
+ assert_eq!(doc.header().directory_offset % 8, 0);
+ assert_eq!(doc.payload(EntryId(0)).expect("payload"), b"x");
+ }
+
+ #[test]
+ fn rejects_invalid_magic() {
+ let mut bytes = Vec::new();
+ bytes.extend_from_slice(b"BAD!");
+ push_u32(&mut bytes, VERSION_0100);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, HEADER_LEN_U32);
+
+ assert!(matches!(
+ decode(arc(bytes), ReadProfile::Strict),
+ Err(NresError::InvalidMagic { got }) if got == *b"BAD!"
+ ));
+ }
+
+ #[test]
+ fn rejects_unsupported_version() {
+ let mut bytes = Vec::new();
+ bytes.extend_from_slice(b"NRes");
+ push_u32(&mut bytes, VERSION_0100 + 1);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, HEADER_LEN_U32);
+
+ assert!(matches!(
+ decode(arc(bytes), ReadProfile::Strict),
+ Err(NresError::UnsupportedVersion { got }) if got == VERSION_0100 + 1
+ ));
+ }
+
+ #[test]
+ fn rejects_negative_entry_count() {
+ let mut bytes = Vec::new();
+ bytes.extend_from_slice(b"NRes");
+ push_u32(&mut bytes, VERSION_0100);
+ bytes.extend_from_slice(&(-1_i32).to_le_bytes());
+ push_u32(&mut bytes, HEADER_LEN_U32);
+
+ assert!(matches!(
+ decode(arc(bytes), ReadProfile::Strict),
+ Err(NresError::InvalidEntryCount { got }) if got == -1
+ ));
+ }
+
+ #[test]
+ fn rejects_directory_size_before_allocation() {
+ let mut bytes = Vec::new();
+ bytes.extend_from_slice(b"NRes");
+ push_u32(&mut bytes, VERSION_0100);
+ push_u32(&mut bytes, i32::MAX.cast_unsigned());
+ push_u32(&mut bytes, HEADER_LEN_U32);
+
+ assert!(matches!(
+ decode(arc(bytes), ReadProfile::Strict),
+ Err(NresError::DirectoryOutOfBounds { .. })
+ ));
+ }
+
+ #[test]
+ fn rejects_total_size_mismatch() {
+ let mut bytes = Vec::new();
+ bytes.extend_from_slice(b"NRes");
+ push_u32(&mut bytes, VERSION_0100);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, HEADER_LEN_U32 + 1);
+
+ assert!(matches!(
+ decode(arc(bytes), ReadProfile::Strict),
+ Err(NresError::TotalSizeMismatch { header, actual })
+ if header == HEADER_LEN_U32 + 1 && actual == HEADER_LEN as u64
+ ));
+ }
+
+ #[test]
+ fn rejects_directory_before_header() {
+ let mut bytes = Vec::new();
+ bytes.extend_from_slice(b"NRes");
+ push_u32(&mut bytes, VERSION_0100);
+ push_u32(&mut bytes, 1);
+ push_u32(&mut bytes, ENTRY_LEN as u32);
+ bytes.resize(ENTRY_LEN, 0);
+
+ assert!(matches!(
+ decode(arc(bytes), ReadProfile::Strict),
+ Err(NresError::DirectoryOutOfBounds { offset, .. }) if offset == 0
+ ));
+ }
+
+ #[test]
+ fn rejects_payload_before_data_region() {
+ let mut bytes = build_archive(&[SyntheticEntry {
+ type_id: 1,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "one",
+ payload: b"x",
+ }]);
+ let directory_offset = bytes.len() - ENTRY_LEN;
+ bytes[directory_offset + 56..directory_offset + 60].copy_from_slice(&15_u32.to_le_bytes());
+
+ assert!(matches!(
+ decode(arc(bytes), ReadProfile::Strict),
+ Err(NresError::EntryDataOutOfBounds { offset, .. }) if offset == 15
+ ));
+ }
+
+ #[test]
+ fn rejects_payload_crossing_directory() {
+ let mut bytes = build_archive(&[SyntheticEntry {
+ type_id: 1,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "one",
+ payload: b"x",
+ }]);
+ let directory_offset = bytes.len() - ENTRY_LEN;
+ let offset = u32::from_le_bytes(
+ bytes[directory_offset + 56..directory_offset + 60]
+ .try_into()
+ .expect("offset field"),
+ );
+ let size = u32::try_from(directory_offset).expect("directory offset") - offset + 1;
+ bytes[directory_offset + 12..directory_offset + 16].copy_from_slice(&size.to_le_bytes());
+
+ assert!(matches!(
+ decode(arc(bytes), ReadProfile::Strict),
+ Err(NresError::EntryDataOutOfBounds {
+ directory_offset: got_directory,
+ ..
+ }) if got_directory == u32::try_from(directory_offset).expect("directory offset")
+ ));
+ }
+
+ #[test]
+ fn rejects_name_without_nul_terminator() {
+ let mut bytes = build_archive(&[SyntheticEntry {
+ type_id: 1,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "one",
+ payload: b"x",
+ }]);
+ let directory_offset = bytes.len() - ENTRY_LEN;
+ bytes[directory_offset + 20..directory_offset + 56].fill(b'A');
+
+ assert!(matches!(
+ decode(arc(bytes), ReadProfile::Strict),
+ Err(NresError::MissingNameTerminator { id }) if id == 0
+ ));
+ }
+
+ #[test]
+ fn preserves_name_bytes_after_nul() {
+ let mut bytes = build_archive(&[SyntheticEntry {
+ type_id: 1,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "one",
+ payload: b"x",
+ }]);
+ let directory_offset = bytes.len() - ENTRY_LEN;
+ bytes[directory_offset + 20..directory_offset + 29].copy_from_slice(b"one\0TAIL!");
+
+ let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("nres");
+ let entry = doc.entry(EntryId(0)).expect("entry");
+
+ assert_eq!(entry.name_bytes(), b"one");
+ assert_eq!(doc.encode(WriteProfile::Lossless), bytes);
+ assert_eq!(doc.encode(WriteProfile::CanonicalCompact), bytes);
+ }
+
+ #[test]
+ fn rejects_sort_index_out_of_range() {
+ let mut bytes = build_archive(&[SyntheticEntry {
+ type_id: 1,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "one",
+ payload: b"x",
+ }]);
+ let directory_offset = bytes.len() - ENTRY_LEN;
+ bytes[directory_offset + 60..directory_offset + 64].copy_from_slice(&1_u32.to_le_bytes());
+
+ assert!(matches!(
+ decode(arc(bytes), ReadProfile::Strict),
+ Err(NresError::SortIndexOutOfRange {
+ position: 0,
+ index: 1,
+ entry_count: 1,
+ })
+ ));
+ }
+
+ #[test]
+ fn rejects_duplicate_sort_mapping() {
+ let mut bytes = build_archive(&[
+ SyntheticEntry {
+ type_id: 1,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "a",
+ payload: b"a",
+ },
+ SyntheticEntry {
+ type_id: 2,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "b",
+ payload: b"b",
+ },
+ ]);
+ let directory_offset = bytes.len() - ENTRY_LEN * 2;
+ bytes[directory_offset + 60..directory_offset + 64].copy_from_slice(&0_u32.to_le_bytes());
+ bytes[directory_offset + ENTRY_LEN + 60..directory_offset + ENTRY_LEN + 64]
+ .copy_from_slice(&0_u32.to_le_bytes());
+
+ assert!(matches!(
+ decode(arc(bytes), ReadProfile::Strict),
+ Err(NresError::SortIndexDuplicate { index }) if index == 0
+ ));
+ }
+
+ #[test]
+ fn binary_lookup_returns_original_entry_index() {
+ let bytes = build_archive(&[
+ SyntheticEntry {
+ type_id: 1,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "Zulu",
+ payload: b"z",
+ },
+ SyntheticEntry {
+ type_id: 2,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "alpha",
+ payload: b"a",
+ },
+ SyntheticEntry {
+ type_id: 3,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "Mike",
+ payload: b"m",
+ },
+ ]);
+ let doc = decode(arc(bytes), ReadProfile::Strict).expect("nres");
+
+ assert!(doc.lookup_order_valid());
+ assert_eq!(doc.find("alpha"), Some(EntryId(1)));
+ assert_eq!(doc.find("Mike"), Some(EntryId(2)));
+ assert_eq!(doc.find("Zulu"), Some(EntryId(0)));
+ }
+
+ #[test]
+ fn compatible_profile_uses_linear_fallback_for_broken_mapping() {
+ let mut bytes = build_archive(&[
+ SyntheticEntry {
+ type_id: 1,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "b",
+ payload: b"b",
+ },
+ SyntheticEntry {
+ type_id: 2,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "a",
+ payload: b"a",
+ },
+ ]);
+ let directory_offset = bytes.len() - ENTRY_LEN * 2;
+ bytes[directory_offset + 60..directory_offset + 64].copy_from_slice(&0_u32.to_le_bytes());
+ bytes[directory_offset + ENTRY_LEN + 60..directory_offset + ENTRY_LEN + 64]
+ .copy_from_slice(&0_u32.to_le_bytes());
+
+ let doc = decode(arc(bytes), ReadProfile::Compatible).expect("compatible nres");
+
+ assert!(!doc.lookup_order_valid());
+ assert_eq!(doc.find("A"), Some(EntryId(1)));
+ assert_eq!(doc.payload(EntryId(1)).expect("payload"), b"a");
+ }
+
+ #[test]
+ fn lookup_is_ascii_case_insensitive() {
+ let bytes = build_archive(&[SyntheticEntry {
+ type_id: 1,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "MiXeD",
+ payload: b"x",
+ }]);
+ let doc = decode(arc(bytes), ReadProfile::Strict).expect("nres");
+
+ assert_eq!(doc.find("mixed"), Some(EntryId(0)));
+ assert_eq!(doc.find("MIXED"), Some(EntryId(0)));
+ }
+
+ #[test]
+ fn parses_synthetic_archive_and_finds_names() {
+ let bytes = build_archive(&[
+ SyntheticEntry {
+ type_id: 1,
+ attr1: 10,
+ attr2: 20,
+ attr3: 30,
+ name: "Zulu",
+ payload: b"z",
+ },
+ SyntheticEntry {
+ type_id: 2,
+ attr1: 11,
+ attr2: 21,
+ attr3: 31,
+ name: "alpha",
+ payload: b"aaaa",
+ },
+ ]);
+ let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("synthetic nres");
+
+ assert_eq!(doc.entry_count(), 2);
+ assert_eq!(doc.find("ALPHA"), Some(EntryId(1)));
+ assert_eq!(doc.find("zulu"), Some(EntryId(0)));
+ assert_eq!(
+ doc.payload(EntryId(1)).expect("payload"),
+ b"aaaa".as_slice()
+ );
+ assert_eq!(doc.encode(WriteProfile::Lossless), bytes);
+ assert_eq!(doc.encode(WriteProfile::CanonicalCompact), bytes);
+ }
+
+ #[test]
+ fn unsorted_lookup_table_falls_back_to_linear_lookup() {
+ let mut bytes = build_archive(&[
+ SyntheticEntry {
+ type_id: 1,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "b",
+ payload: b"b",
+ },
+ SyntheticEntry {
+ type_id: 2,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "a",
+ payload: b"a",
+ },
+ ]);
+ let directory_offset = usize::try_from(u32::from_le_bytes(
+ bytes[12..16].try_into().expect("total size field"),
+ ))
+ .expect("total size")
+ - ENTRY_LEN * 2;
+ bytes[directory_offset + 60..directory_offset + 64].copy_from_slice(&0_u32.to_le_bytes());
+ bytes[directory_offset + ENTRY_LEN + 60..directory_offset + ENTRY_LEN + 64]
+ .copy_from_slice(&1_u32.to_le_bytes());
+
+ let doc = decode(arc(bytes), ReadProfile::Strict).expect("strict nres");
+ assert!(!doc.lookup_order_valid());
+ assert_eq!(doc.find("A"), Some(EntryId(1)));
+ }
+
+ #[test]
+ fn rejects_overlapping_payloads() {
+ let mut bytes = build_archive(&[
+ SyntheticEntry {
+ type_id: 1,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "one",
+ payload: b"1111",
+ },
+ SyntheticEntry {
+ type_id: 2,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "two",
+ payload: b"2222",
+ },
+ ]);
+ let directory_offset = bytes.len() - ENTRY_LEN * 2;
+ let first_offset = u32::from_le_bytes(
+ bytes[directory_offset + 56..directory_offset + 60]
+ .try_into()
+ .expect("offset field"),
+ );
+ bytes[directory_offset + ENTRY_LEN + 56..directory_offset + ENTRY_LEN + 60]
+ .copy_from_slice(&(first_offset + 1).to_le_bytes());
+
+ assert!(matches!(
+ decode(arc(bytes), ReadProfile::Strict),
+ Err(NresError::EntryDataOverlap { .. })
+ ));
+ }
+
+ #[test]
+ fn preserves_nonzero_unindexed_region() {
+ let mut bytes = build_archive(&[SyntheticEntry {
+ type_id: 1,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "payload",
+ payload: b"data",
+ }]);
+ let directory_offset = bytes.len() - ENTRY_LEN;
+ bytes.splice(HEADER_LEN..HEADER_LEN, [0xAA, 0xBB, 0xCC, 0xDD]);
+ let total = u32::try_from(bytes.len()).expect("total size");
+ bytes[12..16].copy_from_slice(&total.to_le_bytes());
+ let offset = u32::from_le_bytes(
+ bytes[directory_offset + 4 + 56..directory_offset + 4 + 60]
+ .try_into()
+ .expect("shifted offset"),
+ );
+ let shifted_directory_offset = directory_offset + 4;
+ bytes[shifted_directory_offset + 56..shifted_directory_offset + 60]
+ .copy_from_slice(&(offset + 4).to_le_bytes());
+
+ let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("nres");
+ assert!(doc.has_nonzero_preserved_region());
+ assert_eq!(doc.encode(WriteProfile::Lossless), bytes);
+ assert_ne!(doc.encode(WriteProfile::CanonicalCompact), bytes);
+ }
+
+ #[test]
+ fn canonical_compact_roundtrip_preserves_entry_semantics() {
+ let mut bytes = build_archive(&[
+ SyntheticEntry {
+ type_id: 7,
+ attr1: 10,
+ attr2: 20,
+ attr3: 30,
+ name: "zeta",
+ payload: b"zz",
+ },
+ SyntheticEntry {
+ type_id: 9,
+ attr1: 11,
+ attr2: 21,
+ attr3: 31,
+ name: "alpha",
+ payload: b"aaaa",
+ },
+ ]);
+ let directory_offset = bytes.len() - ENTRY_LEN * 2;
+ bytes.splice(HEADER_LEN..HEADER_LEN, [0xAA, 0xBB, 0xCC, 0xDD]);
+ let total = u32::try_from(bytes.len()).expect("total size");
+ bytes[12..16].copy_from_slice(&total.to_le_bytes());
+ for entry_index in 0..2 {
+ let field = directory_offset + 4 + entry_index * ENTRY_LEN + 56;
+ let offset =
+ u32::from_le_bytes(bytes[field..field + 4].try_into().expect("shifted offset"));
+ bytes[field..field + 4].copy_from_slice(&(offset + 4).to_le_bytes());
+ }
+
+ let original = decode(arc(bytes), ReadProfile::Strict).expect("original");
+ let compact = decode(
+ arc(original.encode(WriteProfile::CanonicalCompact)),
+ ReadProfile::Strict,
+ )
+ .expect("compact");
+
+ assert_eq!(compact.entry_count(), original.entry_count());
+ assert!(!compact.has_nonzero_preserved_region());
+ for original_entry in original.entries() {
+ let compact_id = compact
+ .find_bytes(original_entry.name_bytes())
+ .expect("compact lookup");
+ let compact_entry = compact.entry(compact_id).expect("compact entry");
+ let original_meta = original_entry.meta();
+ let compact_meta = compact_entry.meta();
+ assert_eq!(compact_entry.name_bytes(), original_entry.name_bytes());
+ assert_eq!(compact_meta.type_id, original_meta.type_id);
+ assert_eq!(compact_meta.attr1, original_meta.attr1);
+ assert_eq!(compact_meta.attr2, original_meta.attr2);
+ assert_eq!(compact_meta.attr3, original_meta.attr3);
+ assert_eq!(compact_meta.data_size, original_meta.data_size);
+ assert_eq!(
+ compact.payload(compact_id).expect("compact payload"),
+ original
+ .payload(original_entry.id())
+ .expect("original payload")
+ );
+ }
+ }
+
+ #[test]
+ fn editor_payload_update_rewrites_offsets_and_size() {
+ let bytes = build_archive(&[
+ SyntheticEntry {
+ type_id: 1,
+ attr1: 10,
+ attr2: 20,
+ attr3: 30,
+ name: "first",
+ payload: b"a",
+ },
+ SyntheticEntry {
+ type_id: 2,
+ attr1: 11,
+ attr2: 21,
+ attr3: 31,
+ name: "second",
+ payload: b"bb",
+ },
+ ]);
+ let original = decode(arc(bytes), ReadProfile::Strict).expect("original");
+ let mut editor = original.editor().expect("editor");
+
+ editor
+ .set_payload(EntryId(0), b"replacement".to_vec())
+ .expect("set payload");
+ let edited =
+ decode(arc(editor.encode().expect("encode")), ReadProfile::Strict).expect("edited");
+ let first = edited.entry(EntryId(0)).expect("first");
+ let second = edited.entry(EntryId(1)).expect("second");
+
+ assert_eq!(
+ edited.payload(EntryId(0)).expect("first payload"),
+ b"replacement"
+ );
+ assert_eq!(edited.payload(EntryId(1)).expect("second payload"), b"bb");
+ assert_eq!(first.meta().data_size, 11);
+ assert_eq!(first.meta().data_offset, HEADER_LEN_U32);
+ assert_eq!(second.meta().data_offset % 8, 0);
+ assert!(second.meta().data_offset > first.meta().data_offset + first.meta().data_size);
+ }
+
+ #[test]
+ fn editor_rename_rebuilds_search_mapping() {
+ let bytes = build_archive(&[
+ SyntheticEntry {
+ type_id: 1,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "zeta",
+ payload: b"z",
+ },
+ SyntheticEntry {
+ type_id: 2,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "middle",
+ payload: b"m",
+ },
+ ]);
+ let original = decode(arc(bytes), ReadProfile::Strict).expect("original");
+ let mut editor = original.editor().expect("editor");
+
+ editor.rename(EntryId(0), b"alpha").expect("rename");
+ let edited =
+ decode(arc(editor.encode().expect("encode")), ReadProfile::Strict).expect("edited");
+
+ assert!(edited.lookup_order_valid());
+ assert_eq!(edited.find("alpha"), Some(EntryId(0)));
+ assert_eq!(edited.find("zeta"), None);
+ assert_eq!(edited.find("middle"), Some(EntryId(1)));
+ assert_eq!(
+ edited.entry(EntryId(0)).expect("entry").name_bytes(),
+ b"alpha"
+ );
+ }
+
+ #[test]
+ fn editor_rejects_invalid_authoring_names() {
+ let bytes = build_archive(&[SyntheticEntry {
+ type_id: 1,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "one",
+ payload: b"x",
+ }]);
+ let original = decode(arc(bytes), ReadProfile::Strict).expect("original");
+ let mut editor = original.editor().expect("editor");
+
+ assert!(matches!(
+ editor.rename(EntryId(0), [b'A'; NAME_LEN]),
+ Err(NresError::AuthoringNameTooLong { len, max })
+ if len == NAME_LEN && max == NAME_LEN - 1
+ ));
+ assert!(matches!(
+ editor.rename(EntryId(0), b"bad\0name"),
+ Err(NresError::AuthoringNameContainsNul { offset }) if offset == 3
+ ));
+
+ let encoded = editor.encode().expect("encode");
+ let unchanged = decode(arc(encoded), ReadProfile::Strict).expect("unchanged");
+ assert_eq!(
+ unchanged.entry(EntryId(0)).expect("entry").name_bytes(),
+ b"one"
+ );
+ }
+
+ #[test]
+ fn rejects_empty_names_and_resolves_duplicates_to_first_entry() {
+ let empty_name = build_archive(&[SyntheticEntry {
+ type_id: 1,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "",
+ payload: b"x",
+ }]);
+ assert!(matches!(
+ decode(arc(empty_name), ReadProfile::Strict),
+ Err(NresError::EmptyName { id: 0 })
+ ));
+
+ let duplicate_names = build_archive(&[
+ SyntheticEntry {
+ type_id: 1,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "duplicate",
+ payload: b"a",
+ },
+ SyntheticEntry {
+ type_id: 2,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "DUPLICATE",
+ payload: b"b",
+ },
+ ]);
+ let doc = decode(arc(duplicate_names), ReadProfile::Strict).expect("duplicates");
+ assert_eq!(doc.find("duplicate"), Some(EntryId(0)));
+ assert_eq!(doc.payload(EntryId(0)).expect("first duplicate"), b"a");
+ assert_eq!(doc.payload(EntryId(1)).expect("second duplicate"), b"b");
+ }
+
+ #[test]
+ fn generated_archives_preserve_lossless_and_canonical_semantics() {
+ let cases = [
+ vec![SyntheticEntry {
+ type_id: 1,
+ attr1: 10,
+ attr2: 20,
+ attr3: 30,
+ name: "single.bin",
+ payload: b"x",
+ }],
+ vec![
+ SyntheticEntry {
+ type_id: 2,
+ attr1: 1,
+ attr2: 2,
+ attr3: 3,
+ name: "zeta.bin",
+ payload: b"zzzz",
+ },
+ SyntheticEntry {
+ type_id: 3,
+ attr1: 4,
+ attr2: 5,
+ attr3: 6,
+ name: "Alpha.bin",
+ payload: b"a",
+ },
+ SyntheticEntry {
+ type_id: 4,
+ attr1: 7,
+ attr2: 8,
+ attr3: 9,
+ name: "middle.bin",
+ payload: b"middle",
+ },
+ ],
+ ];
+
+ for entries in cases {
+ let bytes = build_archive(&entries);
+ let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("generated nres");
+ assert_eq!(doc.encode(WriteProfile::Lossless), bytes);
+
+ let compact = doc.encode(WriteProfile::CanonicalCompact);
+ let compact_doc = decode(arc(compact), ReadProfile::Strict).expect("compact nres");
+ assert_eq!(compact_doc.entry_count(), doc.entry_count());
+ for original in doc.entries() {
+ let compact_id = compact_doc
+ .find_bytes(original.name_bytes())
+ .expect("compact entry");
+ let compact_entry = compact_doc.entry(compact_id).expect("compact meta");
+ assert_eq!(compact_entry.meta().type_id, original.meta().type_id);
+ assert_eq!(compact_entry.meta().attr1, original.meta().attr1);
+ assert_eq!(compact_entry.meta().attr2, original.meta().attr2);
+ assert_eq!(compact_entry.meta().attr3, original.meta().attr3);
+ assert_eq!(
+ compact_doc.payload(compact_id).expect("compact payload"),
+ doc.payload(original.id()).expect("original payload")
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn generated_editor_updates_roundtrip() {
+ for count in 1..5usize {
+ let entries = (0..count)
+ .map(|idx| SyntheticEntry {
+ type_id: u32::try_from(idx + 1).expect("type id"),
+ attr1: u32::try_from(idx).expect("attr1"),
+ attr2: u32::try_from(idx * 2).expect("attr2"),
+ attr3: u32::try_from(idx * 3).expect("attr3"),
+ name: ["a.bin", "b.bin", "c.bin", "d.bin"][idx],
+ payload: ["a", "bb", "ccc", "dddd"][idx].as_bytes(),
+ })
+ .collect::<Vec<_>>();
+ let doc = decode(arc(build_archive(&entries)), ReadProfile::Strict).expect("nres");
+ let mut editor = doc.editor().expect("editor");
+ editor
+ .set_payload(EntryId(0), format!("replacement-{count}").into_bytes())
+ .expect("set payload");
+ editor
+ .rename(EntryId(0), format!("renamed-{count}.bin").as_bytes())
+ .expect("rename");
+
+ let edited =
+ decode(arc(editor.encode().expect("encode")), ReadProfile::Strict).expect("edited");
+ assert_eq!(edited.entry_count(), count);
+ let renamed = edited
+ .find(&format!("RENAMED-{count}.BIN"))
+ .expect("renamed");
+ assert_eq!(renamed, EntryId(0));
+ assert_eq!(
+ edited.payload(EntryId(0)).expect("payload"),
+ format!("replacement-{count}").as_bytes()
+ );
+ }
+ }
+
+ #[test]
+ fn arbitrary_small_inputs_do_not_panic_or_overallocate() {
+ for len in 0..160usize {
+ let mut bytes = vec![0u8; len];
+ if len >= 4 {
+ bytes[0..4].copy_from_slice(b"NRes");
+ }
+ if len >= 8 {
+ bytes[4..8].copy_from_slice(&VERSION_0100.to_le_bytes());
+ }
+ if len >= 12 {
+ bytes[8..12].copy_from_slice(&u32::try_from(len % 4).expect("count").to_le_bytes());
+ }
+ if len >= 16 {
+ bytes[12..16].copy_from_slice(&u32::try_from(len).expect("len").to_le_bytes());
+ }
+
+ let strict =
+ std::panic::catch_unwind(|| decode(arc(bytes.clone()), ReadProfile::Strict));
+ let compatible =
+ std::panic::catch_unwind(|| decode(arc(bytes.clone()), ReadProfile::Compatible));
+ assert!(strict.is_ok());
+ assert!(compatible.is_ok());
+ }
+ }
+
+ #[test]
+ fn licensed_corpora_nres_roundtrip_gates() {
+ let part1 = corpus_gate("IS", 120, 6_804).expect("part 1 NRes gate");
+ let part2 = corpus_gate("IS2", 134, 8_171).expect("part 2 NRes gate");
+
+ assert!(!part1.has_nonzero_preserved_region);
+ assert!(
+ part2.has_nonzero_preserved_region,
+ "part 2 must keep the known non-zero unindexed NRes regression case"
+ );
+ }
+
+ #[derive(Clone, Copy, Debug, Default)]
+ struct CorpusGateResult {
+ has_nonzero_preserved_region: bool,
+ }
+
+ fn corpus_gate(
+ name: &str,
+ expected_files: usize,
+ expected_entries: usize,
+ ) -> Result<CorpusGateResult, String> {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("testdata")
+ .join(name);
+ if !root.is_dir() {
+ return Err(format!(
+ "licensed corpus root is missing: {}",
+ root.display()
+ ));
+ }
+ let mut files = Vec::new();
+ collect_nres_files(&root, &mut files).map_err(|err| err.to_string())?;
+ files.sort();
+
+ let mut total_entries = 0usize;
+ let mut has_nonzero_preserved_region = false;
+ for path in &files {
+ let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
+ let doc = decode(arc(bytes.clone()), ReadProfile::Strict)
+ .map_err(|err| format!("{}: {err}", path.display()))?;
+ total_entries = total_entries
+ .checked_add(doc.entry_count())
+ .ok_or_else(|| "entry count overflow".to_string())?;
+ if doc.has_nonzero_preserved_region() {
+ has_nonzero_preserved_region = true;
+ }
+ for entry in doc.entries() {
+ let id = doc
+ .find_bytes(entry.name_bytes())
+ .ok_or_else(|| format!("lookup failed: {}", path.display()))?;
+ let found = doc
+ .entry(id)
+ .ok_or_else(|| format!("lookup returned invalid id: {}", path.display()))?;
+ if cmp_ascii_casefold(found.name_bytes(), entry.name_bytes()) != Ordering::Equal {
+ return Err(format!("lookup mismatch: {}", path.display()));
+ }
+ let _payload = doc
+ .payload(entry.id())
+ .map_err(|err| format!("{}: {err}", path.display()))?;
+ }
+ if doc.encode(WriteProfile::Lossless) != bytes {
+ return Err(format!("lossless roundtrip mismatch: {}", path.display()));
+ }
+ }
+
+ if files.len() != expected_files {
+ return Err(format!(
+ "{name}: expected {expected_files} NRes files, got {}",
+ files.len()
+ ));
+ }
+ if total_entries != expected_entries {
+ return Err(format!(
+ "{name}: expected {expected_entries} NRes entries, got {total_entries}"
+ ));
+ }
+ Ok(CorpusGateResult {
+ has_nonzero_preserved_region,
+ })
+ }
+
+ fn collect_nres_files(root: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
+ for entry in fs::read_dir(root)? {
+ let path = entry?.path();
+ if path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.starts_with('.'))
+ {
+ continue;
+ }
+ if path.is_dir() {
+ collect_nres_files(&path, out)?;
+ continue;
+ }
+ if path.is_file() {
+ let bytes = fs::read(&path)?;
+ if bytes.starts_with(b"NRes") {
+ out.push(path);
+ }
+ }
+ }
+ Ok(())
+ }
+
+ fn build_archive(entries: &[SyntheticEntry<'_>]) -> Vec<u8> {
+ let mut out = vec![0; HEADER_LEN];
+ let mut offsets = Vec::with_capacity(entries.len());
+ for entry in entries {
+ offsets.push(u32::try_from(out.len()).expect("offset"));
+ out.extend_from_slice(entry.payload);
+ let padding = (8 - (out.len() % 8)) % 8;
+ out.resize(out.len() + padding, 0);
+ }
+ let mut order: Vec<usize> = (0..entries.len()).collect();
+ order.sort_by(|left, right| {
+ cmp_ascii_casefold(
+ entries[*left].name.as_bytes(),
+ entries[*right].name.as_bytes(),
+ )
+ });
+ for (index, entry) in entries.iter().enumerate() {
+ push_u32(&mut out, entry.type_id);
+ push_u32(&mut out, entry.attr1);
+ push_u32(&mut out, entry.attr2);
+ push_u32(
+ &mut out,
+ u32::try_from(entry.payload.len()).expect("payload size"),
+ );
+ push_u32(&mut out, entry.attr3);
+ let mut name = [0; NAME_LEN];
+ let name_bytes = entry.name.as_bytes();
+ name[..name_bytes.len()].copy_from_slice(name_bytes);
+ out.extend_from_slice(&name);
+ push_u32(&mut out, offsets[index]);
+ push_u32(&mut out, u32::try_from(order[index]).expect("sort index"));
+ }
+ out[0..4].copy_from_slice(b"NRes");
+ out[4..8].copy_from_slice(&VERSION_0100.to_le_bytes());
+ out[8..12].copy_from_slice(
+ &u32::try_from(entries.len())
+ .expect("entry count")
+ .to_le_bytes(),
+ );
+ let total_size = u32::try_from(out.len()).expect("total size");
+ out[12..16].copy_from_slice(&total_size.to_le_bytes());
+ out
+ }
+
+ fn arc(bytes: Vec<u8>) -> Arc<[u8]> {
+ Arc::from(bytes.into_boxed_slice())
+ }
+}
diff --git a/crates/fparkan-path/Cargo.toml b/crates/fparkan-path/Cargo.toml
new file mode 100644
index 0000000..57664b7
--- /dev/null
+++ b/crates/fparkan-path/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "fparkan-path"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-path/src/lib.rs b/crates/fparkan-path/src/lib.rs
new file mode 100644
index 0000000..d15aae8
--- /dev/null
+++ b/crates/fparkan-path/src/lib.rs
@@ -0,0 +1,259 @@
+#![forbid(unsafe_code)]
+//! Legacy path normalization and ASCII lookup semantics.
+
+use std::fmt;
+use std::path::{Path, PathBuf};
+
+/// Original bytes.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct OriginalPathBytes(pub Vec<u8>);
+
+impl OriginalPathBytes {
+ /// Returns the preserved byte image.
+ #[must_use]
+ pub fn as_bytes(&self) -> &[u8] {
+ &self.0
+ }
+
+ /// Returns the preserved byte image as an owned vector.
+ #[must_use]
+ pub fn into_vec(self) -> Vec<u8> {
+ self.0
+ }
+}
+
+/// Normalized relative path.
+#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
+pub struct NormalizedPath(String);
+
+impl NormalizedPath {
+ /// Returns string view.
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+/// Normalized path paired with its original byte image.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct NormalizedPathWithOriginal {
+ normalized: NormalizedPath,
+ original: OriginalPathBytes,
+}
+
+impl NormalizedPathWithOriginal {
+ /// Returns normalized path.
+ #[must_use]
+ pub fn normalized(&self) -> &NormalizedPath {
+ &self.normalized
+ }
+
+ /// Returns original path bytes.
+ #[must_use]
+ pub fn original(&self) -> &OriginalPathBytes {
+ &self.original
+ }
+
+ /// Splits into normalized and original path parts.
+ #[must_use]
+ pub fn into_parts(self) -> (NormalizedPath, OriginalPathBytes) {
+ (self.normalized, self.original)
+ }
+}
+
+/// ASCII lookup key.
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+pub struct LookupKey(pub Vec<u8>);
+
+/// Resource name bytes.
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+pub struct ResourceName(pub Vec<u8>);
+
+/// Path policy.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum PathPolicy {
+ /// Strict legacy relative resource path.
+ StrictLegacy,
+ /// Host compatible relative path.
+ HostCompatible,
+}
+
+/// Path error.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum PathError {
+ /// Empty path.
+ Empty,
+ /// Embedded NUL.
+ EmbeddedNul,
+ /// Absolute path.
+ Absolute,
+ /// Parent traversal.
+ ParentTraversal,
+ /// Host path escape.
+ EscapesRoot,
+ /// Invalid UTF-8 after normalization.
+ InvalidUtf8,
+}
+
+impl fmt::Display for PathError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{self:?}")
+ }
+}
+
+impl std::error::Error for PathError {}
+
+/// Normalizes a relative path.
+///
+/// # Errors
+///
+/// Returns [`PathError`] when the input is empty, absolute, contains an
+/// embedded NUL, attempts parent traversal, or is not valid UTF-8 after
+/// legacy separator normalization.
+pub fn normalize_relative(raw: &[u8], _policy: PathPolicy) -> Result<NormalizedPath, PathError> {
+ if raw.is_empty() {
+ return Err(PathError::Empty);
+ }
+ if raw.contains(&0) {
+ return Err(PathError::EmbeddedNul);
+ }
+ let text = std::str::from_utf8(raw).map_err(|_| PathError::InvalidUtf8)?;
+ if text.starts_with('/') || text.starts_with('\\') || has_drive_prefix(text) {
+ return Err(PathError::Absolute);
+ }
+ let mut parts = Vec::new();
+ for part in text.split(['/', '\\']) {
+ if part.is_empty() || part == "." {
+ continue;
+ }
+ if part == ".." {
+ return Err(PathError::ParentTraversal);
+ }
+ parts.push(part);
+ }
+ if parts.is_empty() {
+ return Err(PathError::Empty);
+ }
+ Ok(NormalizedPath(parts.join("/")))
+}
+
+/// Normalizes a relative path while preserving its original bytes.
+///
+/// # Errors
+///
+/// Returns [`PathError`] under the same conditions as [`normalize_relative`].
+pub fn normalize_relative_with_original(
+ raw: &[u8],
+ policy: PathPolicy,
+) -> Result<NormalizedPathWithOriginal, PathError> {
+ let normalized = normalize_relative(raw, policy)?;
+ Ok(NormalizedPathWithOriginal {
+ normalized,
+ original: OriginalPathBytes(raw.to_vec()),
+ })
+}
+
+fn has_drive_prefix(text: &str) -> bool {
+ let bytes = text.as_bytes();
+ bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
+}
+
+/// Builds an ASCII-only casefold lookup key.
+#[must_use]
+pub fn ascii_lookup_key(raw: &[u8]) -> LookupKey {
+ LookupKey(raw.iter().map(u8::to_ascii_uppercase).collect())
+}
+
+/// Ensures relative path does not escape.
+///
+/// # Errors
+///
+/// Returns [`PathError::ParentTraversal`] when a normalized segment attempts
+/// to address a parent directory.
+pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
+ if rel.0.split('/').any(|part| part == "..") {
+ Err(PathError::ParentTraversal)
+ } else {
+ Ok(())
+ }
+}
+
+/// Joins normalized path under root.
+///
+/// # Errors
+///
+/// Returns [`PathError`] if the normalized path fails the escape check.
+pub fn join_under(root: &Path, rel: &NormalizedPath) -> Result<PathBuf, PathError> {
+ reject_escape(rel)?;
+ Ok(root.join(rel.as_str()))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn normalizes_separators() {
+ let p = normalize_relative(b"DATA\\MAPS/INTRO/Land.msh", PathPolicy::StrictLegacy)
+ .expect("path");
+ assert_eq!(p.as_str(), "DATA/MAPS/INTRO/Land.msh");
+ }
+
+ #[test]
+ fn rejects_escape() {
+ assert_eq!(
+ normalize_relative(b"DATA/../secret", PathPolicy::StrictLegacy),
+ Err(PathError::ParentTraversal)
+ );
+ }
+
+ #[test]
+ fn rejects_absolute_drive_and_nul_paths() {
+ assert_eq!(
+ normalize_relative(b"/DATA/MAPS", PathPolicy::StrictLegacy),
+ Err(PathError::Absolute)
+ );
+ assert_eq!(
+ normalize_relative(b"C:\\DATA\\MAPS", PathPolicy::StrictLegacy),
+ Err(PathError::Absolute)
+ );
+ assert_eq!(
+ normalize_relative(b"DATA\0MAPS", PathPolicy::StrictLegacy),
+ Err(PathError::EmbeddedNul)
+ );
+ }
+
+ #[test]
+ fn join_under_keeps_normalized_path_below_root() {
+ let rel = normalize_relative(b"DATA/MAPS/Land.map", PathPolicy::StrictLegacy)
+ .expect("relative path");
+ let joined = join_under(Path::new("/game"), &rel).expect("join");
+
+ assert_eq!(joined, PathBuf::from("/game/DATA/MAPS/Land.map"));
+ }
+
+ #[test]
+ fn ascii_casefold_does_not_unicode_fold() {
+ assert_eq!(ascii_lookup_key(b"AbZ\xD0"), LookupKey(b"ABZ\xD0".to_vec()));
+ }
+
+ #[test]
+ fn non_ascii_original_bytes_remain_stable() {
+ let raw = "DATA/Тест.bin".as_bytes();
+ let path = normalize_relative_with_original(raw, PathPolicy::StrictLegacy)
+ .expect("path with non-ASCII UTF-8");
+
+ assert_eq!(path.normalized().as_str().as_bytes(), raw);
+ assert_eq!(path.original().as_bytes(), raw);
+ assert_eq!(&ascii_lookup_key(raw).0[5..13], &raw[5..13]);
+ }
+
+ #[test]
+ fn original_separators_and_raw_bytes_are_preserved() {
+ let raw = b"DATA\\Maps/Intro\\Land.msh";
+ let path = normalize_relative_with_original(raw, PathPolicy::StrictLegacy).expect("path");
+
+ assert_eq!(path.normalized().as_str(), "DATA/Maps/Intro/Land.msh");
+ assert_eq!(path.original().as_bytes(), raw);
+ }
+}
diff --git a/crates/fparkan-platform/Cargo.toml b/crates/fparkan-platform/Cargo.toml
new file mode 100644
index 0000000..dc103f2
--- /dev/null
+++ b/crates/fparkan-platform/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "fparkan-platform"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-platform/src/lib.rs b/crates/fparkan-platform/src/lib.rs
new file mode 100644
index 0000000..cfa021b
--- /dev/null
+++ b/crates/fparkan-platform/src/lib.rs
@@ -0,0 +1,93 @@
+#![forbid(unsafe_code)]
+//! Platform ports for clocks, input, events, windows, and graphics requests.
+
+/// Monotonic instant.
+#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
+pub struct MonotonicInstant(pub u64);
+
+/// Monotonic clock.
+pub trait MonotonicClock {
+ /// Current instant.
+ fn now(&self) -> MonotonicInstant;
+}
+
+/// Platform event.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum PlatformEvent {
+ /// Quit requested.
+ Quit,
+}
+
+/// Platform error.
+#[derive(Debug)]
+pub enum PlatformError {
+ /// Backend failed.
+ Backend,
+}
+
+impl std::fmt::Display for PlatformError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{self:?}")
+ }
+}
+
+impl std::error::Error for PlatformError {}
+
+/// Event source.
+pub trait EventSource {
+ /// Polls events.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`PlatformError`] when the backend cannot collect events.
+ fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError>;
+}
+
+/// Physical size.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct PhysicalSize {
+ /// Width.
+ pub width: u32,
+ /// Height.
+ pub height: u32,
+}
+
+/// Window port.
+pub trait WindowPort {
+ /// Drawable size.
+ fn drawable_size(&self) -> PhysicalSize;
+ /// Presents.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`PlatformError`] when the backend cannot present the current
+ /// frame.
+ fn present(&mut self) -> Result<(), PlatformError>;
+}
+
+/// Graphics profile.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum GraphicsProfile {
+ /// Desktop core.
+ DesktopCore,
+ /// Embedded profile.
+ Embedded,
+}
+
+/// Version.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct Version {
+ /// Major.
+ pub major: u8,
+ /// Minor.
+ pub minor: u8,
+}
+
+/// Graphics context request.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct GraphicsContextRequest {
+ /// Profile.
+ pub profile: GraphicsProfile,
+ /// Version.
+ pub version: Version,
+}
diff --git a/crates/fparkan-prototype/Cargo.toml b/crates/fparkan-prototype/Cargo.toml
new file mode 100644
index 0000000..4825faf
--- /dev/null
+++ b/crates/fparkan-prototype/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "fparkan-prototype"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+encoding_rs = "0.8"
+fparkan-binary = { path = "../fparkan-binary" }
+fparkan-material = { path = "../fparkan-material" }
+fparkan-msh = { path = "../fparkan-msh" }
+fparkan-nres = { path = "../fparkan-nres" }
+fparkan-path = { path = "../fparkan-path" }
+fparkan-resource = { path = "../fparkan-resource" }
+fparkan-texm = { path = "../fparkan-texm" }
+fparkan-vfs = { path = "../fparkan-vfs" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-prototype/src/lib.rs b/crates/fparkan-prototype/src/lib.rs
new file mode 100644
index 0000000..4efafa1
--- /dev/null
+++ b/crates/fparkan-prototype/src/lib.rs
@@ -0,0 +1,2114 @@
+#![forbid(unsafe_code)]
+//! Prototype registry and unit DAT primitives.
+
+use encoding_rs::WINDOWS_1251;
+use fparkan_binary::{checked_count_bytes, Cursor, DecodeError};
+use fparkan_material::{decode_wear, resolve_material, WEAR_KIND};
+use fparkan_msh::{decode_msh, validate_msh, MshError};
+use fparkan_nres::ReadProfile;
+use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName};
+use fparkan_resource::{
+ archive_path, resource_name, ResourceError, ResourceKey, ResourceRepository,
+};
+use fparkan_texm::decode_texm;
+use fparkan_vfs::{Vfs, VfsError};
+use std::sync::Arc;
+
+const MESH_KIND: u32 = 0x4853_454D;
+const UNIT_DAT_MIN_SIZE: usize = 0x48;
+const UNIT_DAT_MAGIC: u32 = 0x0000_F0F1;
+const PROTOTYPE_INHERITANCE_DEPTH_LIMIT: usize = 32;
+
+/// Prototype key.
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+pub struct PrototypeKey(pub ResourceName);
+
+/// 64-byte object reference record.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ObjectRefRecord {
+ /// Archive raw bytes.
+ pub archive_raw: [u8; 32],
+ /// Resource raw bytes.
+ pub resource_raw: [u8; 32],
+}
+
+/// Unit DAT document.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct UnitDat {
+ /// Opaque eight-byte header before component records.
+ pub header_opaque: [u8; 8],
+ /// Component records.
+ pub records: Vec<UnitComponentRecord>,
+}
+
+/// Unit DAT binding used by mission object references.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct UnitDatBinding {
+ /// Flags.
+ pub flags: u32,
+ /// Archive raw bytes.
+ pub archive_raw: [u8; 32],
+ /// Model key raw bytes.
+ pub model_raw: [u8; 32],
+}
+
+/// Unit DAT component.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct UnitComponentRecord {
+ /// Archive raw bytes.
+ pub archive_raw: [u8; 32],
+ /// Resource raw bytes.
+ pub resource_raw: [u8; 32],
+ /// Component kind.
+ pub kind: u32,
+ /// Parent or link.
+ pub parent_or_link: i32,
+ /// Description raw bytes.
+ pub description_raw: [u8; 32],
+ /// Opaque tail.
+ pub tail0: u32,
+ /// Opaque tail.
+ pub tail1: u32,
+}
+
+/// Prototype geometry.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum PrototypeGeometry {
+ /// Mesh resource.
+ Mesh(ResourceKey),
+ /// Valid non-geometric prototype.
+ NonGeometric,
+}
+
+/// Effective prototype.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct EffectivePrototype {
+ /// Key.
+ pub key: PrototypeKey,
+ /// Geometry.
+ pub geometry: PrototypeGeometry,
+ /// Resolution source.
+ pub source: PrototypeSource,
+ /// Resource dependencies discovered while resolving this prototype.
+ pub dependencies: Vec<ResourceKey>,
+}
+
+/// Prototype resolution source.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum PrototypeSource {
+ /// Direct archive/key lookup.
+ DirectArchive,
+ /// `objects.rlb` registry lookup.
+ ObjectsRegistry,
+ /// Unit DAT binding.
+ UnitDat,
+}
+
+/// Prototype graph.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct PrototypeGraph {
+ /// Requested keys.
+ pub roots: Vec<PrototypeKey>,
+ /// Effective prototype requests after unit DAT expansion.
+ pub prototype_requests: Vec<PrototypeKey>,
+}
+
+/// Mission prototype dependency graph report.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct PrototypeGraphReport {
+ /// Requested mission roots.
+ pub root_count: usize,
+ /// Roots that point at unit DAT files.
+ pub unit_reference_count: usize,
+ /// Roots that point directly at prototype keys.
+ pub direct_reference_count: usize,
+ /// Component records reached from unit DAT files.
+ pub unit_component_count: usize,
+ /// Prototype requests that resolved to an effective prototype.
+ pub resolved_count: usize,
+ /// Mesh dependencies reached by resolved prototypes.
+ pub mesh_dependency_count: usize,
+ /// WEAR requests derived from reached mesh dependencies.
+ pub wear_request_count: usize,
+ /// WEAR entries successfully decoded.
+ pub wear_resolved_count: usize,
+ /// Material slots requested by decoded WEAR tables.
+ pub material_slot_count: usize,
+ /// MAT0 material entries successfully decoded.
+ pub material_resolved_count: usize,
+ /// Texture requests derived from MAT0 texture phases.
+ pub texture_request_count: usize,
+ /// Texm texture entries successfully decoded.
+ pub texture_resolved_count: usize,
+ /// Lightmap requests declared by decoded WEAR tables.
+ pub lightmap_request_count: usize,
+ /// Lightmap Texm entries successfully decoded.
+ pub lightmap_resolved_count: usize,
+ /// Graph failures tied to mission root edges.
+ pub failures: Vec<PrototypeGraphFailure>,
+}
+
+impl PrototypeGraphReport {
+ /// Returns true when all reachable mission roots resolved.
+ #[must_use]
+ pub fn is_success(&self) -> bool {
+ self.failures.is_empty()
+ && self.resolved_count == self.direct_reference_count + self.unit_component_count
+ }
+}
+
+/// Prototype graph failure tied to a root edge.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PrototypeGraphFailure {
+ /// Root index in the requested mission order.
+ pub root_index: usize,
+ /// Raw mission resource bytes.
+ pub resource_raw: Vec<u8>,
+ /// Edge that failed.
+ pub edge: PrototypeGraphEdge,
+ /// Failure detail.
+ pub message: String,
+}
+
+/// Prototype graph edge.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum PrototypeGraphEdge {
+ /// Mission object to unit DAT binding.
+ MissionToUnitDat,
+ /// Mission object to `objects.rlb` registry.
+ MissionToObjectsRegistry,
+ /// Unit DAT component to prototype key.
+ UnitDatToComponent,
+ /// Resolved prototype to mesh archive/resource.
+ PrototypeToMesh,
+ /// Mesh resource to matching WEAR table.
+ MeshToWear,
+ /// WEAR material slot to MAT0.
+ WearToMaterial,
+ /// MAT0 phase to Texm.
+ MaterialToTexture,
+ /// WEAR lightmap slot to lightmap Texm.
+ WearToLightmap,
+}
+
+/// Prototype error.
+#[derive(Debug)]
+pub enum PrototypeError {
+ /// Decode error.
+ Decode(DecodeError),
+ /// Invalid size.
+ InvalidSize,
+ /// Invalid unit DAT magic.
+ InvalidUnitDatMagic(u32),
+ /// Invalid path.
+ InvalidPath(String),
+ /// VFS error.
+ Vfs(String),
+ /// Resource repository error.
+ Resource(String),
+ /// Referenced mesh is present but invalid.
+ InvalidMesh(String),
+}
+
+impl From<DecodeError> for PrototypeError {
+ fn from(value: DecodeError) -> Self {
+ Self::Decode(value)
+ }
+}
+
+impl From<ResourceError> for PrototypeError {
+ fn from(value: ResourceError) -> Self {
+ Self::Resource(value.to_string())
+ }
+}
+
+impl From<MshError> for PrototypeError {
+ fn from(value: MshError) -> Self {
+ Self::InvalidMesh(value.to_string())
+ }
+}
+
+impl From<VfsError> for PrototypeError {
+ fn from(value: VfsError) -> Self {
+ Self::Vfs(value.to_string())
+ }
+}
+
+impl std::fmt::Display for PrototypeError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{self:?}")
+ }
+}
+
+impl std::error::Error for PrototypeError {}
+
+/// Decodes an `objects.rlb` registry entry as 64-byte records.
+///
+/// # Errors
+///
+/// Returns [`PrototypeError::InvalidSize`] when the payload is not composed of
+/// whole 64-byte records.
+pub fn decode_registry_entry(payload: &[u8]) -> Result<Vec<ObjectRefRecord>, PrototypeError> {
+ if !payload.len().is_multiple_of(64) {
+ return Err(PrototypeError::InvalidSize);
+ }
+ let mut out = Vec::with_capacity(payload.len() / 64);
+ for chunk in payload.chunks_exact(64) {
+ let mut archive_raw = [0; 32];
+ let mut resource_raw = [0; 32];
+ archive_raw.copy_from_slice(&chunk[..32]);
+ resource_raw.copy_from_slice(&chunk[32..64]);
+ out.push(ObjectRefRecord {
+ archive_raw,
+ resource_raw,
+ });
+ }
+ Ok(out)
+}
+
+/// Decodes unit DAT as an eight-byte header followed by `N * 112` bytes.
+///
+/// # Errors
+///
+/// Returns [`PrototypeError`] when the payload is too small or contains a
+/// partial component record.
+pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> {
+ if payload.len() < 8 {
+ return Err(PrototypeError::InvalidSize);
+ }
+ let mut header_opaque = [0; 8];
+ header_opaque.copy_from_slice(&payload[..8]);
+ let remaining = payload.len().saturating_sub(8) as u64;
+ if !remaining.is_multiple_of(112) {
+ return Err(PrototypeError::InvalidSize);
+ }
+ let record_count = remaining / 112;
+ let bytes = checked_count_bytes(record_count, 112, remaining)?;
+ if bytes as u64 != remaining {
+ return Err(PrototypeError::InvalidSize);
+ }
+ let mut cursor = Cursor::new(&payload[8..]);
+ let mut records = Vec::with_capacity(
+ usize::try_from(record_count).map_err(|_| DecodeError::IntegerOverflow)?,
+ );
+ for _ in 0..record_count {
+ let mut archive_raw = [0; 32];
+ let mut resource_raw = [0; 32];
+ let mut description_raw = [0; 32];
+ archive_raw.copy_from_slice(cursor.read_exact(32)?);
+ resource_raw.copy_from_slice(cursor.read_exact(32)?);
+ let kind = cursor.read_u32_le()?;
+ let parent_or_link = cursor.read_i32_le()?;
+ description_raw.copy_from_slice(cursor.read_exact(32)?);
+ let tail0 = cursor.read_u32_le()?;
+ let tail1 = cursor.read_u32_le()?;
+ records.push(UnitComponentRecord {
+ archive_raw,
+ resource_raw,
+ kind,
+ parent_or_link,
+ description_raw,
+ tail0,
+ tail1,
+ });
+ }
+ cursor.require_eof()?;
+ Ok(UnitDat {
+ header_opaque,
+ records,
+ })
+}
+
+/// Decodes a mission unit DAT binding.
+///
+/// # Errors
+///
+/// Returns [`PrototypeError`] when the DAT file is too small, has the wrong
+/// magic, or does not contain both archive and model keys.
+pub fn decode_unit_dat_binding(payload: &[u8]) -> Result<UnitDatBinding, PrototypeError> {
+ if payload.len() < UNIT_DAT_MIN_SIZE {
+ return Err(PrototypeError::InvalidSize);
+ }
+ let magic = u32::from_le_bytes(
+ payload[0..4]
+ .try_into()
+ .map_err(|_| PrototypeError::InvalidSize)?,
+ );
+ if magic != UNIT_DAT_MAGIC {
+ return Err(PrototypeError::InvalidUnitDatMagic(magic));
+ }
+ let flags = u32::from_le_bytes(
+ payload[4..8]
+ .try_into()
+ .map_err(|_| PrototypeError::InvalidSize)?,
+ );
+ let mut archive_raw = [0; 32];
+ let mut model_raw = [0; 32];
+ archive_raw.copy_from_slice(&payload[0x08..0x28]);
+ model_raw.copy_from_slice(&payload[0x28..0x48]);
+ if cstr_bytes(&archive_raw).is_empty() || cstr_bytes(&model_raw).is_empty() {
+ return Err(PrototypeError::InvalidSize);
+ }
+ Ok(UnitDatBinding {
+ flags,
+ archive_raw,
+ model_raw,
+ })
+}
+
+/// Resolves one prototype request through unit DAT, `objects.rlb`, and direct mesh lookup.
+///
+/// # Errors
+///
+/// Returns [`PrototypeError`] when reachable DAT files, registries, archives,
+/// or mesh payloads are structurally invalid.
+pub fn resolve_prototype(
+ repository: &dyn ResourceRepository,
+ vfs: &dyn Vfs,
+ resource: &ResourceName,
+) -> Result<Option<EffectivePrototype>, PrototypeError> {
+ if has_extension_bytes(&resource.0, b"dat") {
+ return resolve_unit_dat_first_component(repository, vfs, resource);
+ }
+
+ resolve_direct_prototype(repository, resource)
+}
+
+fn resolve_direct_prototype(
+ repository: &dyn ResourceRepository,
+ resource: &ResourceName,
+) -> Result<Option<EffectivePrototype>, PrototypeError> {
+ let objects =
+ archive_path(b"objects.rlb").map_err(|err| PrototypeError::InvalidPath(err.to_string()))?;
+ resolve_archive_model(
+ repository,
+ &objects,
+ resource,
+ PrototypeSource::ObjectsRegistry,
+ )
+}
+
+struct ResolvedPrototypeRequests {
+ expected_count: usize,
+ prototypes: Vec<EffectivePrototype>,
+}
+
+fn resolve_prototype_requests(
+ repository: &dyn ResourceRepository,
+ vfs: &dyn Vfs,
+ resource: &ResourceName,
+) -> Result<ResolvedPrototypeRequests, PrototypeError> {
+ if has_extension_bytes(&resource.0, b"dat") {
+ return resolve_unit_dat_prototype_requests(repository, vfs, resource);
+ }
+
+ let prototype = resolve_direct_prototype(repository, resource)?;
+ Ok(ResolvedPrototypeRequests {
+ expected_count: 1,
+ prototypes: prototype.into_iter().collect(),
+ })
+}
+
+fn resolve_unit_dat_first_component(
+ repository: &dyn ResourceRepository,
+ vfs: &dyn Vfs,
+ resource: &ResourceName,
+) -> Result<Option<EffectivePrototype>, PrototypeError> {
+ let expansion = resolve_unit_dat_prototype_requests(repository, vfs, resource)?;
+ Ok(expansion.prototypes.into_iter().next())
+}
+
+fn resolve_unit_dat_prototype_requests(
+ repository: &dyn ResourceRepository,
+ vfs: &dyn Vfs,
+ resource: &ResourceName,
+) -> Result<ResolvedPrototypeRequests, PrototypeError> {
+ let dat_path = normalized_path_from_name(resource)?;
+ let bytes = match vfs.read(&dat_path) {
+ Ok(bytes) => bytes,
+ Err(VfsError::NotFound(_)) => {
+ return Ok(ResolvedPrototypeRequests {
+ expected_count: 0,
+ prototypes: Vec::new(),
+ });
+ }
+ Err(err) => return Err(err.into()),
+ };
+
+ if let Ok(unit) = decode_unit_dat(&bytes) {
+ if !unit.records.is_empty() {
+ let mut prototypes = Vec::with_capacity(unit.records.len());
+ for record in &unit.records {
+ let prototype = resolve_unit_component(repository, record)?.ok_or_else(|| {
+ PrototypeError::Resource(format!(
+ "unit component {} did not resolve",
+ String::from_utf8_lossy(cstr_bytes(&record.resource_raw))
+ ))
+ })?;
+ prototypes.push(prototype);
+ }
+ return Ok(ResolvedPrototypeRequests {
+ expected_count: unit.records.len(),
+ prototypes,
+ });
+ }
+ }
+
+ let binding = decode_unit_dat_binding(&bytes)?;
+ let archive =
+ normalized_path_from_name(&ResourceName(cstr_bytes(&binding.archive_raw).to_vec()))?;
+ let model = ResourceName(cstr_bytes(&binding.model_raw).to_vec());
+ let prototype = resolve_archive_model(repository, &archive, &model, PrototypeSource::UnitDat)?;
+ Ok(ResolvedPrototypeRequests {
+ expected_count: 1,
+ prototypes: prototype.into_iter().collect(),
+ })
+}
+
+fn resolve_unit_component(
+ repository: &dyn ResourceRepository,
+ record: &UnitComponentRecord,
+) -> Result<Option<EffectivePrototype>, PrototypeError> {
+ let archive =
+ normalized_path_from_name(&ResourceName(cstr_bytes(&record.archive_raw).to_vec()))?;
+ let resource = ResourceName(cstr_bytes(&record.resource_raw).to_vec());
+ if resource.0.is_empty() {
+ return Ok(None);
+ }
+ resolve_archive_model(repository, &archive, &resource, PrototypeSource::UnitDat)
+}
+
+/// Resolves many roots and records every resolved root in a graph.
+///
+/// # Errors
+///
+/// Returns [`PrototypeError`] when any reachable root fails with a structural
+/// error.
+pub fn build_prototype_graph(
+ repository: &dyn ResourceRepository,
+ vfs: &dyn Vfs,
+ roots: &[ResourceName],
+) -> Result<(PrototypeGraph, Vec<EffectivePrototype>), PrototypeError> {
+ let mut graph = PrototypeGraph::default();
+ let mut resolved = Vec::new();
+ for root in roots {
+ let key = PrototypeKey(root.clone());
+ graph.roots.push(key);
+ let expansion = resolve_prototype_requests(repository, vfs, root)?;
+ for prototype in expansion.prototypes {
+ graph.prototype_requests.push(prototype.key.clone());
+ resolved.push(prototype);
+ }
+ }
+ Ok((graph, resolved))
+}
+
+/// Resolves many mission roots and records edge-specific graph failures.
+///
+/// This function reports per-root failures in [`PrototypeGraphReport`] instead
+/// of returning early.
+pub fn build_prototype_graph_report(
+ repository: &dyn ResourceRepository,
+ vfs: &dyn Vfs,
+ roots: &[ResourceName],
+) -> (
+ PrototypeGraph,
+ Vec<EffectivePrototype>,
+ PrototypeGraphReport,
+) {
+ let mut graph = PrototypeGraph::default();
+ let mut resolved = Vec::new();
+ let mut report = PrototypeGraphReport {
+ root_count: roots.len(),
+ ..PrototypeGraphReport::default()
+ };
+
+ for (root_index, root) in roots.iter().enumerate() {
+ graph.roots.push(PrototypeKey(root.clone()));
+ let edge = if has_extension_bytes(&root.0, b"dat") {
+ report.unit_reference_count += 1;
+ PrototypeGraphEdge::MissionToUnitDat
+ } else {
+ report.direct_reference_count += 1;
+ PrototypeGraphEdge::MissionToObjectsRegistry
+ };
+
+ match resolve_prototype_requests(repository, vfs, root) {
+ Ok(expansion) => {
+ let expected = expansion.expected_count;
+ if edge == PrototypeGraphEdge::MissionToUnitDat {
+ report.unit_component_count += expected;
+ }
+ let actual = expansion.prototypes.len();
+ for prototype in expansion.prototypes {
+ graph.prototype_requests.push(prototype.key.clone());
+ report.resolved_count += 1;
+ report.mesh_dependency_count += prototype.dependencies.len();
+ resolved.push(prototype);
+ }
+ if actual < expected {
+ report.failures.push(PrototypeGraphFailure {
+ root_index,
+ resource_raw: root.0.clone(),
+ edge,
+ message: "resource did not resolve to an effective prototype".to_string(),
+ });
+ }
+ }
+ Err(err) => report.failures.push(PrototypeGraphFailure {
+ root_index,
+ resource_raw: root.0.clone(),
+ edge: graph_error_edge(edge, &err),
+ message: err.to_string(),
+ }),
+ }
+ }
+
+ (graph, resolved, report)
+}
+
+/// Extends a graph report by validating visual dependencies for each resolved
+/// prototype.
+pub fn extend_graph_report_with_visual_dependencies(
+ repository: &dyn ResourceRepository,
+ report: &mut PrototypeGraphReport,
+ prototypes: &[EffectivePrototype],
+) {
+ let texture_archive = archive_path(b"textures.lib").ok();
+ let lightmap_archive = archive_path(b"lightmap.lib").ok();
+ for (prototype_index, prototype) in prototypes.iter().enumerate() {
+ let PrototypeGeometry::Mesh(mesh) = &prototype.geometry else {
+ continue;
+ };
+ report.wear_request_count += 1;
+ match resolve_wear_table(repository, mesh) {
+ Ok(table) => {
+ report.wear_resolved_count += 1;
+ report.material_slot_count += table.entries.len();
+ for (material_index, _entry) in table.entries.iter().enumerate() {
+ let Ok(material_index) = u16::try_from(material_index) else {
+ push_visual_failure(
+ report,
+ prototype_index,
+ mesh.name.0.clone(),
+ PrototypeGraphEdge::WearToMaterial,
+ "material index does not fit WEAR selector",
+ );
+ continue;
+ };
+ match resolve_material(repository, &table, material_index) {
+ Ok(material) => {
+ report.material_resolved_count += 1;
+ for texture in material.document.texture_requests() {
+ report.texture_request_count += 1;
+ match resolve_texm_from_candidates(
+ repository,
+ &texture,
+ [texture_archive.as_ref(), lightmap_archive.as_ref()],
+ ) {
+ Ok(()) => report.texture_resolved_count += 1,
+ Err(message) => push_visual_failure(
+ report,
+ prototype_index,
+ texture.0,
+ PrototypeGraphEdge::MaterialToTexture,
+ &message,
+ ),
+ }
+ }
+ }
+ Err(err) => push_visual_failure(
+ report,
+ prototype_index,
+ mesh.name.0.clone(),
+ PrototypeGraphEdge::WearToMaterial,
+ &err.to_string(),
+ ),
+ }
+ }
+ for lightmap in &table.lightmaps {
+ report.lightmap_request_count += 1;
+ match resolve_texm_from_candidates(
+ repository,
+ &lightmap.lightmap,
+ [lightmap_archive.as_ref(), texture_archive.as_ref()],
+ ) {
+ Ok(()) => report.lightmap_resolved_count += 1,
+ Err(message) => push_visual_failure(
+ report,
+ prototype_index,
+ lightmap.lightmap.0.clone(),
+ PrototypeGraphEdge::WearToLightmap,
+ &message,
+ ),
+ }
+ }
+ }
+ Err(message) => push_visual_failure(
+ report,
+ prototype_index,
+ mesh.name.0.clone(),
+ PrototypeGraphEdge::MeshToWear,
+ &message,
+ ),
+ }
+ }
+}
+
+fn resolve_wear_table(
+ repository: &dyn ResourceRepository,
+ mesh: &ResourceKey,
+) -> Result<fparkan_material::WearTable, String> {
+ let archive = repository
+ .open_archive(&mesh.archive)
+ .map_err(|err| err.to_string())?;
+ let wear_name = derive_wear_name(&mesh.name)
+ .ok_or_else(|| "cannot derive WEAR name from mesh resource".to_string())?;
+ let handle = repository
+ .find(archive, &wear_name)
+ .map_err(|err| err.to_string())?
+ .ok_or_else(|| {
+ format!(
+ "missing WEAR entry {}",
+ String::from_utf8_lossy(&wear_name.0)
+ )
+ })?;
+ let info = repository
+ .entry_info(handle)
+ .map_err(|err| err.to_string())?;
+ if info.key.type_id != Some(WEAR_KIND) {
+ return Err(format!(
+ "entry {} is not WEAR",
+ String::from_utf8_lossy(&wear_name.0)
+ ));
+ }
+ let bytes = repository
+ .read(handle)
+ .map_err(|err| err.to_string())?
+ .into_owned();
+ decode_wear(&bytes).map_err(|err| err.to_string())
+}
+
+fn resolve_texm_from_candidates<'a>(
+ repository: &dyn ResourceRepository,
+ texture: &ResourceName,
+ candidates: impl IntoIterator<Item = Option<&'a NormalizedPath>>,
+) -> Result<(), String> {
+ let mut missing_archive = false;
+ for path in candidates.into_iter().flatten() {
+ let archive = match repository.open_archive(path) {
+ Ok(archive) => archive,
+ Err(ResourceError::MissingArchive) => {
+ missing_archive = true;
+ continue;
+ }
+ Err(err) => return Err(err.to_string()),
+ };
+ let Some(handle) = repository
+ .find(archive, texture)
+ .map_err(|err| err.to_string())?
+ else {
+ continue;
+ };
+ let bytes = repository
+ .read(handle)
+ .map_err(|err| err.to_string())?
+ .into_owned();
+ decode_texm(Arc::from(bytes.into_boxed_slice())).map_err(|err| err.to_string())?;
+ return Ok(());
+ }
+ if missing_archive {
+ Err(format!(
+ "texture archive missing for {}",
+ String::from_utf8_lossy(&texture.0)
+ ))
+ } else {
+ Err(format!(
+ "missing texture {}",
+ String::from_utf8_lossy(&texture.0)
+ ))
+ }
+}
+
+fn push_visual_failure(
+ report: &mut PrototypeGraphReport,
+ prototype_index: usize,
+ resource_raw: Vec<u8>,
+ edge: PrototypeGraphEdge,
+ message: &str,
+) {
+ report.failures.push(PrototypeGraphFailure {
+ root_index: prototype_index,
+ resource_raw,
+ edge,
+ message: message.to_string(),
+ });
+}
+
+fn derive_wear_name(model_name: &ResourceName) -> Option<ResourceName> {
+ let stem = file_stem_bytes(&model_name.0);
+ if stem.is_empty() {
+ return None;
+ }
+ let mut out = stem.to_vec();
+ out.extend_from_slice(b".wea");
+ Some(ResourceName(out))
+}
+
+fn graph_error_edge(edge: PrototypeGraphEdge, err: &PrototypeError) -> PrototypeGraphEdge {
+ match err {
+ PrototypeError::InvalidMesh(_) => PrototypeGraphEdge::PrototypeToMesh,
+ PrototypeError::Decode(_)
+ | PrototypeError::InvalidSize
+ | PrototypeError::InvalidUnitDatMagic(_)
+ | PrototypeError::InvalidPath(_)
+ | PrototypeError::Vfs(_)
+ | PrototypeError::Resource(_) => edge,
+ }
+}
+
+fn resolve_archive_model(
+ repository: &dyn ResourceRepository,
+ archive: &NormalizedPath,
+ model_key: &ResourceName,
+ source: PrototypeSource,
+) -> Result<Option<EffectivePrototype>, PrototypeError> {
+ if archive.as_str().eq_ignore_ascii_case("objects.rlb") {
+ if let Some(prototype) = resolve_objects_registry_model(repository, archive, model_key)? {
+ return Ok(Some(prototype));
+ }
+ }
+
+ let Some(mesh) = find_mesh_resource(repository, archive, model_key)? else {
+ return Ok(None);
+ };
+ Ok(Some(effective(model_key.clone(), mesh, source)))
+}
+
+fn resolve_objects_registry_model(
+ repository: &dyn ResourceRepository,
+ registry_archive: &NormalizedPath,
+ object_key: &ResourceName,
+) -> Result<Option<EffectivePrototype>, PrototypeError> {
+ let Some(refs) =
+ collect_registry_refs(repository, registry_archive, object_key, &mut Vec::new(), 0)?
+ else {
+ return Ok(None);
+ };
+
+ let mut missing_mesh_refs = Vec::new();
+ for item in refs.iter().filter(|item| is_explicit_mesh_ref(item)) {
+ if let Some(prototype) =
+ resolve_object_ref_model(repository, object_key, item, cstr_bytes(&item.resource_raw))?
+ {
+ return Ok(Some(prototype));
+ }
+ missing_mesh_refs.push(describe_object_ref(item));
+ }
+ if !missing_mesh_refs.is_empty() {
+ return Err(PrototypeError::Resource(format!(
+ "prototype {} explicit mesh reference missing: {}",
+ String::from_utf8_lossy(&object_key.0),
+ missing_mesh_refs.join(" -> ")
+ )));
+ }
+
+ Ok(Some(EffectivePrototype {
+ key: PrototypeKey(object_key.clone()),
+ geometry: PrototypeGeometry::NonGeometric,
+ source: PrototypeSource::ObjectsRegistry,
+ dependencies: Vec::new(),
+ }))
+}
+
+fn collect_registry_refs(
+ repository: &dyn ResourceRepository,
+ registry_archive: &NormalizedPath,
+ object_key: &ResourceName,
+ stack: &mut Vec<ResourceName>,
+ depth: usize,
+) -> Result<Option<Vec<ObjectRefRecord>>, PrototypeError> {
+ if depth > PROTOTYPE_INHERITANCE_DEPTH_LIMIT {
+ return Err(PrototypeError::Resource(format!(
+ "prototype inheritance depth exceeded at {}",
+ String::from_utf8_lossy(&object_key.0)
+ )));
+ }
+ if stack
+ .iter()
+ .any(|item| eq_ignore_ascii_case(&item.0, &object_key.0))
+ {
+ return Err(PrototypeError::Resource(format!(
+ "prototype inheritance cycle at {}",
+ String::from_utf8_lossy(&object_key.0)
+ )));
+ }
+ let archive_id = match repository.open_archive(registry_archive) {
+ Ok(id) => id,
+ Err(ResourceError::MissingArchive) => return Ok(None),
+ Err(err) => return Err(err.into()),
+ };
+ let Some((registry_entry, _matched_name)) =
+ find_any_candidate(repository, archive_id, &mesh_name_candidates(&object_key.0))?
+ else {
+ return Ok(None);
+ };
+ let payload = repository.read(registry_entry)?.into_owned();
+ let refs = decode_registry_entry(&payload)?;
+ let mut effective_refs = Vec::new();
+ stack.push(object_key.clone());
+ for item in refs {
+ if archive_name_is(&item.archive_raw, b"objects.rlb") {
+ let parent_key = ResourceName(cstr_bytes(&item.resource_raw).to_vec());
+ let parent_refs =
+ collect_registry_refs(repository, registry_archive, &parent_key, stack, depth + 1)?
+ .ok_or_else(|| {
+ PrototypeError::Resource(format!(
+ "missing parent prototype {}",
+ String::from_utf8_lossy(&parent_key.0)
+ ))
+ })?;
+ effective_refs.extend(parent_refs);
+ } else {
+ effective_refs.push(item);
+ }
+ }
+ stack.pop();
+
+ Ok(Some(effective_refs))
+}
+
+fn resolve_object_ref_model(
+ repository: &dyn ResourceRepository,
+ requested: &ResourceName,
+ item: &ObjectRefRecord,
+ model_name: &[u8],
+) -> Result<Option<EffectivePrototype>, PrototypeError> {
+ let archive = normalized_path_from_name(&ResourceName(cstr_bytes(&item.archive_raw).to_vec()))?;
+ let Some(mesh) = find_mesh_resource(repository, &archive, &ResourceName(model_name.to_vec()))?
+ else {
+ return Ok(None);
+ };
+ Ok(Some(effective(
+ requested.clone(),
+ mesh,
+ PrototypeSource::ObjectsRegistry,
+ )))
+}
+
+fn is_explicit_mesh_ref(item: &ObjectRefRecord) -> bool {
+ has_extension_bytes(cstr_bytes(&item.resource_raw), b"msh")
+}
+
+fn describe_object_ref(item: &ObjectRefRecord) -> String {
+ format!(
+ "{}:{}",
+ String::from_utf8_lossy(cstr_bytes(&item.archive_raw)),
+ String::from_utf8_lossy(cstr_bytes(&item.resource_raw))
+ )
+}
+
+fn find_mesh_resource(
+ repository: &dyn ResourceRepository,
+ archive: &NormalizedPath,
+ model_key: &ResourceName,
+) -> Result<Option<ResourceKey>, PrototypeError> {
+ let archive_id = match repository.open_archive(archive) {
+ Ok(id) => id,
+ Err(ResourceError::MissingArchive) => return Ok(None),
+ Err(err) => return Err(err.into()),
+ };
+ let candidates = mesh_name_candidates(&model_key.0);
+ let Some((handle, matched_name)) = find_any_candidate(repository, archive_id, &candidates)?
+ else {
+ return Ok(None);
+ };
+ validate_mesh_payload(repository.read(handle)?.into_owned())?;
+ Ok(Some(ResourceKey {
+ archive: archive.clone(),
+ name: resource_name(matched_name),
+ type_id: Some(MESH_KIND),
+ }))
+}
+
+fn validate_mesh_payload(payload: Vec<u8>) -> Result<(), PrototypeError> {
+ let nested = fparkan_nres::decode(
+ Arc::from(payload.into_boxed_slice()),
+ ReadProfile::Compatible,
+ )
+ .map_err(|err| PrototypeError::InvalidMesh(err.to_string()))?;
+ let document = decode_msh(&nested)?;
+ validate_msh(&document)?;
+ Ok(())
+}
+
+fn find_any_candidate(
+ repository: &dyn ResourceRepository,
+ archive_id: fparkan_resource::ArchiveId,
+ candidates: &[Vec<u8>],
+) -> Result<Option<(fparkan_resource::EntryHandle, Vec<u8>)>, PrototypeError> {
+ for candidate in candidates {
+ if let Some(handle) = repository.find(archive_id, &resource_name(candidate))? {
+ return Ok(Some((handle, candidate.clone())));
+ }
+ }
+ Ok(None)
+}
+
+fn effective(
+ requested: ResourceName,
+ mesh: ResourceKey,
+ source: PrototypeSource,
+) -> EffectivePrototype {
+ EffectivePrototype {
+ key: PrototypeKey(requested),
+ geometry: PrototypeGeometry::Mesh(mesh.clone()),
+ source,
+ dependencies: vec![mesh],
+ }
+}
+
+fn mesh_name_candidates(name: &[u8]) -> Vec<Vec<u8>> {
+ let trimmed = trim_ascii(name);
+ if trimmed.is_empty() {
+ return Vec::new();
+ }
+ let mut out = Vec::new();
+ push_unique_bytes(&mut out, trimmed.to_vec());
+ if has_extension_bytes(trimmed, b"msh") {
+ let stem = file_stem_bytes(trimmed);
+ if !stem.is_empty() {
+ push_unique_bytes(&mut out, stem.to_vec());
+ }
+ } else {
+ let mut with_suffix = trimmed.to_vec();
+ with_suffix.extend_from_slice(b".msh");
+ push_unique_bytes(&mut out, with_suffix);
+ }
+ out
+}
+
+fn push_unique_bytes(items: &mut Vec<Vec<u8>>, value: Vec<u8>) {
+ if !items.iter().any(|item| eq_ignore_ascii_case(item, &value)) {
+ items.push(value);
+ }
+}
+
+fn normalized_path_from_name(name: &ResourceName) -> Result<NormalizedPath, PrototypeError> {
+ let text = legacy_path_text(cstr_bytes(&name.0));
+ normalize_relative(text.as_bytes(), PathPolicy::StrictLegacy)
+ .map_err(|err| PrototypeError::InvalidPath(err.to_string()))
+}
+
+fn legacy_path_text(raw: &[u8]) -> String {
+ if let Ok(text) = std::str::from_utf8(raw) {
+ text.to_string()
+ } else {
+ let (decoded, _, _) = WINDOWS_1251.decode(raw);
+ decoded.into_owned()
+ }
+}
+
+fn cstr_bytes(raw: &[u8]) -> &[u8] {
+ let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len());
+ trim_ascii(&raw[..len])
+}
+
+fn archive_name_is(raw: &[u8], expected: &[u8]) -> bool {
+ cstr_bytes(raw).eq_ignore_ascii_case(expected)
+}
+
+fn trim_ascii(bytes: &[u8]) -> &[u8] {
+ let mut start = 0usize;
+ let mut end = bytes.len();
+ while start < end && bytes[start].is_ascii_whitespace() {
+ start += 1;
+ }
+ while end > start && bytes[end - 1].is_ascii_whitespace() {
+ end -= 1;
+ }
+ &bytes[start..end]
+}
+
+fn has_extension_bytes(name: &[u8], ext: &[u8]) -> bool {
+ let Some(pos) = name.iter().rposition(|byte| *byte == b'.') else {
+ return false;
+ };
+ eq_ignore_ascii_case(&name[pos + 1..], ext)
+}
+
+fn file_stem_bytes(name: &[u8]) -> &[u8] {
+ let file_name = name
+ .iter()
+ .rposition(|byte| *byte == b'/' || *byte == b'\\')
+ .map_or(name, |pos| &name[pos + 1..]);
+ let Some(dot) = file_name.iter().rposition(|byte| *byte == b'.') else {
+ return file_name;
+ };
+ &file_name[..dot]
+}
+
+fn eq_ignore_ascii_case(left: &[u8], right: &[u8]) -> bool {
+ left.eq_ignore_ascii_case(right)
+}
+
+/// Decodes FX/prototype bytes by preserving them for future typed support.
+#[must_use]
+pub fn preserve_payload(payload: &[u8]) -> Arc<[u8]> {
+ Arc::from(payload.to_vec().into_boxed_slice())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_resource::{archive_path as resource_archive_path, CachedResourceRepository};
+ use fparkan_vfs::{DirectoryVfs, MemoryVfs};
+ use std::path::Path;
+
+ #[test]
+ fn registry_requires_record_multiple() {
+ assert!(decode_registry_entry(&[0; 63]).is_err());
+ assert_eq!(decode_registry_entry(&[0; 64]).expect("record").len(), 1);
+ }
+
+ #[test]
+ fn registry_zero_records_payload_is_empty() {
+ let records = decode_registry_entry(&[]).expect("empty registry");
+
+ assert!(records.is_empty());
+ }
+
+ #[test]
+ fn registry_preserves_bounded_name_tails_and_order() {
+ let mut bytes = Vec::new();
+ let mut first = [0u8; 64];
+ first[..9].copy_from_slice(b"arch\0tail");
+ first[32..40].copy_from_slice(b"res\0tail");
+ bytes.extend_from_slice(&first);
+ let mut second = [0u8; 64];
+ second[..10].copy_from_slice(b"other.rlb\0");
+ second[32..43].copy_from_slice(b"second.msh\0");
+ bytes.extend_from_slice(&second);
+
+ let records = decode_registry_entry(&bytes).expect("registry records");
+
+ assert_eq!(records.len(), 2);
+ assert_eq!(&records[0].archive_raw[..9], b"arch\0tail");
+ assert_eq!(&records[0].resource_raw[..8], b"res\0tail");
+ assert_eq!(cstr_bytes(&records[0].archive_raw), b"arch");
+ assert_eq!(cstr_bytes(&records[1].resource_raw), b"second.msh");
+ }
+
+ #[test]
+ fn unit_zero_records_uses_exact_size() {
+ let bytes = [0_u8; 8];
+ let unit = decode_unit_dat(&bytes).expect("unit");
+ assert!(unit.records.is_empty());
+ }
+
+ #[test]
+ fn unit_dat_one_record_uses_exact_size_formula() {
+ let bytes = build_unit_dat(&[(b"objects.rlb".as_slice(), b"component".as_slice())]);
+ let unit = decode_unit_dat(&bytes).expect("unit");
+
+ assert_eq!(bytes.len(), 8 + 112);
+ assert_eq!(unit.records.len(), 1);
+ assert_eq!(cstr_bytes(&unit.records[0].archive_raw), b"objects.rlb");
+ assert_eq!(cstr_bytes(&unit.records[0].resource_raw), b"component");
+ }
+
+ #[test]
+ fn unit_dat_rejects_truncated_record() {
+ let mut bytes = build_unit_dat(&[(b"objects.rlb".as_slice(), b"component".as_slice())]);
+ bytes.pop();
+
+ assert!(matches!(
+ decode_unit_dat(&bytes),
+ Err(PrototypeError::InvalidSize)
+ ));
+ }
+
+ #[test]
+ fn unit_dat_preserves_header_description_tail_and_parent_link() {
+ let mut bytes = build_unit_dat(&[(b"objects.rlb".as_slice(), b"component".as_slice())]);
+ bytes[0..8].copy_from_slice(&[0xF1, 0xF0, 1, 2, 3, 4, 5, 6]);
+ bytes[8 + 68..8 + 72].copy_from_slice(&(-7_i32).to_le_bytes());
+ let description = b"desc\0tail";
+ bytes[8 + 72..8 + 72 + description.len()].copy_from_slice(description);
+ bytes[8 + 104..8 + 108].copy_from_slice(&0x1122_3344_u32.to_le_bytes());
+ bytes[8 + 108..8 + 112].copy_from_slice(&0x5566_7788_u32.to_le_bytes());
+
+ let unit = decode_unit_dat(&bytes).expect("unit");
+ let record = &unit.records[0];
+ assert_eq!(unit.header_opaque, [0xF1, 0xF0, 1, 2, 3, 4, 5, 6]);
+ assert_eq!(record.parent_or_link, -7);
+ assert_eq!(&record.description_raw[..description.len()], description);
+ assert_eq!(record.tail0, 0x1122_3344);
+ assert_eq!(record.tail1, 0x5566_7788);
+ }
+
+ #[test]
+ fn unit_dat_accepts_full_description_without_nul() {
+ let mut bytes = build_unit_dat(&[(b"objects.rlb".as_slice(), b"component".as_slice())]);
+ bytes[8 + 72..8 + 104].copy_from_slice(b"12345678901234567890123456789012");
+
+ let unit = decode_unit_dat(&bytes).expect("unit");
+
+ assert_eq!(
+ &unit.records[0].description_raw,
+ b"12345678901234567890123456789012"
+ );
+ }
+
+ #[test]
+ fn unit_dat_preserves_positive_parent_link() {
+ let mut bytes = build_unit_dat(&[(b"objects.rlb".as_slice(), b"component".as_slice())]);
+ bytes[8 + 68..8 + 72].copy_from_slice(&12_i32.to_le_bytes());
+
+ let unit = decode_unit_dat(&bytes).expect("unit");
+
+ assert_eq!(unit.records[0].parent_or_link, 12);
+ }
+
+ #[test]
+ fn resolves_synthetic_objects_registry_model() {
+ let mut vfs = MemoryVfs::default();
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ let static_path = resource_archive_path(b"static.rlb").expect("static path");
+ let mesh = minimal_msh_payload();
+ vfs.insert(
+ objects_path,
+ Arc::from(
+ build_nres(&[(
+ b"s_tree_04".as_slice(),
+ build_object_refs(&[(b"static.rlb".as_slice(), b"s_tree_0_04.msh".as_slice())])
+ .as_slice(),
+ )])
+ .into_boxed_slice(),
+ ),
+ );
+ vfs.insert(
+ static_path,
+ Arc::from(
+ build_nres(&[(b"s_tree_0_04.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(),
+ ),
+ );
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+ let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"s_tree_04"))
+ .expect("resolve")
+ .expect("prototype");
+
+ assert_eq!(resolved.source, PrototypeSource::ObjectsRegistry);
+ let PrototypeGeometry::Mesh(mesh) = resolved.geometry else {
+ panic!("expected mesh");
+ };
+ assert_eq!(mesh.archive.as_str(), "static.rlb");
+ assert!(mesh.name.0.eq_ignore_ascii_case(b"s_tree_0_04.msh"));
+ }
+
+ #[test]
+ fn graph_report_records_resolved_roots_and_failures() {
+ let mut vfs = MemoryVfs::default();
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ let static_path = resource_archive_path(b"static.rlb").expect("static path");
+ let mesh = minimal_msh_payload();
+ vfs.insert(
+ objects_path,
+ Arc::from(
+ build_nres(&[(
+ b"s_tree_04".as_slice(),
+ build_object_refs(&[(b"static.rlb".as_slice(), b"s_tree_0_04.msh".as_slice())])
+ .as_slice(),
+ )])
+ .into_boxed_slice(),
+ ),
+ );
+ vfs.insert(
+ static_path,
+ Arc::from(
+ build_nres(&[(b"s_tree_0_04.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(),
+ ),
+ );
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+ let roots = [resource_name(b"s_tree_04"), resource_name(b"missing_key")];
+ let (graph, resolved, report) = build_prototype_graph_report(&repo, vfs.as_ref(), &roots);
+
+ assert_eq!(graph.roots.len(), 2);
+ assert_eq!(resolved.len(), 1);
+ assert_eq!(report.root_count, 2);
+ assert_eq!(report.direct_reference_count, 2);
+ assert_eq!(report.unit_reference_count, 0);
+ assert_eq!(report.resolved_count, 1);
+ assert_eq!(report.failures.len(), 1);
+ assert_eq!(report.failures[0].root_index, 1);
+ assert_eq!(
+ report.failures[0].edge,
+ PrototypeGraphEdge::MissionToObjectsRegistry
+ );
+ assert!(!report.is_success());
+ }
+
+ #[test]
+ fn resolves_synthetic_unit_dat_binding() {
+ let mut vfs = MemoryVfs::default();
+ let dat_path = resource_archive_path(b"UNITS/AUTO/unit.dat").expect("dat path");
+ let archive_path = resource_archive_path(b"units.rlb").expect("archive path");
+ let mesh = minimal_msh_payload();
+ vfs.insert(
+ dat_path,
+ Arc::from(build_unit_dat_binding(b"units.rlb", b"unit_model").into_boxed_slice()),
+ );
+ vfs.insert(
+ archive_path,
+ Arc::from(
+ build_nres(&[(b"unit_model.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(),
+ ),
+ );
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+ let resolved =
+ resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"UNITS/AUTO/unit.dat"))
+ .expect("resolve")
+ .expect("prototype");
+
+ assert_eq!(resolved.source, PrototypeSource::UnitDat);
+ let PrototypeGeometry::Mesh(mesh) = resolved.geometry else {
+ panic!("expected mesh");
+ };
+ assert_eq!(mesh.archive.as_str(), "units.rlb");
+ assert!(mesh.name.0.eq_ignore_ascii_case(b"unit_model.msh"));
+ }
+
+ #[test]
+ fn unit_dat_expands_components_in_order() {
+ let mut vfs = MemoryVfs::default();
+ let dat_path = resource_archive_path(b"UNITS/AUTO/compound.dat").expect("dat path");
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ let static_path = resource_archive_path(b"static.rlb").expect("static path");
+ let mesh = minimal_msh_payload();
+ vfs.insert(
+ dat_path,
+ Arc::from(
+ build_unit_dat(&[
+ (b"objects.rlb".as_slice(), b"component_a".as_slice()),
+ (b"objects.rlb".as_slice(), b"component_b".as_slice()),
+ ])
+ .into_boxed_slice(),
+ ),
+ );
+ vfs.insert(
+ objects_path,
+ Arc::from(
+ build_nres(&[
+ (
+ b"component_a".as_slice(),
+ build_object_refs(&[(
+ b"static.rlb".as_slice(),
+ b"component_a.msh".as_slice(),
+ )])
+ .as_slice(),
+ ),
+ (
+ b"component_b".as_slice(),
+ build_object_refs(&[(
+ b"static.rlb".as_slice(),
+ b"component_b.msh".as_slice(),
+ )])
+ .as_slice(),
+ ),
+ ])
+ .into_boxed_slice(),
+ ),
+ );
+ vfs.insert(
+ static_path,
+ Arc::from(
+ build_nres(&[
+ (b"component_a.msh".as_slice(), mesh.as_slice()),
+ (b"component_b.msh".as_slice(), mesh.as_slice()),
+ ])
+ .into_boxed_slice(),
+ ),
+ );
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+ let roots = [resource_name(b"UNITS/AUTO/compound.dat")];
+ let (graph, resolved, report) = build_prototype_graph_report(&repo, vfs.as_ref(), &roots);
+
+ assert_eq!(graph.roots.len(), 1);
+ assert_eq!(graph.prototype_requests.len(), 2);
+ assert_eq!(graph.prototype_requests[0].0 .0, b"component_a");
+ assert_eq!(graph.prototype_requests[1].0 .0, b"component_b");
+ assert_eq!(resolved.len(), 2);
+ assert_eq!(report.unit_reference_count, 1);
+ assert_eq!(report.unit_component_count, 2);
+ assert_eq!(report.resolved_count, 2);
+ assert!(report.is_success());
+ }
+
+ #[test]
+ fn objects_registry_inheritance_merges_parent_then_local_refs() {
+ let mut vfs = MemoryVfs::default();
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ let static_path = resource_archive_path(b"static.rlb").expect("static path");
+ let fortif_path = resource_archive_path(b"fortif.rlb").expect("fortif path");
+ let mesh = minimal_msh_payload();
+ vfs.insert(
+ objects_path,
+ Arc::from(
+ build_nres(&[
+ (
+ b"parent_proto".as_slice(),
+ build_object_refs(&[(
+ b"static.rlb".as_slice(),
+ b"parent_proto.msh".as_slice(),
+ )])
+ .as_slice(),
+ ),
+ (
+ b"child_proto".as_slice(),
+ build_object_refs(&[
+ (b"objects.rlb".as_slice(), b"parent_proto".as_slice()),
+ (b"fortif.rlb".as_slice(), b"child_proto.bas".as_slice()),
+ ])
+ .as_slice(),
+ ),
+ ])
+ .into_boxed_slice(),
+ ),
+ );
+ vfs.insert(
+ static_path,
+ Arc::from(
+ build_nres(&[(b"parent_proto.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(),
+ ),
+ );
+ vfs.insert(
+ fortif_path,
+ Arc::from(build_nres(&[(b"child_proto.bas".as_slice(), b"base")]).into_boxed_slice()),
+ );
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+ let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"child_proto"))
+ .expect("resolve")
+ .expect("prototype");
+
+ let PrototypeGeometry::Mesh(mesh) = resolved.geometry else {
+ panic!("expected inherited mesh");
+ };
+ assert_eq!(mesh.archive.as_str(), "static.rlb");
+ assert!(mesh.name.0.eq_ignore_ascii_case(b"parent_proto.msh"));
+ }
+
+ #[test]
+ fn objects_registry_inheritance_resolves_multiple_levels() {
+ let mut vfs = MemoryVfs::default();
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ let static_path = resource_archive_path(b"static.rlb").expect("static path");
+ let mesh = minimal_msh_payload();
+ vfs.insert(
+ objects_path,
+ Arc::from(
+ build_nres(&[
+ (
+ b"grandparent".as_slice(),
+ build_object_refs(&[(
+ b"static.rlb".as_slice(),
+ b"grandparent.msh".as_slice(),
+ )])
+ .as_slice(),
+ ),
+ (
+ b"parent".as_slice(),
+ build_object_refs(&[(
+ b"objects.rlb".as_slice(),
+ b"grandparent".as_slice(),
+ )])
+ .as_slice(),
+ ),
+ (
+ b"child".as_slice(),
+ build_object_refs(&[(b"objects.rlb".as_slice(), b"parent".as_slice())])
+ .as_slice(),
+ ),
+ ])
+ .into_boxed_slice(),
+ ),
+ );
+ vfs.insert(
+ static_path,
+ Arc::from(
+ build_nres(&[(b"grandparent.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(),
+ ),
+ );
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+ let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"child"))
+ .expect("resolve")
+ .expect("prototype");
+
+ let PrototypeGeometry::Mesh(mesh) = resolved.geometry else {
+ panic!("expected inherited mesh");
+ };
+ assert!(mesh.name.0.eq_ignore_ascii_case(b"grandparent.msh"));
+ }
+
+ #[test]
+ fn base_only_registry_entry_is_nongeometric() {
+ let mut vfs = MemoryVfs::default();
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ let fortif_path = resource_archive_path(b"fortif.rlb").expect("fortif path");
+ vfs.insert(
+ objects_path,
+ Arc::from(
+ build_nres(&[(
+ b"base_only".as_slice(),
+ build_object_refs(&[(b"fortif.rlb".as_slice(), b"base_only.bas".as_slice())])
+ .as_slice(),
+ )])
+ .into_boxed_slice(),
+ ),
+ );
+ vfs.insert(
+ fortif_path,
+ Arc::from(build_nres(&[(b"base_only.bas".as_slice(), b"base")]).into_boxed_slice()),
+ );
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+ let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"base_only"))
+ .expect("resolve")
+ .expect("prototype");
+
+ assert_eq!(resolved.geometry, PrototypeGeometry::NonGeometric);
+ assert!(resolved.dependencies.is_empty());
+ }
+
+ #[test]
+ fn objects_registry_inheritance_rejects_direct_cycle() {
+ let mut vfs = MemoryVfs::default();
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ vfs.insert(
+ objects_path,
+ Arc::from(
+ build_nres(&[(
+ b"self_cycle".as_slice(),
+ build_object_refs(&[(b"objects.rlb".as_slice(), b"self_cycle".as_slice())])
+ .as_slice(),
+ )])
+ .into_boxed_slice(),
+ ),
+ );
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+ let err = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"self_cycle"))
+ .expect_err("cycle");
+
+ assert!(err.to_string().contains("cycle"));
+ }
+
+ #[test]
+ fn objects_registry_inheritance_rejects_indirect_cycle() {
+ let mut vfs = MemoryVfs::default();
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ vfs.insert(
+ objects_path,
+ Arc::from(
+ build_nres(&[
+ (
+ b"cycle_a".as_slice(),
+ build_object_refs(&[(b"objects.rlb".as_slice(), b"cycle_b".as_slice())])
+ .as_slice(),
+ ),
+ (
+ b"cycle_b".as_slice(),
+ build_object_refs(&[(b"objects.rlb".as_slice(), b"cycle_a".as_slice())])
+ .as_slice(),
+ ),
+ ])
+ .into_boxed_slice(),
+ ),
+ );
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+ let err =
+ resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"cycle_a")).expect_err("cycle");
+
+ assert!(err.to_string().contains("cycle"));
+ }
+
+ #[test]
+ fn invalid_referenced_msh_is_error() {
+ let mut vfs = MemoryVfs::default();
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ let static_path = resource_archive_path(b"static.rlb").expect("static path");
+ vfs.insert(
+ objects_path,
+ Arc::from(
+ build_nres(&[(
+ b"bad_tree".as_slice(),
+ build_object_refs(&[(b"static.rlb".as_slice(), b"bad_tree.msh".as_slice())])
+ .as_slice(),
+ )])
+ .into_boxed_slice(),
+ ),
+ );
+ vfs.insert(
+ static_path,
+ Arc::from(
+ build_nres(&[(b"bad_tree.msh".as_slice(), b"not an nres".as_slice())])
+ .into_boxed_slice(),
+ ),
+ );
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+
+ let err = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"bad_tree"))
+ .expect_err("invalid mesh");
+
+ assert!(matches!(err, PrototypeError::InvalidMesh(_)));
+ }
+
+ #[test]
+ fn missing_referenced_archive_reports_root_chain() {
+ let mut vfs = MemoryVfs::default();
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ vfs.insert(
+ objects_path,
+ Arc::from(
+ build_nres(&[(
+ b"broken".as_slice(),
+ build_object_refs(&[(b"missing.rlb".as_slice(), b"broken.msh".as_slice())])
+ .as_slice(),
+ )])
+ .into_boxed_slice(),
+ ),
+ );
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+
+ let (_graph, _resolved, report) =
+ build_prototype_graph_report(&repo, vfs.as_ref(), &[resource_name(b"broken")]);
+
+ assert_eq!(report.failures.len(), 1);
+ assert_eq!(report.failures[0].resource_raw, b"broken");
+ assert_eq!(
+ report.failures[0].edge,
+ PrototypeGraphEdge::MissionToObjectsRegistry
+ );
+ assert!(report.failures[0].message.contains("broken"));
+ assert!(report.failures[0]
+ .message
+ .contains("missing.rlb:broken.msh"));
+ }
+
+ #[test]
+ fn missing_referenced_resource_reports_root_chain() {
+ let mut vfs = MemoryVfs::default();
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ let static_path = resource_archive_path(b"static.rlb").expect("static path");
+ vfs.insert(
+ objects_path,
+ Arc::from(
+ build_nres(&[(
+ b"broken".as_slice(),
+ build_object_refs(&[(b"static.rlb".as_slice(), b"missing.msh".as_slice())])
+ .as_slice(),
+ )])
+ .into_boxed_slice(),
+ ),
+ );
+ vfs.insert(static_path, Arc::from(build_nres(&[]).into_boxed_slice()));
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+
+ let (_graph, _resolved, report) =
+ build_prototype_graph_report(&repo, vfs.as_ref(), &[resource_name(b"broken")]);
+
+ assert_eq!(report.failures.len(), 1);
+ assert_eq!(report.failures[0].resource_raw, b"broken");
+ assert!(report.failures[0]
+ .message
+ .contains("static.rlb:missing.msh"));
+ }
+
+ #[test]
+ fn first_existing_explicit_msh_is_selected_in_order() {
+ let mut vfs = MemoryVfs::default();
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ let static_path = resource_archive_path(b"static.rlb").expect("static path");
+ let mesh = minimal_msh_payload();
+ vfs.insert(
+ objects_path,
+ Arc::from(
+ build_nres(&[(
+ b"ordered".as_slice(),
+ build_object_refs(&[
+ (b"static.rlb".as_slice(), b"missing.msh".as_slice()),
+ (b"static.rlb".as_slice(), b"ordered.msh".as_slice()),
+ ])
+ .as_slice(),
+ )])
+ .into_boxed_slice(),
+ ),
+ );
+ vfs.insert(
+ static_path,
+ Arc::from(
+ build_nres(&[(b"ordered.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(),
+ ),
+ );
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+
+ let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"ordered"))
+ .expect("ordered resolve")
+ .expect("prototype");
+
+ let PrototypeGeometry::Mesh(mesh) = resolved.geometry else {
+ panic!("expected mesh");
+ };
+ assert!(mesh.name.0.eq_ignore_ascii_case(b"ordered.msh"));
+ }
+
+ #[test]
+ fn objects_registry_inheritance_rejects_depth_limit() {
+ let mut names = Vec::new();
+ let mut payloads = Vec::new();
+ for index in 0..34usize {
+ names.push(format!("proto_{index}").into_bytes());
+ payloads.push(build_object_refs(&[(
+ b"objects.rlb".as_slice(),
+ format!("proto_{}", index + 1).as_bytes(),
+ )]));
+ }
+ let entries = names
+ .iter()
+ .zip(payloads.iter())
+ .map(|(name, payload)| (name.as_slice(), payload.as_slice()))
+ .collect::<Vec<_>>();
+ let mut vfs = MemoryVfs::default();
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ vfs.insert(
+ objects_path,
+ Arc::from(build_nres(&entries).into_boxed_slice()),
+ );
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+
+ let err =
+ resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"proto_0")).expect_err("depth");
+
+ assert!(err.to_string().contains("depth exceeded"));
+ }
+
+ #[test]
+ fn generated_acyclic_prototype_graph_resolves_deterministically() {
+ let first = generated_acyclic_graph(&[0, 1, 2, 3, 4, 5]);
+ let second = generated_acyclic_graph(&[5, 4, 3, 2, 1, 0]);
+
+ assert_eq!(first.0, second.0);
+ assert_eq!(first.1, second.1);
+ assert_eq!(first.2, second.2);
+ }
+
+ #[test]
+ fn arbitrary_unit_and_registry_bytes_are_bounded_and_panic_free() {
+ for len in 0..256usize {
+ let bytes = vec![0xA5; len];
+ let unit = std::panic::catch_unwind(|| decode_unit_dat(&bytes));
+ let registry = std::panic::catch_unwind(|| decode_registry_entry(&bytes));
+
+ assert!(unit.is_ok());
+ assert!(registry.is_ok());
+ }
+ }
+
+ #[test]
+ fn resolver_cache_invalidates_when_archive_fingerprint_changes() {
+ let root = temp_dir("resolver-cache");
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ let static_path = resource_archive_path(b"static.rlb").expect("static path");
+ std::fs::write(
+ root.join(objects_path.as_str()),
+ build_nres(&[(
+ b"dynamic".as_slice(),
+ build_object_refs(&[(b"static.rlb".as_slice(), b"dynamic.msh".as_slice())])
+ .as_slice(),
+ )]),
+ )
+ .expect("objects.rlb");
+ std::fs::write(
+ root.join(static_path.as_str()),
+ build_nres(&[(b"dynamic.msh".as_slice(), b"not an nres".as_slice())]),
+ )
+ .expect("initial static.rlb");
+ let vfs = Arc::new(DirectoryVfs::new(&root));
+ let repo = CachedResourceRepository::new(vfs.clone());
+
+ let err = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"dynamic"))
+ .expect_err("invalid initial mesh");
+ assert!(matches!(err, PrototypeError::InvalidMesh(_)));
+
+ std::fs::write(
+ root.join(static_path.as_str()),
+ build_nres(&[(b"dynamic.msh".as_slice(), minimal_msh_payload().as_slice())]),
+ )
+ .expect("updated static.rlb");
+ let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"dynamic"))
+ .expect("updated resolve")
+ .expect("prototype");
+
+ let PrototypeGeometry::Mesh(mesh) = resolved.geometry else {
+ panic!("expected mesh");
+ };
+ assert!(mesh.name.0.eq_ignore_ascii_case(b"dynamic.msh"));
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn resolves_known_part1_registry_cases() {
+ let root = corpus_root("IS").expect("part 1 root");
+ let vfs = Arc::new(DirectoryVfs::new(&root));
+ let repo = CachedResourceRepository::new(vfs.clone());
+ let cases = [
+ (b"r_h_01".as_slice(), "bases.rlb", b"r_h_01.msh".as_slice()),
+ (
+ b"s_tree_04".as_slice(),
+ "static.rlb",
+ b"s_tree_0_04.msh".as_slice(),
+ ),
+ (
+ b"fr_m_brige".as_slice(),
+ "fortif.rlb",
+ b"fr_m_brige.msh".as_slice(),
+ ),
+ ];
+
+ for (key, archive, model) in cases {
+ let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(key))
+ .unwrap_or_else(|err| panic!("failed to resolve {:?}: {err}", key))
+ .unwrap_or_else(|| panic!("missing prototype for {:?}", key));
+ let PrototypeGeometry::Mesh(mesh) = resolved.geometry else {
+ panic!("expected mesh");
+ };
+ assert_eq!(mesh.archive.as_str().to_ascii_lowercase(), archive);
+ assert!(mesh.name.0.eq_ignore_ascii_case(model));
+ }
+ }
+
+ #[test]
+ fn resolves_some_registry_entries_in_both_corpora() {
+ for corpus in ["IS", "IS2"] {
+ let root = corpus_root(corpus).expect("corpus root");
+ let objects = std::fs::read(root.join("objects.rlb")).expect("objects.rlb");
+ let document = fparkan_nres::decode(
+ Arc::from(objects.into_boxed_slice()),
+ fparkan_nres::ReadProfile::Compatible,
+ )
+ .expect("objects.rlb document");
+ let vfs = Arc::new(DirectoryVfs::new(&root));
+ let repo = CachedResourceRepository::new(vfs.clone());
+ let mut resolved = 0usize;
+
+ for entry in document.entries().iter().take(64) {
+ if resolve_prototype(&repo, vfs.as_ref(), &resource_name(entry.name_bytes()))
+ .unwrap_or_else(|err| panic!("{corpus} {:?}: {err}", entry.name_bytes()))
+ .is_some()
+ {
+ resolved += 1;
+ }
+ }
+
+ assert!(resolved > 0, "{corpus}: no registry entries resolved");
+ }
+ }
+
+ #[test]
+ fn licensed_corpora_unit_dat_parse_counts() {
+ let cases = [("IS", 425, 5_219), ("IS2", 676, 8_145)];
+ for (corpus, expected_files, expected_records) in cases {
+ let root = corpus_root(corpus).expect("corpus root");
+ let mut dat_paths = Vec::new();
+ collect_unit_dat_files(&root, &mut dat_paths);
+ dat_paths.sort();
+ let mut records = 0usize;
+ for path in &dat_paths {
+ let bytes = std::fs::read(path).expect("unit DAT");
+ let unit = decode_unit_dat(&bytes).expect("unit DAT decode");
+ for record in &unit.records {
+ assert!(
+ archive_name_is(&record.archive_raw, b"objects.rlb"),
+ "{}: unexpected component archive {:?}",
+ path.display(),
+ cstr_bytes(&record.archive_raw)
+ );
+ assert_eq!(
+ record.kind,
+ 1,
+ "{}: unexpected component kind",
+ path.display()
+ );
+ }
+ records += unit.records.len();
+ }
+ assert_eq!(dat_paths.len(), expected_files, "{corpus} unit DAT files");
+ assert_eq!(records, expected_records, "{corpus} unit DAT records");
+ }
+ }
+
+ #[test]
+ fn licensed_corpora_registry_payloads_are_record_aligned() {
+ for corpus in ["IS", "IS2"] {
+ let root = corpus_root(corpus).expect("corpus root");
+ let objects = std::fs::read(root.join("objects.rlb")).expect("objects.rlb");
+ let document = fparkan_nres::decode(
+ Arc::from(objects.into_boxed_slice()),
+ fparkan_nres::ReadProfile::Compatible,
+ )
+ .expect("objects.rlb document");
+
+ assert!(document.entry_count() > 0, "{corpus}: empty objects.rlb");
+ for entry in document.entries() {
+ let payload = document.payload(entry.id()).expect("registry payload");
+ assert!(
+ payload.len().is_multiple_of(64),
+ "{corpus}: registry payload for {:?} is not 64-byte aligned",
+ entry.name_bytes()
+ );
+ decode_registry_entry(payload).expect("registry payload decode");
+ }
+ }
+ }
+
+ fn collect_unit_dat_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
+ let mut children: Vec<_> = std::fs::read_dir(dir)
+ .expect("read dir")
+ .map(|entry| entry.expect("entry").path())
+ .collect();
+ children.sort();
+ for child in children {
+ if child.is_dir() {
+ collect_unit_dat_files(&child, out);
+ } else if child
+ .extension()
+ .and_then(|ext| ext.to_str())
+ .is_some_and(|ext| ext.eq_ignore_ascii_case("dat"))
+ && child.components().any(|component| {
+ component
+ .as_os_str()
+ .to_str()
+ .is_some_and(|text| text.eq_ignore_ascii_case("UNITS"))
+ })
+ {
+ out.push(child);
+ }
+ }
+ }
+
+ fn corpus_root(name: &str) -> Option<std::path::PathBuf> {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("testdata")
+ .join(name);
+ root.is_dir().then_some(root)
+ }
+
+ fn generated_acyclic_graph(
+ order: &[usize],
+ ) -> (
+ PrototypeGraph,
+ Vec<EffectivePrototype>,
+ PrototypeGraphReport,
+ ) {
+ let names = (0..6usize)
+ .map(|index| format!("node_{index}").into_bytes())
+ .collect::<Vec<_>>();
+ let payloads = (0..6usize)
+ .map(|index| {
+ if index == 0 {
+ build_object_refs(&[(b"static.rlb".as_slice(), b"node_0.msh".as_slice())])
+ } else {
+ build_object_refs(&[(
+ b"objects.rlb".as_slice(),
+ format!("node_{}", index - 1).as_bytes(),
+ )])
+ }
+ })
+ .collect::<Vec<_>>();
+ let entries = order
+ .iter()
+ .map(|index| (names[*index].as_slice(), payloads[*index].as_slice()))
+ .collect::<Vec<_>>();
+ let mut vfs = MemoryVfs::default();
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ let static_path = resource_archive_path(b"static.rlb").expect("static path");
+ vfs.insert(
+ objects_path,
+ Arc::from(build_nres(&entries).into_boxed_slice()),
+ );
+ vfs.insert(
+ static_path,
+ Arc::from(
+ build_nres(&[(b"node_0.msh".as_slice(), minimal_msh_payload().as_slice())])
+ .into_boxed_slice(),
+ ),
+ );
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+ build_prototype_graph_report(
+ &repo,
+ vfs.as_ref(),
+ &[resource_name(b"node_5"), resource_name(b"node_3")],
+ )
+ }
+
+ fn temp_dir(name: &str) -> std::path::PathBuf {
+ let path = std::env::temp_dir().join(format!(
+ "fparkan-prototype-{name}-{}",
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("clock")
+ .as_nanos()
+ ));
+ std::fs::create_dir_all(&path).expect("temp dir");
+ path
+ }
+
+ fn build_unit_dat_binding(archive: &[u8], model: &[u8]) -> Vec<u8> {
+ let mut out = vec![0; UNIT_DAT_MIN_SIZE];
+ out[0..4].copy_from_slice(&UNIT_DAT_MAGIC.to_le_bytes());
+ copy_cstr(&mut out[0x08..0x28], archive);
+ copy_cstr(&mut out[0x28..0x48], model);
+ out
+ }
+
+ fn build_unit_dat(components: &[(&[u8], &[u8])]) -> Vec<u8> {
+ let mut out = vec![0; 8];
+ out[0..4].copy_from_slice(&UNIT_DAT_MAGIC.to_le_bytes());
+ for (index, (archive, resource)) in components.iter().enumerate() {
+ let mut record = [0; 112];
+ copy_cstr(&mut record[0..32], archive);
+ copy_cstr(&mut record[32..64], resource);
+ record[64..68].copy_from_slice(&1_u32.to_le_bytes());
+ record[68..72].copy_from_slice(
+ &i32::try_from(index)
+ .map_or(-1, |value| value.saturating_sub(1))
+ .to_le_bytes(),
+ );
+ copy_cstr(&mut record[72..104], b"component");
+ out.extend_from_slice(&record);
+ }
+ out
+ }
+
+ fn build_object_refs(items: &[(&[u8], &[u8])]) -> Vec<u8> {
+ let mut out = Vec::with_capacity(items.len() * 64);
+ for (archive, resource) in items {
+ let mut chunk = [0; 64];
+ copy_cstr(&mut chunk[..32], archive);
+ copy_cstr(&mut chunk[32..], resource);
+ out.extend_from_slice(&chunk);
+ }
+ out
+ }
+
+ fn build_nres(entries: &[(&[u8], &[u8])]) -> Vec<u8> {
+ let entries = entries
+ .iter()
+ .map(|(name, payload)| TestEntry {
+ type_id: 0,
+ attr3: 0,
+ name,
+ payload,
+ })
+ .collect::<Vec<_>>();
+ build_nres_typed(&entries)
+ }
+
+ fn minimal_msh_payload() -> Vec<u8> {
+ build_nres_typed(&[
+ TestEntry {
+ type_id: 1,
+ attr3: 38,
+ name: b"Res1",
+ payload: &[],
+ },
+ TestEntry {
+ type_id: 2,
+ attr3: 0,
+ name: b"Res2",
+ payload: &[0; 0x8c],
+ },
+ TestEntry {
+ type_id: 3,
+ attr3: 0,
+ name: b"Res3",
+ payload: &[],
+ },
+ TestEntry {
+ type_id: 6,
+ attr3: 0,
+ name: b"Res6",
+ payload: &[],
+ },
+ TestEntry {
+ type_id: 13,
+ attr3: 0,
+ name: b"Res13",
+ payload: &[],
+ },
+ ])
+ }
+
+ struct TestEntry<'a> {
+ type_id: u32,
+ attr3: u32,
+ name: &'a [u8],
+ payload: &'a [u8],
+ }
+
+ fn build_nres_typed(entries: &[TestEntry<'_>]) -> Vec<u8> {
+ let mut out = vec![0; 16];
+ let mut offsets = Vec::with_capacity(entries.len());
+ for entry in entries {
+ offsets.push(u32::try_from(out.len()).expect("offset"));
+ out.extend_from_slice(entry.payload);
+ let padding = (8 - (out.len() % 8)) % 8;
+ out.resize(out.len() + padding, 0);
+ }
+ let mut order: Vec<usize> = (0..entries.len()).collect();
+ order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name));
+ for (idx, entry) in entries.iter().enumerate() {
+ push_u32(&mut out, entry.type_id);
+ push_u32(&mut out, 0);
+ push_u32(&mut out, 0);
+ push_u32(
+ &mut out,
+ u32::try_from(entry.payload.len()).expect("payload"),
+ );
+ push_u32(&mut out, entry.attr3);
+ let mut name_raw = [0; 36];
+ copy_cstr(&mut name_raw, entry.name);
+ out.extend_from_slice(&name_raw);
+ push_u32(&mut out, offsets[idx]);
+ push_u32(&mut out, u32::try_from(order[idx]).expect("sort index"));
+ }
+ out[0..4].copy_from_slice(b"NRes");
+ out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
+ out[8..12].copy_from_slice(&u32::try_from(entries.len()).expect("count").to_le_bytes());
+ let total_size = u32::try_from(out.len()).expect("total size");
+ out[12..16].copy_from_slice(&total_size.to_le_bytes());
+ out
+ }
+
+ fn copy_cstr(dst: &mut [u8], src: &[u8]) {
+ let len = dst.len().saturating_sub(1).min(src.len());
+ dst[..len].copy_from_slice(&src[..len]);
+ }
+
+ fn push_u32(out: &mut Vec<u8>, value: u32) {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+}
diff --git a/crates/fparkan-render/Cargo.toml b/crates/fparkan-render/Cargo.toml
new file mode 100644
index 0000000..b045d68
--- /dev/null
+++ b/crates/fparkan-render/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "fparkan-render"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+fparkan-world = { path = "../fparkan-world" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs
new file mode 100644
index 0000000..a2f18d6
--- /dev/null
+++ b/crates/fparkan-render/src/lib.rs
@@ -0,0 +1,554 @@
+#![forbid(unsafe_code)]
+//! Backend-neutral render commands and deterministic captures.
+
+use fparkan_world::OriginalObjectId;
+
+/// Immutable camera data visible to command generation.
+#[derive(Clone, Debug, PartialEq)]
+pub struct CameraSnapshot {
+ /// View matrix, row-major.
+ pub view: [f32; 16],
+ /// Projection matrix, row-major.
+ pub projection: [f32; 16],
+}
+
+impl Default for CameraSnapshot {
+ fn default() -> Self {
+ Self {
+ view: identity_transform(),
+ projection: identity_transform(),
+ }
+ }
+}
+
+/// Draw id.
+#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
+pub struct DrawId(pub u64);
+
+/// GPU mesh id.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct GpuMeshId(pub u64);
+
+/// GPU material id.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct GpuMaterialId(pub u64);
+
+/// Render phase.
+#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
+pub enum RenderPhase {
+ /// Terrain.
+ Terrain,
+ /// Opaque.
+ Opaque,
+ /// Alpha test.
+ AlphaTest,
+ /// Transparent.
+ Transparent,
+ /// Effects.
+ Effects,
+ /// Debug.
+ Debug,
+ /// UI.
+ Ui,
+}
+
+/// Index range.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct IndexRange {
+ /// Start.
+ pub start: u32,
+ /// Count.
+ pub count: u32,
+}
+
+/// A draw candidate in an immutable render snapshot.
+#[derive(Clone, Debug, PartialEq)]
+pub struct RenderSnapshotDraw {
+ /// Draw id.
+ pub id: DrawId,
+ /// Phase.
+ pub phase: RenderPhase,
+ /// Object id.
+ pub object_id: Option<OriginalObjectId>,
+ /// Mesh.
+ pub mesh: GpuMeshId,
+ /// Material table after WEAR/MAT0 fallback resolution.
+ pub material_slots: Vec<GpuMaterialId>,
+ /// Batch material index into [`Self::material_slots`].
+ pub material_index: u16,
+ /// Node transform matrix, row-major.
+ pub transform: [f32; 16],
+ /// Index range.
+ pub range: IndexRange,
+ /// Stable sort order.
+ pub stable_order: u64,
+}
+
+/// Immutable backend-neutral render snapshot.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct RenderSnapshot {
+ /// Camera data for the frame.
+ pub camera: CameraSnapshot,
+ /// Draw candidates gathered from world/assets.
+ pub draws: Vec<RenderSnapshotDraw>,
+}
+
+/// Command generation profile.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct RenderProfile {
+ /// Include UI phase commands when present.
+ pub include_ui: bool,
+}
+
+/// Draw command.
+#[derive(Clone, Debug, PartialEq)]
+pub struct DrawCommand {
+ /// Draw id.
+ pub id: DrawId,
+ /// Phase.
+ pub phase: RenderPhase,
+ /// Object id.
+ pub object_id: Option<OriginalObjectId>,
+ /// Mesh.
+ pub mesh: GpuMeshId,
+ /// Material.
+ pub material: GpuMaterialId,
+ /// Transform matrix, row-major.
+ pub transform: [f32; 16],
+ /// Index range.
+ pub range: IndexRange,
+ /// Stable sort order.
+ pub stable_order: u64,
+}
+
+/// Render command.
+#[derive(Clone, Debug, PartialEq)]
+pub enum RenderCommand {
+ /// Begin frame.
+ BeginFrame,
+ /// Draw.
+ Draw(DrawCommand),
+ /// End frame.
+ EndFrame,
+}
+
+/// Render command list.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct RenderCommandList {
+ /// Commands.
+ pub commands: Vec<RenderCommand>,
+}
+
+/// Frame output.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct FrameOutput;
+
+/// Render error.
+#[derive(Debug)]
+pub enum RenderError {
+ /// Invalid range.
+ InvalidRange,
+ /// Invalid draw range with command-generation context.
+ InvalidDrawRange {
+ /// Draw id.
+ draw_id: DrawId,
+ /// Stable sort order.
+ stable_order: u64,
+ /// Range start.
+ start: u32,
+ /// Range count.
+ count: u32,
+ },
+ /// A batch material index did not resolve through the material table.
+ MaterialIndexOutOfBounds {
+ /// Draw id.
+ draw_id: DrawId,
+ /// Requested material index.
+ material_index: u16,
+ /// Available material slots.
+ material_count: usize,
+ },
+}
+
+impl std::fmt::Display for RenderError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{self:?}")
+ }
+}
+
+impl std::error::Error for RenderError {}
+
+/// Builds a deterministic command list from an immutable render snapshot.
+///
+/// # Errors
+///
+/// Returns [`RenderError`] when a draw has an invalid index range or a material
+/// index that cannot be resolved through its material slot table.
+pub fn build_commands(
+ snapshot: &RenderSnapshot,
+ profile: RenderProfile,
+) -> Result<RenderCommandList, RenderError> {
+ let mut draws = snapshot
+ .draws
+ .iter()
+ .filter(|draw| profile.include_ui || draw.phase != RenderPhase::Ui)
+ .collect::<Vec<_>>();
+ draws.sort_by_key(|draw| (draw.phase, draw.stable_order, draw.id));
+
+ let mut commands = Vec::with_capacity(draws.len() + 2);
+ commands.push(RenderCommand::BeginFrame);
+ for draw in draws {
+ if draw.range.count == 0 {
+ return Err(RenderError::InvalidDrawRange {
+ draw_id: draw.id,
+ stable_order: draw.stable_order,
+ start: draw.range.start,
+ count: draw.range.count,
+ });
+ }
+ let material = draw
+ .material_slots
+ .get(usize::from(draw.material_index))
+ .copied()
+ .ok_or(RenderError::MaterialIndexOutOfBounds {
+ draw_id: draw.id,
+ material_index: draw.material_index,
+ material_count: draw.material_slots.len(),
+ })?;
+ commands.push(RenderCommand::Draw(DrawCommand {
+ id: draw.id,
+ phase: draw.phase,
+ object_id: draw.object_id,
+ mesh: draw.mesh,
+ material,
+ transform: draw.transform,
+ range: draw.range,
+ stable_order: draw.stable_order,
+ }));
+ }
+ commands.push(RenderCommand::EndFrame);
+ Ok(RenderCommandList { commands })
+}
+
+/// Backend port.
+pub trait RenderBackend {
+ /// Executes commands.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`RenderError`] when the command stream is malformed for the
+ /// backend.
+ fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError>;
+}
+
+/// Backend that validates commands and intentionally produces no pixels.
+#[derive(Clone, Debug, Default)]
+pub struct NullBackend;
+
+impl RenderBackend for NullBackend {
+ fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
+ validate_commands(commands)?;
+ Ok(FrameOutput)
+ }
+}
+
+/// Backend that stores deterministic command captures for verification.
+#[derive(Clone, Debug, Default)]
+pub struct RecordingBackend {
+ captures: Vec<Vec<u8>>,
+}
+
+impl RecordingBackend {
+ /// Returns all captures in submission order.
+ #[must_use]
+ pub fn captures(&self) -> &[Vec<u8>] {
+ &self.captures
+ }
+
+ /// Returns the most recent capture.
+ #[must_use]
+ pub fn last_capture(&self) -> Option<&[u8]> {
+ self.captures.last().map(Vec::as_slice)
+ }
+
+ /// Clears stored captures without changing backend behavior.
+ pub fn clear(&mut self) {
+ self.captures.clear();
+ }
+}
+
+impl RenderBackend for RecordingBackend {
+ fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
+ let capture = canonical_capture(commands)?;
+ self.captures.push(capture);
+ Ok(FrameOutput)
+ }
+}
+
+/// Builds a canonical capture.
+///
+/// # Errors
+///
+/// Returns [`RenderError`] when a draw command contains an invalid index range.
+pub fn canonical_capture(commands: &RenderCommandList) -> Result<Vec<u8>, RenderError> {
+ validate_commands(commands)?;
+ let mut out = Vec::new();
+ for command in &commands.commands {
+ match command {
+ RenderCommand::BeginFrame => out.extend_from_slice(b"B\n"),
+ RenderCommand::EndFrame => out.extend_from_slice(b"E\n"),
+ RenderCommand::Draw(draw) => {
+ out.extend_from_slice(
+ format!(
+ "D,{:?},{},{},{},{}\n",
+ draw.phase, draw.id.0, draw.mesh.0, draw.material.0, draw.stable_order
+ )
+ .as_bytes(),
+ );
+ }
+ }
+ }
+ Ok(out)
+}
+
+fn validate_commands(commands: &RenderCommandList) -> Result<(), RenderError> {
+ for command in &commands.commands {
+ if let RenderCommand::Draw(draw) = command {
+ if draw.range.count == 0 {
+ return Err(RenderError::InvalidRange);
+ }
+ }
+ }
+ Ok(())
+}
+
+fn identity_transform() -> [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,
+ ]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn snapshot_draw(
+ id: u64,
+ phase: RenderPhase,
+ material_index: u16,
+ stable_order: u64,
+ ) -> RenderSnapshotDraw {
+ RenderSnapshotDraw {
+ id: DrawId(id),
+ phase,
+ object_id: Some(OriginalObjectId(u32::try_from(id).expect("id fits"))),
+ mesh: GpuMeshId(10 + id),
+ material_slots: vec![GpuMaterialId(31), GpuMaterialId(37)],
+ material_index,
+ transform: identity_transform(),
+ range: IndexRange { start: 0, count: 3 },
+ stable_order,
+ }
+ }
+
+ #[test]
+ fn capture_is_stable() {
+ let list = RenderCommandList {
+ commands: vec![
+ RenderCommand::BeginFrame,
+ RenderCommand::Draw(DrawCommand {
+ id: DrawId(1),
+ phase: RenderPhase::Opaque,
+ object_id: None,
+ mesh: GpuMeshId(2),
+ material: GpuMaterialId(3),
+ transform: [0.0; 16],
+ range: IndexRange { start: 0, count: 3 },
+ stable_order: 4,
+ }),
+ RenderCommand::EndFrame,
+ ],
+ };
+ assert_eq!(
+ canonical_capture(&list).expect("capture"),
+ b"B\nD,Opaque,1,2,3,4\nE\n"
+ );
+ }
+
+ #[test]
+ fn null_backend_validates_without_capture() {
+ let mut backend = NullBackend;
+ let invalid = RenderCommandList {
+ commands: vec![RenderCommand::Draw(DrawCommand {
+ id: DrawId(1),
+ phase: RenderPhase::Opaque,
+ object_id: None,
+ mesh: GpuMeshId(2),
+ material: GpuMaterialId(3),
+ transform: [0.0; 16],
+ range: IndexRange { start: 0, count: 0 },
+ stable_order: 4,
+ })],
+ };
+
+ assert!(matches!(
+ backend.execute(&invalid),
+ Err(RenderError::InvalidRange)
+ ));
+ }
+
+ #[test]
+ fn recording_backend_stores_captures() {
+ let mut backend = RecordingBackend::default();
+ let list = RenderCommandList {
+ commands: vec![RenderCommand::BeginFrame, RenderCommand::EndFrame],
+ };
+
+ backend.execute(&list).expect("execute");
+ backend.execute(&list).expect("execute");
+
+ assert_eq!(backend.captures().len(), 2);
+ assert_eq!(backend.last_capture(), Some(&b"B\nE\n"[..]));
+ backend.clear();
+ assert!(backend.captures().is_empty());
+ }
+
+ #[test]
+ fn one_snapshot_draw_produces_one_draw_command() -> Result<(), RenderError> {
+ let snapshot = RenderSnapshot {
+ camera: CameraSnapshot::default(),
+ draws: vec![snapshot_draw(1, RenderPhase::Opaque, 0, 10)],
+ };
+
+ let commands = build_commands(&snapshot, RenderProfile::default())?;
+
+ assert!(matches!(commands.commands[0], RenderCommand::BeginFrame));
+ assert!(matches!(commands.commands[2], RenderCommand::EndFrame));
+ let RenderCommand::Draw(draw) = &commands.commands[1] else {
+ panic!("expected draw");
+ };
+ assert_eq!(draw.id, DrawId(1));
+ assert_eq!(draw.mesh, GpuMeshId(11));
+ assert_eq!(draw.range, IndexRange { start: 0, count: 3 });
+ Ok(())
+ }
+
+ #[test]
+ fn material_index_maps_through_resolved_material_slots() -> Result<(), RenderError> {
+ let snapshot = RenderSnapshot {
+ camera: CameraSnapshot::default(),
+ draws: vec![snapshot_draw(2, RenderPhase::Opaque, 1, 10)],
+ };
+
+ let commands = build_commands(&snapshot, RenderProfile::default())?;
+
+ let RenderCommand::Draw(draw) = &commands.commands[1] else {
+ panic!("expected draw");
+ };
+ assert_eq!(draw.material, GpuMaterialId(37));
+ Ok(())
+ }
+
+ #[test]
+ fn node_transform_is_retained() -> Result<(), RenderError> {
+ let mut draw = snapshot_draw(3, RenderPhase::Opaque, 0, 10);
+ draw.transform[3] = 12.5;
+ draw.transform[7] = -4.0;
+ let snapshot = RenderSnapshot {
+ camera: CameraSnapshot::default(),
+ draws: vec![draw],
+ };
+
+ let commands = build_commands(&snapshot, RenderProfile::default())?;
+
+ let RenderCommand::Draw(draw) = &commands.commands[1] else {
+ panic!("expected draw");
+ };
+ assert_eq!(draw.transform[3], 12.5);
+ assert_eq!(draw.transform[7], -4.0);
+ Ok(())
+ }
+
+ #[test]
+ fn command_order_uses_phase_then_stable_key() -> Result<(), RenderError> {
+ let snapshot = RenderSnapshot {
+ camera: CameraSnapshot::default(),
+ draws: vec![
+ snapshot_draw(3, RenderPhase::Transparent, 0, 0),
+ snapshot_draw(2, RenderPhase::Opaque, 0, 20),
+ snapshot_draw(1, RenderPhase::Opaque, 0, 10),
+ ],
+ };
+
+ let commands = build_commands(&snapshot, RenderProfile::default())?;
+ let capture = canonical_capture(&commands)?;
+
+ assert_eq!(
+ capture,
+ b"B\nD,Opaque,1,11,31,10\nD,Opaque,2,12,31,20\nD,Transparent,3,13,31,0\nE\n"
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn command_capture_independent_of_snapshot_construction_order() -> Result<(), RenderError> {
+ let forward = RenderSnapshot {
+ camera: CameraSnapshot::default(),
+ draws: vec![
+ snapshot_draw(1, RenderPhase::Opaque, 0, 10),
+ snapshot_draw(2, RenderPhase::Opaque, 1, 20),
+ ],
+ };
+ let reverse = RenderSnapshot {
+ camera: CameraSnapshot::default(),
+ draws: vec![
+ snapshot_draw(2, RenderPhase::Opaque, 1, 20),
+ snapshot_draw(1, RenderPhase::Opaque, 0, 10),
+ ],
+ };
+
+ assert_eq!(
+ canonical_capture(&build_commands(&forward, RenderProfile::default())?)?,
+ canonical_capture(&build_commands(&reverse, RenderProfile::default())?)?
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn invalid_range_returns_contextual_error() {
+ let mut draw = snapshot_draw(9, RenderPhase::Opaque, 0, 10);
+ draw.range = IndexRange { start: 4, count: 0 };
+ let snapshot = RenderSnapshot {
+ camera: CameraSnapshot::default(),
+ draws: vec![draw],
+ };
+
+ assert!(matches!(
+ build_commands(&snapshot, RenderProfile::default()),
+ Err(RenderError::InvalidDrawRange {
+ draw_id: DrawId(9),
+ stable_order: 10,
+ start: 4,
+ count: 0
+ })
+ ));
+ }
+
+ #[test]
+ fn ui_phase_is_excluded_until_requested() -> Result<(), RenderError> {
+ let snapshot = RenderSnapshot {
+ camera: CameraSnapshot::default(),
+ draws: vec![
+ snapshot_draw(1, RenderPhase::Opaque, 0, 10),
+ snapshot_draw(2, RenderPhase::Ui, 0, 20),
+ ],
+ };
+
+ let default_commands = build_commands(&snapshot, RenderProfile::default())?;
+ let ui_commands = build_commands(&snapshot, RenderProfile { include_ui: true })?;
+
+ assert_eq!(default_commands.commands.len(), 3);
+ assert_eq!(ui_commands.commands.len(), 4);
+ Ok(())
+ }
+}
diff --git a/crates/fparkan-resource/Cargo.toml b/crates/fparkan-resource/Cargo.toml
new file mode 100644
index 0000000..44e13c5
--- /dev/null
+++ b/crates/fparkan-resource/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "fparkan-resource"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+fparkan-nres = { path = "../fparkan-nres" }
+fparkan-path = { path = "../fparkan-path" }
+fparkan-rsli = { path = "../fparkan-rsli" }
+fparkan-vfs = { path = "../fparkan-vfs" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-resource/src/lib.rs b/crates/fparkan-resource/src/lib.rs
new file mode 100644
index 0000000..aa6de70
--- /dev/null
+++ b/crates/fparkan-resource/src/lib.rs
@@ -0,0 +1,880 @@
+#![forbid(unsafe_code)]
+//! Resource identity and repository ports.
+
+use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName};
+use fparkan_vfs::{Vfs, VfsError};
+use std::collections::BTreeMap;
+use std::ops::Range;
+use std::sync::{Arc, Mutex};
+
+/// Resource key.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ResourceKey {
+ /// Archive path.
+ pub archive: NormalizedPath,
+ /// Entry name.
+ pub name: ResourceName,
+ /// Optional type id.
+ pub type_id: Option<u32>,
+}
+
+/// Resource entry metadata.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ResourceEntryInfo {
+ /// Stable resource key.
+ pub key: ResourceKey,
+ /// Archive entry attribute 1.
+ pub attr1: u32,
+ /// Archive entry attribute 2.
+ pub attr2: u32,
+ /// Archive entry attribute 3.
+ pub attr3: u32,
+}
+
+/// Archive identity.
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct ArchiveId(pub u64);
+
+/// Entry handle.
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct EntryHandle {
+ /// Archive.
+ pub archive: ArchiveId,
+ /// Local entry index.
+ pub local: u32,
+}
+
+/// Archive kind.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ArchiveKind {
+ /// `NRes` archive.
+ Nres,
+ /// `RsLi` archive.
+ Rsli,
+}
+
+/// Resource bytes.
+#[derive(Clone, Debug)]
+pub enum ResourceBytes {
+ /// Shared byte owner.
+ Shared(Arc<[u8]>),
+ /// Slice in owner.
+ Slice {
+ /// Shared owner bytes.
+ owner: Arc<[u8]>,
+ /// Slice range.
+ range: Range<usize>,
+ },
+}
+
+impl ResourceBytes {
+ /// Returns a byte slice.
+ #[must_use]
+ pub fn as_slice(&self) -> &[u8] {
+ match self {
+ Self::Shared(bytes) => bytes,
+ Self::Slice { owner, range } => &owner[range.clone()],
+ }
+ }
+
+ /// Returns byte length.
+ #[must_use]
+ pub fn len(&self) -> usize {
+ self.as_slice().len()
+ }
+
+ /// Returns whether the resource is empty.
+ #[must_use]
+ pub fn is_empty(&self) -> bool {
+ self.len() == 0
+ }
+
+ /// Returns owned bytes.
+ #[must_use]
+ pub fn into_owned(self) -> Vec<u8> {
+ match self {
+ Self::Shared(bytes) => bytes.to_vec(),
+ Self::Slice { owner, range } => owner[range].to_vec(),
+ }
+ }
+}
+
+/// Resource error.
+#[derive(Debug)]
+pub enum ResourceError {
+ /// Missing archive.
+ MissingArchive,
+ /// Missing entry.
+ MissingEntry,
+ /// Stale or invalid handle.
+ InvalidHandle,
+ /// Format error.
+ Format(String),
+ /// Entry-specific read error.
+ EntryRead {
+ /// Resource key.
+ key: ResourceKey,
+ /// Source error text.
+ source: String,
+ },
+ /// Repository state lock was poisoned.
+ Poisoned,
+}
+
+impl std::fmt::Display for ResourceError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{self:?}")
+ }
+}
+
+impl std::error::Error for ResourceError {}
+
+/// Repository port.
+pub trait ResourceRepository {
+ /// Opens archive.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ResourceError`] when the archive is missing, unsupported, or
+ /// malformed.
+ fn open_archive(&self, path: &NormalizedPath) -> Result<ArchiveId, ResourceError>;
+ /// Finds entry.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ResourceError`] when `archive` is not a valid opened archive.
+ fn find(
+ &self,
+ archive: ArchiveId,
+ name: &ResourceName,
+ ) -> Result<Option<EntryHandle>, ResourceError>;
+ /// Reads bytes.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ResourceError`] when `entry` is stale, invalid, or cannot be
+ /// decoded.
+ fn read(&self, entry: EntryHandle) -> Result<ResourceBytes, ResourceError>;
+ /// Reads entry metadata.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ResourceError`] when `entry` is stale or invalid.
+ fn entry_info(&self, entry: EntryHandle) -> Result<ResourceEntryInfo, ResourceError>;
+}
+
+/// Cached archive repository over a [`Vfs`].
+pub struct CachedResourceRepository {
+ vfs: Arc<dyn Vfs>,
+ state: Mutex<RepositoryState>,
+}
+
+#[derive(Default)]
+struct RepositoryState {
+ paths: BTreeMap<String, ArchiveId>,
+ archives: Vec<ArchiveSlot>,
+ payload_cache: DecodedPayloadCache,
+}
+
+struct ArchiveSlot {
+ path: NormalizedPath,
+ fingerprint: u64,
+ kind: ArchiveKind,
+ document: ArchiveDocument,
+}
+
+enum ArchiveDocument {
+ Nres(fparkan_nres::NresDocument),
+ Rsli(fparkan_rsli::RsliDocument),
+}
+
+#[derive(Debug, Default)]
+struct DecodedPayloadCache {
+ max_entries: usize,
+ generation: u64,
+ entries: BTreeMap<EntryHandle, PayloadCacheEntry>,
+}
+
+#[derive(Clone, Debug)]
+struct PayloadCacheEntry {
+ bytes: Arc<[u8]>,
+ last_access: u64,
+}
+
+impl CachedResourceRepository {
+ /// Creates a cached repository.
+ #[must_use]
+ pub fn new(vfs: Arc<dyn Vfs>) -> Self {
+ Self::with_payload_cache_budget(vfs, 64)
+ }
+
+ /// Creates a cached repository with a decoded payload entry budget.
+ #[must_use]
+ pub fn with_payload_cache_budget(vfs: Arc<dyn Vfs>, max_payload_entries: usize) -> Self {
+ Self {
+ vfs,
+ state: Mutex::new(RepositoryState {
+ payload_cache: DecodedPayloadCache::new(max_payload_entries),
+ ..RepositoryState::default()
+ }),
+ }
+ }
+
+ /// Returns the archive kind for an opened archive.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ResourceError::InvalidHandle`] when `archive` is not present.
+ pub fn archive_kind(&self, archive: ArchiveId) -> Result<ArchiveKind, ResourceError> {
+ let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
+ Ok(state.archive(archive)?.kind)
+ }
+
+ /// Returns the archive path for an opened archive.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ResourceError::InvalidHandle`] when `archive` is not present.
+ pub fn archive_path(&self, archive: ArchiveId) -> Result<NormalizedPath, ResourceError> {
+ let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
+ Ok(state.archive(archive)?.path.clone())
+ }
+}
+
+impl ResourceRepository for CachedResourceRepository {
+ fn open_archive(&self, path: &NormalizedPath) -> Result<ArchiveId, ResourceError> {
+ let metadata = self.vfs.metadata(path).map_err(resource_error_from_vfs)?;
+ let fingerprint = metadata.fingerprint;
+ if let Some(id) = self.cached_id(path, fingerprint)? {
+ return Ok(id);
+ }
+
+ let bytes = self.vfs.read(path).map_err(resource_error_from_vfs)?;
+ let slot = decode_archive(path.clone(), bytes, fingerprint)?;
+ let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
+ if let Some(id) = state.paths.get(path.as_str()).copied() {
+ if state.archive(id)?.fingerprint == fingerprint {
+ return Ok(id);
+ }
+ *state.archive_mut(id)? = slot;
+ state.payload_cache.remove_archive(id);
+ return Ok(id);
+ }
+ let id = ArchiveId(u64::try_from(state.archives.len()).map_err(|_| {
+ ResourceError::Format("too many open archives for handle space".to_string())
+ })?);
+ state.paths.insert(path.as_str().to_string(), id);
+ state.archives.push(slot);
+ Ok(id)
+ }
+
+ fn find(
+ &self,
+ archive: ArchiveId,
+ name: &ResourceName,
+ ) -> Result<Option<EntryHandle>, ResourceError> {
+ let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
+ let slot = state.archive(archive)?;
+ let local = match &slot.document {
+ ArchiveDocument::Nres(document) => document.find_bytes(&name.0).map(|id| id.0),
+ ArchiveDocument::Rsli(document) => document.find_bytes(&name.0).map(|id| id.0),
+ };
+ Ok(local.map(|local| EntryHandle { archive, local }))
+ }
+
+ fn read(&self, entry: EntryHandle) -> Result<ResourceBytes, ResourceError> {
+ let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
+ if let Some(bytes) = state.payload_cache.get(entry) {
+ return Ok(ResourceBytes::Shared(bytes));
+ }
+
+ let payload = {
+ let slot = state.archive(entry.archive)?;
+ let key = slot.entry_key(entry.local)?;
+ slot.read_payload(entry.local)
+ .map_err(|source| ResourceError::EntryRead {
+ key: key.clone(),
+ source,
+ })?
+ };
+ let shared = Arc::from(payload.into_boxed_slice());
+ state.payload_cache.insert(entry, Arc::clone(&shared));
+ Ok(ResourceBytes::Shared(shared))
+ }
+
+ fn entry_info(&self, entry: EntryHandle) -> Result<ResourceEntryInfo, ResourceError> {
+ let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
+ let slot = state.archive(entry.archive)?;
+ match &slot.document {
+ ArchiveDocument::Nres(document) => {
+ let local =
+ usize::try_from(entry.local).map_err(|_| ResourceError::InvalidHandle)?;
+ let entry = document
+ .entries()
+ .get(local)
+ .ok_or(ResourceError::InvalidHandle)?;
+ let meta = entry.meta();
+ Ok(ResourceEntryInfo {
+ key: ResourceKey {
+ archive: slot.path.clone(),
+ name: ResourceName(entry.name_bytes().to_vec()),
+ type_id: Some(meta.type_id),
+ },
+ attr1: meta.attr1,
+ attr2: meta.attr2,
+ attr3: meta.attr3,
+ })
+ }
+ ArchiveDocument::Rsli(document) => {
+ let meta = document
+ .entry(fparkan_rsli::EntryId(entry.local))
+ .ok_or(ResourceError::InvalidHandle)?;
+ Ok(ResourceEntryInfo {
+ key: ResourceKey {
+ archive: slot.path.clone(),
+ name: ResourceName(meta.name_raw.to_vec()),
+ type_id: None,
+ },
+ attr1: u32::try_from(meta.flags).unwrap_or_default(),
+ attr2: 0,
+ attr3: 0,
+ })
+ }
+ }
+ }
+}
+
+impl CachedResourceRepository {
+ fn cached_id(
+ &self,
+ path: &NormalizedPath,
+ fingerprint: u64,
+ ) -> Result<Option<ArchiveId>, ResourceError> {
+ let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
+ let Some(id) = state.paths.get(path.as_str()).copied() else {
+ return Ok(None);
+ };
+ if state.archive(id)?.fingerprint == fingerprint {
+ Ok(Some(id))
+ } else {
+ Ok(None)
+ }
+ }
+}
+
+impl DecodedPayloadCache {
+ fn new(max_entries: usize) -> Self {
+ Self {
+ max_entries,
+ generation: 0,
+ entries: BTreeMap::new(),
+ }
+ }
+
+ fn get(&mut self, handle: EntryHandle) -> Option<Arc<[u8]>> {
+ let entry = self.entries.get_mut(&handle)?;
+ self.generation = self.generation.saturating_add(1);
+ entry.last_access = self.generation;
+ Some(Arc::clone(&entry.bytes))
+ }
+
+ fn insert(&mut self, handle: EntryHandle, bytes: Arc<[u8]>) {
+ if self.max_entries == 0 {
+ return;
+ }
+ self.generation = self.generation.saturating_add(1);
+ self.entries.insert(
+ handle,
+ PayloadCacheEntry {
+ bytes,
+ last_access: self.generation,
+ },
+ );
+ while self.entries.len() > self.max_entries {
+ let Some(victim) = self
+ .entries
+ .iter()
+ .min_by_key(|(_, entry)| entry.last_access)
+ .map(|(handle, _)| *handle)
+ else {
+ break;
+ };
+ self.entries.remove(&victim);
+ }
+ }
+
+ fn remove_archive(&mut self, archive: ArchiveId) {
+ self.entries.retain(|handle, _| handle.archive != archive);
+ }
+}
+
+impl RepositoryState {
+ fn archive(&self, id: ArchiveId) -> Result<&ArchiveSlot, ResourceError> {
+ let index = usize::try_from(id.0).map_err(|_| ResourceError::InvalidHandle)?;
+ self.archives.get(index).ok_or(ResourceError::InvalidHandle)
+ }
+
+ fn archive_mut(&mut self, id: ArchiveId) -> Result<&mut ArchiveSlot, ResourceError> {
+ let index = usize::try_from(id.0).map_err(|_| ResourceError::InvalidHandle)?;
+ self.archives
+ .get_mut(index)
+ .ok_or(ResourceError::InvalidHandle)
+ }
+}
+
+impl ArchiveSlot {
+ fn entry_key(&self, local: u32) -> Result<ResourceKey, ResourceError> {
+ match &self.document {
+ ArchiveDocument::Nres(document) => {
+ let local = usize::try_from(local).map_err(|_| ResourceError::InvalidHandle)?;
+ let entry = document
+ .entries()
+ .get(local)
+ .ok_or(ResourceError::InvalidHandle)?;
+ Ok(ResourceKey {
+ archive: self.path.clone(),
+ name: ResourceName(entry.name_bytes().to_vec()),
+ type_id: Some(entry.meta().type_id),
+ })
+ }
+ ArchiveDocument::Rsli(document) => {
+ let meta = document
+ .entry(fparkan_rsli::EntryId(local))
+ .ok_or(ResourceError::InvalidHandle)?;
+ Ok(ResourceKey {
+ archive: self.path.clone(),
+ name: ResourceName(c_name_bytes(&meta.name_raw).to_vec()),
+ type_id: None,
+ })
+ }
+ }
+ }
+
+ fn read_payload(&self, local: u32) -> Result<Vec<u8>, String> {
+ match &self.document {
+ ArchiveDocument::Nres(document) => document
+ .payload(fparkan_nres::EntryId(local))
+ .map(<[u8]>::to_vec)
+ .map_err(|err| err.to_string()),
+ ArchiveDocument::Rsli(document) => document
+ .load(fparkan_rsli::EntryId(local))
+ .map_err(|err| err.to_string()),
+ }
+ }
+}
+
+fn decode_archive(
+ path: NormalizedPath,
+ bytes: Arc<[u8]>,
+ fingerprint: u64,
+) -> Result<ArchiveSlot, ResourceError> {
+ if bytes.starts_with(b"NRes") {
+ let document = fparkan_nres::decode(bytes, fparkan_nres::ReadProfile::Compatible)
+ .map_err(|err| ResourceError::Format(err.to_string()))?;
+ return Ok(ArchiveSlot {
+ path,
+ fingerprint,
+ kind: ArchiveKind::Nres,
+ document: ArchiveDocument::Nres(document),
+ });
+ }
+ if bytes.get(0..4) == Some(b"NL\0\x01") {
+ let document = fparkan_rsli::decode(bytes, fparkan_rsli::ReadProfile::Compatible)
+ .map_err(|err| ResourceError::Format(err.to_string()))?;
+ return Ok(ArchiveSlot {
+ path,
+ fingerprint,
+ kind: ArchiveKind::Rsli,
+ document: ArchiveDocument::Rsli(document),
+ });
+ }
+ Err(ResourceError::Format(
+ "unsupported archive magic for resource repository".to_string(),
+ ))
+}
+
+fn resource_error_from_vfs(err: VfsError) -> ResourceError {
+ match err {
+ VfsError::NotFound(_) => ResourceError::MissingArchive,
+ VfsError::Ambiguous(path) => ResourceError::Format(format!("ambiguous VFS path: {path}")),
+ VfsError::Io(source) => ResourceError::Format(source.to_string()),
+ VfsError::Path => ResourceError::Format("invalid VFS path".to_string()),
+ }
+}
+
+/// Builds a resource name from raw bytes.
+#[must_use]
+pub fn resource_name(raw: impl AsRef<[u8]>) -> ResourceName {
+ ResourceName(raw.as_ref().to_vec())
+}
+
+/// Normalizes an archive path for resource lookup.
+///
+/// # Errors
+///
+/// Returns [`ResourceError::Format`] when the path is not a valid relative
+/// resource path.
+pub fn archive_path(raw: impl AsRef<[u8]>) -> Result<NormalizedPath, ResourceError> {
+ normalize_relative(raw.as_ref(), PathPolicy::StrictLegacy)
+ .map_err(|err| ResourceError::Format(err.to_string()))
+}
+
+fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
+ let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len());
+ &raw[..len]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_vfs::{DirectoryVfs, MemoryVfs};
+ use std::path::Path;
+
+ #[test]
+ fn cached_repository_reads_synthetic_nres() {
+ let path = archive_path(b"archives/test.lib").expect("path");
+ let bytes = build_nres(&[("Alpha.TXT", b"alpha".as_slice()), ("beta.bin", b"beta")]);
+ let mut vfs = MemoryVfs::default();
+ vfs.insert(path.clone(), Arc::from(bytes.into_boxed_slice()));
+ let repo = CachedResourceRepository::new(Arc::new(vfs));
+
+ let first = repo.open_archive(&path).expect("open archive");
+ let second = repo.open_archive(&path).expect("open archive again");
+ assert_eq!(first, second);
+ assert_eq!(repo.archive_kind(first).expect("kind"), ArchiveKind::Nres);
+
+ let handle = repo
+ .find(first, &resource_name(b"alpha.txt"))
+ .expect("find")
+ .expect("entry");
+ assert_eq!(repo.read(handle).expect("read").as_slice(), b"alpha");
+ let info = repo.entry_info(handle).expect("entry info");
+ assert_eq!(info.key.archive, path);
+ assert!(info.key.name.0.eq_ignore_ascii_case(b"Alpha.TXT"));
+ assert!(matches!(
+ repo.read(EntryHandle {
+ archive: ArchiveId(99),
+ local: 0
+ }),
+ Err(ResourceError::InvalidHandle)
+ ));
+ }
+
+ #[test]
+ fn entry_handles_are_archive_qualified() {
+ let first_path = archive_path(b"first.lib").expect("first path");
+ let second_path = archive_path(b"second.lib").expect("second path");
+ let mut vfs = MemoryVfs::default();
+ vfs.insert(
+ first_path.clone(),
+ Arc::from(build_nres(&[("same.bin", b"first".as_slice())]).into_boxed_slice()),
+ );
+ vfs.insert(
+ second_path.clone(),
+ Arc::from(build_nres(&[("same.bin", b"second".as_slice())]).into_boxed_slice()),
+ );
+ let repo = CachedResourceRepository::new(Arc::new(vfs));
+
+ let first_archive = repo.open_archive(&first_path).expect("first archive");
+ let second_archive = repo.open_archive(&second_path).expect("second archive");
+ let first_handle = repo
+ .find(first_archive, &resource_name(b"same.bin"))
+ .expect("first find")
+ .expect("first handle");
+ let second_handle = repo
+ .find(second_archive, &resource_name(b"same.bin"))
+ .expect("second find")
+ .expect("second handle");
+
+ assert_ne!(first_handle, second_handle);
+ assert_eq!(first_handle.archive, first_archive);
+ assert_eq!(second_handle.archive, second_archive);
+ assert_eq!(
+ repo.read(first_handle).expect("first read").as_slice(),
+ b"first"
+ );
+ assert_eq!(
+ repo.read(second_handle).expect("second read").as_slice(),
+ b"second"
+ );
+ }
+
+ #[test]
+ fn archive_cache_and_decoded_payload_cache_evict_independently() {
+ let path = archive_path(b"cache/test.lib").expect("path");
+ let bytes = build_nres(&[("a.bin", b"a".as_slice()), ("b.bin", b"b".as_slice())]);
+ let mut vfs = MemoryVfs::default();
+ vfs.insert(path.clone(), Arc::from(bytes.into_boxed_slice()));
+ let repo = CachedResourceRepository::with_payload_cache_budget(Arc::new(vfs), 1);
+
+ let archive = repo.open_archive(&path).expect("open archive");
+ let first = repo
+ .find(archive, &resource_name(b"a.bin"))
+ .expect("find a")
+ .expect("a");
+ let second = repo
+ .find(archive, &resource_name(b"b.bin"))
+ .expect("find b")
+ .expect("b");
+ assert_eq!(repo.read(first).expect("read a").as_slice(), b"a");
+ assert_eq!(repo.read(second).expect("read b").as_slice(), b"b");
+
+ let state = repo.state.lock().expect("state");
+ assert_eq!(state.archives.len(), 1);
+ assert_eq!(state.payload_cache.entries.len(), 1);
+ assert_eq!(state.paths.get(path.as_str()).copied(), Some(archive));
+ drop(state);
+
+ assert_eq!(repo.open_archive(&path).expect("cached archive"), archive);
+ assert_eq!(
+ repo.read(first).expect("reread evicted payload").as_slice(),
+ b"a"
+ );
+ }
+
+ #[test]
+ fn archive_cache_invalidates_when_vfs_bytes_change() {
+ let root = temp_dir("archive-invalidate");
+ let path = archive_path(b"cache/test.lib").expect("path");
+ let host_path = root.join(path.as_str());
+ std::fs::create_dir_all(host_path.parent().expect("parent")).expect("cache dir");
+ std::fs::write(&host_path, build_nres(&[("a.bin", b"before".as_slice())]))
+ .expect("initial archive");
+ let repo = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(&root)));
+
+ let archive = repo.open_archive(&path).expect("open initial archive");
+ let first = repo
+ .find(archive, &resource_name(b"a.bin"))
+ .expect("find initial")
+ .expect("initial handle");
+ assert_eq!(
+ repo.read(first).expect("read initial").as_slice(),
+ b"before"
+ );
+
+ std::fs::write(&host_path, build_nres(&[("a.bin", b"after".as_slice())]))
+ .expect("updated archive");
+ let reopened = repo.open_archive(&path).expect("open updated archive");
+ let second = repo
+ .find(reopened, &resource_name(b"a.bin"))
+ .expect("find updated")
+ .expect("updated handle");
+
+ assert_eq!(reopened, archive);
+ assert_eq!(
+ repo.read(second).expect("read updated").as_slice(),
+ b"after"
+ );
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn entry_read_error_carries_archive_path_and_entry_name() {
+ let path = archive_path(b"bad/rsli.lib").expect("path");
+ let mut vfs = MemoryVfs::default();
+ vfs.insert(
+ path.clone(),
+ Arc::from(build_rsli_unknown_method(b"BROKEN.TEX", b"x").into_boxed_slice()),
+ );
+ let repo = CachedResourceRepository::new(Arc::new(vfs));
+ let archive = repo.open_archive(&path).expect("open bad archive");
+ let handle = repo
+ .find(archive, &resource_name(b"BROKEN.TEX"))
+ .expect("find bad entry")
+ .expect("bad handle");
+
+ let err = repo.read(handle).expect_err("read should fail");
+
+ match err {
+ ResourceError::EntryRead { key, source } => {
+ assert_eq!(key.archive, path);
+ assert_eq!(key.name.0, b"BROKEN.TEX");
+ assert!(source.contains("unsupported packing method"));
+ }
+ other => panic!("unexpected error: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn licensed_corpora_repository_reads_nres_and_rsli() {
+ licensed_repository_gate("IS").expect("part 1 repository gate");
+ licensed_repository_gate("IS2").expect("part 2 repository gate");
+ }
+
+ fn licensed_repository_gate(corpus: &str) -> Result<(), String> {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("testdata")
+ .join(corpus);
+ if !root.is_dir() {
+ return Err(format!(
+ "licensed corpus root is missing: {}",
+ root.display()
+ ));
+ }
+ let repo = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(&root)));
+
+ let material_path = archive_path(b"Material.lib").map_err(|err| err.to_string())?;
+ let material_bytes =
+ std::fs::read(root.join(material_path.as_str())).map_err(|err| err.to_string())?;
+ let material_doc = fparkan_nres::decode(
+ Arc::from(material_bytes.clone().into_boxed_slice()),
+ fparkan_nres::ReadProfile::Compatible,
+ )
+ .map_err(|err| err.to_string())?;
+ let material_entry = material_doc
+ .entries()
+ .first()
+ .ok_or_else(|| "Material.lib has no entries".to_string())?;
+
+ let material_archive = repo
+ .open_archive(&material_path)
+ .map_err(|err| err.to_string())?;
+ let material_handle = repo
+ .find(
+ material_archive,
+ &resource_name(material_entry.name_bytes()),
+ )
+ .map_err(|err| err.to_string())?
+ .ok_or_else(|| "Material.lib first entry not found".to_string())?;
+ let material_payload = repo
+ .read(material_handle)
+ .map_err(|err| err.to_string())?
+ .into_owned();
+ let expected_material = material_doc
+ .payload(material_entry.id())
+ .map_err(|err| err.to_string())?;
+ if material_payload != expected_material {
+ return Err("Material.lib payload mismatch".to_string());
+ }
+
+ let font_path = archive_path(b"gamefont.rlb").map_err(|err| err.to_string())?;
+ let font_bytes =
+ std::fs::read(root.join(font_path.as_str())).map_err(|err| err.to_string())?;
+ let font_doc = fparkan_rsli::decode(
+ Arc::from(font_bytes.into_boxed_slice()),
+ fparkan_rsli::ReadProfile::Compatible,
+ )
+ .map_err(|err| err.to_string())?;
+ let font_entry = font_doc
+ .entries()
+ .first()
+ .ok_or_else(|| "gamefont.rlb has no entries".to_string())?;
+ let font_archive = repo
+ .open_archive(&font_path)
+ .map_err(|err| err.to_string())?;
+ let font_handle = repo
+ .find(font_archive, &resource_name(font_entry.name_raw))
+ .map_err(|err| err.to_string())?
+ .ok_or_else(|| "gamefont.rlb first entry not found".to_string())?;
+ let font_payload = repo
+ .read(font_handle)
+ .map_err(|err| err.to_string())?
+ .into_owned();
+ let expected_font = font_doc
+ .load(fparkan_rsli::EntryId(0))
+ .map_err(|err| err.to_string())?;
+ if font_payload != expected_font {
+ return Err("gamefont.rlb payload mismatch".to_string());
+ }
+ Ok(())
+ }
+
+ fn build_nres(entries: &[(&str, &[u8])]) -> Vec<u8> {
+ let mut out = vec![0; 16];
+ let mut offsets = Vec::with_capacity(entries.len());
+ for (_, payload) in entries {
+ offsets.push(u32::try_from(out.len()).expect("offset"));
+ out.extend_from_slice(payload);
+ let padding = (8 - (out.len() % 8)) % 8;
+ out.resize(out.len() + padding, 0);
+ }
+ let mut order: Vec<usize> = (0..entries.len()).collect();
+ order.sort_by(|left, right| {
+ entries[*left]
+ .0
+ .as_bytes()
+ .cmp(entries[*right].0.as_bytes())
+ });
+ for (idx, (name, payload)) in entries.iter().enumerate() {
+ push_u32(&mut out, 0);
+ push_u32(&mut out, 0);
+ push_u32(&mut out, 0);
+ push_u32(
+ &mut out,
+ u32::try_from(payload.len()).expect("payload size"),
+ );
+ push_u32(&mut out, 0);
+ let mut name_raw = [0; 36];
+ name_raw[..name.len()].copy_from_slice(name.as_bytes());
+ out.extend_from_slice(&name_raw);
+ push_u32(&mut out, offsets[idx]);
+ push_u32(&mut out, u32::try_from(order[idx]).expect("sort index"));
+ }
+ out[0..4].copy_from_slice(b"NRes");
+ out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
+ out[8..12].copy_from_slice(&u32::try_from(entries.len()).expect("count").to_le_bytes());
+ let total_size = u32::try_from(out.len()).expect("total size");
+ out[12..16].copy_from_slice(&total_size.to_le_bytes());
+ out
+ }
+
+ fn push_u32(out: &mut Vec<u8>, value: u32) {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn temp_dir(name: &str) -> std::path::PathBuf {
+ let path = std::env::temp_dir().join(format!(
+ "fparkan-resource-{name}-{}",
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("clock")
+ .as_nanos()
+ ));
+ std::fs::create_dir_all(&path).expect("temp dir");
+ path
+ }
+
+ fn build_rsli_unknown_method(name: &[u8], payload: &[u8]) -> Vec<u8> {
+ let mut header = [0u8; 32];
+ header[0..4].copy_from_slice(b"NL\0\x01");
+ header[4..6].copy_from_slice(&1i16.to_le_bytes());
+ header[14..16].copy_from_slice(&0xABBAu16.to_le_bytes());
+ header[20..24].copy_from_slice(&0x1234u32.to_le_bytes());
+
+ let mut row = [0u8; 32];
+ let name_len = name.len().min(12);
+ row[0..name_len].copy_from_slice(&name[..name_len]);
+ row[16..18].copy_from_slice(&0x1E0i16.to_le_bytes());
+ row[20..24].copy_from_slice(
+ &u32::try_from(payload.len())
+ .expect("rsli unpacked size")
+ .to_le_bytes(),
+ );
+ row[24..28].copy_from_slice(&64u32.to_le_bytes());
+ row[28..32].copy_from_slice(
+ &u32::try_from(payload.len())
+ .expect("rsli packed size")
+ .to_le_bytes(),
+ );
+
+ let mut out = Vec::new();
+ out.extend_from_slice(&header);
+ out.extend_from_slice(&test_xor_stream(&row, 0x1234));
+ out.extend_from_slice(payload);
+ out
+ }
+
+ fn test_xor_stream(data: &[u8], key16: u16) -> Vec<u8> {
+ let mut lo = u8::try_from(key16 & 0xFF).expect("lo");
+ let mut hi = u8::try_from((key16 >> 8) & 0xFF).expect("hi");
+ data.iter()
+ .map(|byte| {
+ lo = hi ^ lo.wrapping_shl(1);
+ let transformed = byte ^ lo;
+ hi = lo ^ (hi >> 1);
+ transformed
+ })
+ .collect()
+ }
+}
diff --git a/crates/fparkan-rsli/Cargo.toml b/crates/fparkan-rsli/Cargo.toml
new file mode 100644
index 0000000..481788d
--- /dev/null
+++ b/crates/fparkan-rsli/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "fparkan-rsli"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-rsli/src/lib.rs b/crates/fparkan-rsli/src/lib.rs
new file mode 100644
index 0000000..59b4c67
--- /dev/null
+++ b/crates/fparkan-rsli/src/lib.rs
@@ -0,0 +1,2113 @@
+#![forbid(unsafe_code)]
+//! Stage-1 `RsLi` archive contract.
+
+use std::fmt;
+use std::io::Read;
+use std::sync::Arc;
+
+/// Read profile.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ReadProfile {
+ /// Reject compatibility quirks.
+ Strict,
+ /// Accept registered retail compatibility quirks.
+ Compatible,
+}
+
+/// Write profile.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum WriteProfile {
+ /// Return the original byte image.
+ Lossless,
+}
+
+/// `RsLi` compatibility switches.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct RsliCompatibilityProfile {
+ /// Allow the registered `AO` trailer overlay.
+ pub allow_ao_trailer: bool,
+ /// Allow retail Deflate entries whose declared size is one byte past EOF.
+ pub allow_deflate_eof_plus_one: bool,
+ /// Rebuild lookup order when a retail presorted table is corrupt.
+ pub allow_invalid_presorted_fallback: bool,
+}
+
+impl Default for RsliCompatibilityProfile {
+ fn default() -> Self {
+ Self {
+ allow_ao_trailer: true,
+ allow_deflate_eof_plus_one: true,
+ allow_invalid_presorted_fallback: true,
+ }
+ }
+}
+
+/// `RsLi` packing method.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum RsliMethod {
+ /// Stored without packing.
+ Stored,
+ /// XOR only.
+ XorOnly,
+ /// Simple LZSS.
+ Lzss,
+ /// XOR plus simple LZSS.
+ XorLzss,
+ /// Adaptive LZSS/Huffman method `0x080`.
+ AdaptiveLzss,
+ /// XOR plus adaptive LZSS/Huffman method `0x0A0`.
+ XorAdaptiveLzss,
+ /// Raw Deflate.
+ RawDeflate,
+ /// Unsupported method bits.
+ Unknown(u32),
+}
+
+/// Entry identifier in original table order.
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct EntryId(pub u32);
+
+/// Archive header summary.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RsliHeader {
+ /// Raw 32-byte header.
+ pub raw: [u8; 32],
+ /// Format version.
+ pub version: u8,
+ /// Entry count.
+ pub entry_count: u16,
+ /// Presorted flag from the header.
+ pub presorted_flag: u16,
+ /// XOR seed used for the entry table.
+ pub xor_seed: u32,
+}
+
+/// `AO` trailer summary.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AoTrailer {
+ /// Raw six-byte trailer.
+ pub raw: [u8; 6],
+ /// Media overlay byte offset.
+ pub overlay: u32,
+}
+
+/// Entry metadata.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct EntryMeta {
+ /// Decoded byte-for-byte name adapter.
+ pub name: String,
+ /// Raw fixed-size name field.
+ pub name_raw: [u8; 12],
+ /// Original flags.
+ pub flags: i32,
+ /// Packing method.
+ pub method: RsliMethod,
+ /// Effective payload offset after overlay.
+ pub data_offset: u64,
+ /// Declared packed size.
+ pub packed_size: u32,
+ /// Declared unpacked size.
+ pub unpacked_size: u32,
+ /// Sort table value.
+ pub sort_to_original: i16,
+ /// Raw data offset stored in the table.
+ pub data_offset_raw: u32,
+}
+
+/// Parsed `RsLi` document.
+#[derive(Debug)]
+pub struct RsliDocument {
+ bytes: Arc<[u8]>,
+ header: RsliHeader,
+ ao_trailer: Option<AoTrailer>,
+ entries: Vec<EntryMeta>,
+ records: Vec<EntryRecord>,
+}
+
+/// Packed resource bytes and metadata.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PackedResource {
+ /// Entry metadata.
+ pub meta: EntryMeta,
+ /// Packed bytes as stored in the archive.
+ pub packed: Vec<u8>,
+}
+
+/// `RsLi` parse or decode error.
+#[derive(Debug)]
+pub enum RsliError {
+ /// Invalid magic.
+ InvalidMagic {
+ /// Observed magic.
+ got: [u8; 2],
+ },
+ /// Reserved header byte has an unexpected value.
+ InvalidReserved {
+ /// Observed reserved byte.
+ got: u8,
+ },
+ /// Unsupported version.
+ UnsupportedVersion {
+ /// Observed version.
+ got: u8,
+ },
+ /// Invalid entry count.
+ InvalidEntryCount {
+ /// Observed signed count.
+ got: i16,
+ },
+ /// Too many entries for stable ids.
+ TooManyEntries {
+ /// Observed count.
+ got: usize,
+ },
+ /// Entry table is outside the archive.
+ EntryTableOutOfBounds {
+ /// Table byte offset.
+ table_offset: u64,
+ /// Table byte length.
+ table_len: u64,
+ /// Archive byte length.
+ file_len: u64,
+ },
+ /// Entry table is structurally corrupt.
+ CorruptEntryTable(&'static str),
+ /// Entry id is outside this archive.
+ EntryIdOutOfRange {
+ /// Entry id.
+ id: u32,
+ /// Entry count.
+ entry_count: u32,
+ },
+ /// Entry payload is outside the archive.
+ EntryDataOutOfBounds {
+ /// Entry id.
+ id: u32,
+ /// Payload offset.
+ offset: u64,
+ /// Payload declared size.
+ size: u32,
+ /// Archive byte length.
+ file_len: u64,
+ },
+ /// `AO` media overlay points outside the archive.
+ MediaOverlayOutOfBounds {
+ /// Overlay byte offset.
+ overlay: u32,
+ /// Archive byte length.
+ file_len: u64,
+ },
+ /// Unsupported packing method.
+ UnsupportedMethod {
+ /// Raw method bits.
+ raw: u32,
+ },
+ /// Packed range ends past EOF.
+ PackedSizePastEof {
+ /// Entry id.
+ id: u32,
+ /// Payload offset.
+ offset: u64,
+ /// Declared packed size.
+ packed_size: u32,
+ /// Archive byte length.
+ file_len: u64,
+ },
+ /// Registered retail quirk is rejected by the selected profile.
+ DeflateEofPlusOneQuirkRejected {
+ /// Entry id.
+ id: u32,
+ },
+ /// Payload decompression failed.
+ DecompressionFailed(&'static str),
+ /// Decoded payload size does not match the declared size.
+ OutputSizeMismatch {
+ /// Expected decoded size.
+ expected: u32,
+ /// Observed decoded size.
+ got: u32,
+ },
+ /// Integer conversion or arithmetic overflow.
+ IntegerOverflow,
+}
+
+impl fmt::Display for RsliError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::InvalidMagic { got } => write!(f, "invalid RsLi magic: {got:02X?}"),
+ Self::InvalidReserved { got } => write!(f, "invalid RsLi reserved byte: {got:#x}"),
+ Self::UnsupportedVersion { got } => write!(f, "unsupported RsLi version: {got:#x}"),
+ Self::InvalidEntryCount { got } => write!(f, "invalid entry_count: {got}"),
+ Self::TooManyEntries { got } => write!(f, "too many entries: {got} exceeds u32::MAX"),
+ Self::EntryTableOutOfBounds {
+ table_offset,
+ table_len,
+ file_len,
+ } => write!(
+ f,
+ "entry table out of bounds: off={table_offset}, len={table_len}, file={file_len}"
+ ),
+ Self::CorruptEntryTable(message) => write!(f, "corrupt entry table: {message}"),
+ Self::EntryIdOutOfRange { id, entry_count } => {
+ write!(f, "RsLi entry id out of range: {id} >= {entry_count}")
+ }
+ Self::EntryDataOutOfBounds {
+ id,
+ offset,
+ size,
+ file_len,
+ } => write!(
+ f,
+ "entry data out of bounds: id={id}, off={offset}, size={size}, file={file_len}"
+ ),
+ Self::MediaOverlayOutOfBounds { overlay, file_len } => {
+ write!(
+ f,
+ "media overlay out of bounds: overlay={overlay}, file={file_len}"
+ )
+ }
+ Self::UnsupportedMethod { raw } => write!(f, "unsupported packing method: {raw:#x}"),
+ Self::PackedSizePastEof {
+ id,
+ offset,
+ packed_size,
+ file_len,
+ } => write!(
+ f,
+ "packed range past EOF: id={id}, off={offset}, size={packed_size}, file={file_len}"
+ ),
+ Self::DeflateEofPlusOneQuirkRejected { id } => {
+ write!(f, "deflate EOF+1 quirk rejected for entry {id}")
+ }
+ Self::DecompressionFailed(message) => write!(f, "decompression failed: {message}"),
+ Self::OutputSizeMismatch { expected, got } => {
+ write!(f, "output size mismatch: expected={expected}, got={got}")
+ }
+ Self::IntegerOverflow => write!(f, "integer overflow"),
+ }
+ }
+}
+
+impl std::error::Error for RsliError {}
+
+/// Decodes an `RsLi` document.
+///
+/// # Errors
+///
+/// Returns [`RsliError`] when the header, table, payload ranges, registered
+/// compatibility quirks, or packed payloads are invalid for the selected
+/// profile.
+pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<RsliDocument, RsliError> {
+ let options = match profile {
+ ReadProfile::Strict => ParseOptions {
+ allow_ao_trailer: false,
+ allow_deflate_eof_plus_one: false,
+ allow_invalid_presorted_fallback: false,
+ },
+ ReadProfile::Compatible => {
+ let profile = RsliCompatibilityProfile::default();
+ ParseOptions {
+ allow_ao_trailer: profile.allow_ao_trailer,
+ allow_deflate_eof_plus_one: profile.allow_deflate_eof_plus_one,
+ allow_invalid_presorted_fallback: profile.allow_invalid_presorted_fallback,
+ }
+ }
+ };
+ let ParsedRsli {
+ header,
+ ao_trailer,
+ records,
+ } = parse_rsli(&bytes, options)?;
+ let entries = records.iter().map(|record| record.meta.clone()).collect();
+ Ok(RsliDocument {
+ bytes,
+ header,
+ ao_trailer,
+ entries,
+ records,
+ })
+}
+
+impl RsliDocument {
+ /// Header summary.
+ #[must_use]
+ pub fn header(&self) -> &RsliHeader {
+ &self.header
+ }
+
+ /// Optional `AO` trailer.
+ #[must_use]
+ pub fn ao_trailer(&self) -> Option<&AoTrailer> {
+ self.ao_trailer.as_ref()
+ }
+
+ /// Entry count.
+ #[must_use]
+ pub fn entry_count(&self) -> usize {
+ self.entries.len()
+ }
+
+ /// Entries in original table order.
+ #[must_use]
+ pub fn entries(&self) -> &[EntryMeta] {
+ &self.entries
+ }
+
+ /// Finds an entry by name.
+ #[must_use]
+ pub fn find(&self, name: &str) -> Option<EntryId> {
+ self.find_bytes(name.as_bytes())
+ }
+
+ /// Finds an entry by raw ASCII-case-insensitive name bytes.
+ #[must_use]
+ pub fn find_bytes(&self, name: &[u8]) -> Option<EntryId> {
+ let len = name
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(name.len());
+ let query = name[..len]
+ .iter()
+ .map(u8::to_ascii_uppercase)
+ .collect::<Vec<_>>();
+ self.find_impl(&query)
+ }
+
+ /// Returns an entry by id.
+ #[must_use]
+ pub fn entry(&self, id: EntryId) -> Option<&EntryMeta> {
+ self.entries.get(usize::try_from(id.0).ok()?)
+ }
+
+ /// Loads and unpacks an entry.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`RsliError`] when `id` is invalid or the packed payload cannot
+ /// be decoded to the declared size.
+ pub fn load(&self, id: EntryId) -> Result<Vec<u8>, RsliError> {
+ let record = self.record_by_id(id)?;
+ let packed = self.packed_slice(id, record)?;
+ decode_payload(
+ packed,
+ record.meta.method,
+ record.key16,
+ record.meta.unpacked_size,
+ )
+ }
+
+ /// Returns packed bytes and public metadata.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`RsliError`] when `id` is invalid or the packed range is outside
+ /// the archive.
+ pub fn load_packed(&self, id: EntryId) -> Result<PackedResource, RsliError> {
+ let record = self.record_by_id(id)?;
+ let packed = self.packed_slice(id, record)?.to_vec();
+ Ok(PackedResource {
+ meta: record.meta.clone(),
+ packed,
+ })
+ }
+
+ /// Encodes the document according to the selected profile.
+ #[must_use]
+ pub fn encode(&self, profile: WriteProfile) -> Vec<u8> {
+ match profile {
+ WriteProfile::Lossless => self.bytes.to_vec(),
+ }
+ }
+}
+
+impl RsliDocument {
+ fn find_impl(&self, query_bytes: &[u8]) -> Option<EntryId> {
+ let mut low = 0usize;
+ let mut high = self.records.len();
+ while low < high {
+ let mid = low + (high - low) / 2;
+ let original = self.records.get(mid)?.meta.sort_to_original;
+ if original < 0 {
+ break;
+ }
+ let original = usize::try_from(original).ok()?;
+ let record = self.records.get(original)?;
+ match cmp_c_string(query_bytes, c_name_bytes(&record.meta.name_raw)) {
+ std::cmp::Ordering::Less => high = mid,
+ std::cmp::Ordering::Greater => low = mid + 1,
+ std::cmp::Ordering::Equal => return Some(EntryId(u32::try_from(original).ok()?)),
+ }
+ }
+
+ self.records.iter().enumerate().find_map(|(idx, record)| {
+ if cmp_c_string(query_bytes, c_name_bytes(&record.meta.name_raw))
+ == std::cmp::Ordering::Equal
+ {
+ Some(EntryId(u32::try_from(idx).ok()?))
+ } else {
+ None
+ }
+ })
+ }
+
+ fn record_by_id(&self, id: EntryId) -> Result<&EntryRecord, RsliError> {
+ let idx = usize::try_from(id.0).map_err(|_| RsliError::IntegerOverflow)?;
+ self.records
+ .get(idx)
+ .ok_or_else(|| RsliError::EntryIdOutOfRange {
+ id: id.0,
+ entry_count: saturating_u32_len(self.records.len()),
+ })
+ }
+
+ fn packed_slice<'a>(
+ &'a self,
+ id: EntryId,
+ record: &EntryRecord,
+ ) -> Result<&'a [u8], RsliError> {
+ let end = record
+ .effective_offset
+ .checked_add(record.packed_size_available)
+ .ok_or(RsliError::IntegerOverflow)?;
+ self.bytes
+ .get(record.effective_offset..end)
+ .ok_or(RsliError::EntryDataOutOfBounds {
+ id: id.0,
+ offset: u64::try_from(record.effective_offset).unwrap_or(u64::MAX),
+ size: record.packed_size_declared,
+ file_len: u64::try_from(self.bytes.len()).unwrap_or(u64::MAX),
+ })
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct ParseOptions {
+ allow_ao_trailer: bool,
+ allow_deflate_eof_plus_one: bool,
+ allow_invalid_presorted_fallback: bool,
+}
+
+#[derive(Clone, Debug)]
+struct ParsedRsli {
+ header: RsliHeader,
+ ao_trailer: Option<AoTrailer>,
+ records: Vec<EntryRecord>,
+}
+
+#[derive(Clone, Debug)]
+struct EntryRecord {
+ meta: EntryMeta,
+ key16: u16,
+ packed_size_declared: u32,
+ packed_size_available: usize,
+ effective_offset: usize,
+}
+
+#[allow(clippy::too_many_lines)]
+fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliError> {
+ if bytes.len() < 32 {
+ return Err(RsliError::EntryTableOutOfBounds {
+ table_offset: 32,
+ table_len: 0,
+ file_len: u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?,
+ });
+ }
+
+ let mut header_raw = [0u8; 32];
+ header_raw.copy_from_slice(&bytes[0..32]);
+
+ let mut magic = [0u8; 2];
+ magic.copy_from_slice(&bytes[0..2]);
+ if &magic != b"NL" {
+ return Err(RsliError::InvalidMagic { got: magic });
+ }
+ let reserved = bytes[2];
+ if reserved != 0 {
+ return Err(RsliError::InvalidReserved { got: reserved });
+ }
+ let version = bytes[3];
+ if version != 0x01 {
+ return Err(RsliError::UnsupportedVersion { got: version });
+ }
+
+ let entry_count_signed = i16::from_le_bytes([bytes[4], bytes[5]]);
+ if entry_count_signed < 0 {
+ return Err(RsliError::InvalidEntryCount {
+ got: entry_count_signed,
+ });
+ }
+ let count = usize::try_from(entry_count_signed).map_err(|_| RsliError::IntegerOverflow)?;
+ if count > usize::try_from(u32::MAX).map_err(|_| RsliError::IntegerOverflow)? {
+ return Err(RsliError::TooManyEntries { got: count });
+ }
+
+ let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
+ let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
+ let header = RsliHeader {
+ raw: header_raw,
+ version,
+ entry_count: u16::try_from(entry_count_signed).map_err(|_| RsliError::IntegerOverflow)?,
+ presorted_flag,
+ xor_seed,
+ };
+
+ let table_len = count.checked_mul(32).ok_or(RsliError::IntegerOverflow)?;
+ let table_end = 32usize
+ .checked_add(table_len)
+ .ok_or(RsliError::IntegerOverflow)?;
+ if table_end > bytes.len() {
+ return Err(RsliError::EntryTableOutOfBounds {
+ table_offset: 32,
+ table_len: u64::try_from(table_len).map_err(|_| RsliError::IntegerOverflow)?,
+ file_len: u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?,
+ });
+ }
+
+ let table_plain = xor_stream(&bytes[32..table_end], (xor_seed & 0xFFFF) as u16);
+ if table_plain.len() != table_len {
+ return Err(RsliError::CorruptEntryTable(
+ "entry table decrypt length mismatch",
+ ));
+ }
+
+ let (overlay, trailer_raw) = parse_ao_trailer(bytes, options.allow_ao_trailer)?;
+
+ let mut records = Vec::with_capacity(count);
+ for idx in 0..count {
+ let row = &table_plain[idx * 32..(idx + 1) * 32];
+ let mut name_raw = [0u8; 12];
+ name_raw.copy_from_slice(&row[0..12]);
+
+ let flags_signed = i16::from_le_bytes([row[16], row[17]]);
+ let mut sort_to_original = i16::from_le_bytes([row[18], row[19]]);
+ let unpacked_size = u32::from_le_bytes([row[20], row[21], row[22], row[23]]);
+ let data_offset_raw = u32::from_le_bytes([row[24], row[25], row[26], row[27]]);
+ let packed_size_declared = u32::from_le_bytes([row[28], row[29], row[30], row[31]]);
+ let method_raw = u32::from(flags_signed.cast_unsigned()) & 0x1E0;
+ let method = parse_method(method_raw);
+
+ let effective_offset_u64 = u64::from(data_offset_raw)
+ .checked_add(u64::from(overlay))
+ .ok_or(RsliError::IntegerOverflow)?;
+ let effective_offset =
+ usize::try_from(effective_offset_u64).map_err(|_| RsliError::IntegerOverflow)?;
+ let mut packed_size_available =
+ usize::try_from(packed_size_declared).map_err(|_| RsliError::IntegerOverflow)?;
+ let end = effective_offset_u64
+ .checked_add(u64::from(packed_size_declared))
+ .ok_or(RsliError::IntegerOverflow)?;
+ let file_len = u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?;
+
+ if end > file_len {
+ if method_raw == 0x100 && end == file_len + 1 {
+ if options.allow_deflate_eof_plus_one
+ && is_registered_deflate_eof_plus_one_quirk(&name_raw)
+ {
+ packed_size_available = packed_size_available
+ .checked_sub(1)
+ .ok_or(RsliError::IntegerOverflow)?;
+ } else {
+ return Err(RsliError::DeflateEofPlusOneQuirkRejected {
+ id: u32::try_from(idx).map_err(|_| RsliError::IntegerOverflow)?,
+ });
+ }
+ } else {
+ return Err(RsliError::PackedSizePastEof {
+ id: u32::try_from(idx).map_err(|_| RsliError::IntegerOverflow)?,
+ offset: effective_offset_u64,
+ packed_size: packed_size_declared,
+ file_len,
+ });
+ }
+ }
+
+ let available_end = effective_offset
+ .checked_add(packed_size_available)
+ .ok_or(RsliError::IntegerOverflow)?;
+ if available_end > bytes.len() {
+ return Err(RsliError::EntryDataOutOfBounds {
+ id: u32::try_from(idx).map_err(|_| RsliError::IntegerOverflow)?,
+ offset: effective_offset_u64,
+ size: packed_size_declared,
+ file_len,
+ });
+ }
+
+ if presorted_flag != 0xABBA {
+ sort_to_original = 0;
+ }
+
+ records.push(EntryRecord {
+ meta: EntryMeta {
+ name: decode_name(c_name_bytes(&name_raw)),
+ name_raw,
+ flags: i32::from(flags_signed),
+ method,
+ data_offset: effective_offset_u64,
+ packed_size: packed_size_declared,
+ unpacked_size,
+ sort_to_original,
+ data_offset_raw,
+ },
+ key16: sort_to_original.cast_unsigned(),
+ packed_size_declared,
+ packed_size_available,
+ effective_offset,
+ });
+ }
+
+ if presorted_flag == 0xABBA {
+ if validate_permutation(&records).is_err() {
+ if !options.allow_invalid_presorted_fallback {
+ validate_permutation(&records)?;
+ }
+ rebuild_sorted_mapping(&mut records)?;
+ }
+ } else {
+ rebuild_sorted_mapping(&mut records)?;
+ }
+
+ Ok(ParsedRsli {
+ header,
+ ao_trailer: trailer_raw.map(|raw| AoTrailer { raw, overlay }),
+ records,
+ })
+}
+
+fn rebuild_sorted_mapping(records: &mut [EntryRecord]) -> Result<(), RsliError> {
+ let mut sorted: Vec<usize> = (0..records.len()).collect();
+ sorted.sort_by(|a, b| {
+ cmp_c_string(
+ c_name_bytes(&records[*a].meta.name_raw),
+ c_name_bytes(&records[*b].meta.name_raw),
+ )
+ });
+ for (idx, record) in records.iter_mut().enumerate() {
+ record.meta.sort_to_original =
+ i16::try_from(sorted[idx]).map_err(|_| RsliError::IntegerOverflow)?;
+ record.key16 = record.meta.sort_to_original.cast_unsigned();
+ }
+ Ok(())
+}
+
+fn parse_ao_trailer(bytes: &[u8], allow: bool) -> Result<(u32, Option<[u8; 6]>), RsliError> {
+ if !allow || bytes.len() < 6 || &bytes[bytes.len() - 6..bytes.len() - 4] != b"AO" {
+ return Ok((0, None));
+ }
+ let mut raw = [0u8; 6];
+ raw.copy_from_slice(&bytes[bytes.len() - 6..]);
+ let overlay = u32::from_le_bytes([raw[2], raw[3], raw[4], raw[5]]);
+ if u64::from(overlay) > u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)? {
+ return Err(RsliError::MediaOverlayOutOfBounds {
+ overlay,
+ file_len: u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?,
+ });
+ }
+ Ok((overlay, Some(raw)))
+}
+
+fn validate_permutation(records: &[EntryRecord]) -> Result<(), RsliError> {
+ let mut seen = vec![false; records.len()];
+ for record in records {
+ let idx = i32::from(record.meta.sort_to_original);
+ if idx < 0 {
+ return Err(RsliError::CorruptEntryTable(
+ "sort_to_original is not a valid permutation index",
+ ));
+ }
+ let idx = usize::try_from(idx).map_err(|_| RsliError::IntegerOverflow)?;
+ if idx >= records.len() || seen[idx] {
+ return Err(RsliError::CorruptEntryTable(
+ "sort_to_original is not a permutation",
+ ));
+ }
+ seen[idx] = true;
+ }
+ if seen.iter().any(|value| !*value) {
+ return Err(RsliError::CorruptEntryTable(
+ "sort_to_original is not a permutation",
+ ));
+ }
+ Ok(())
+}
+
+fn parse_method(raw: u32) -> RsliMethod {
+ match raw {
+ 0x000 => RsliMethod::Stored,
+ 0x020 => RsliMethod::XorOnly,
+ 0x040 => RsliMethod::Lzss,
+ 0x060 => RsliMethod::XorLzss,
+ 0x080 => RsliMethod::AdaptiveLzss,
+ 0x0A0 => RsliMethod::XorAdaptiveLzss,
+ 0x100 => RsliMethod::RawDeflate,
+ other => RsliMethod::Unknown(other),
+ }
+}
+
+fn is_registered_deflate_eof_plus_one_quirk(name_raw: &[u8; 12]) -> bool {
+ c_name_bytes(name_raw)
+ .iter()
+ .map(u8::to_ascii_uppercase)
+ .eq(b"INTERF8.TEX".iter().copied())
+}
+
+fn decode_name(name: &[u8]) -> String {
+ name.iter().map(|byte| char::from(*byte)).collect()
+}
+
+fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
+ let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len());
+ &raw[..len]
+}
+
+fn cmp_c_string(a: &[u8], b: &[u8]) -> std::cmp::Ordering {
+ let min_len = a.len().min(b.len());
+ for idx in 0..min_len {
+ if a[idx] != b[idx] {
+ return a[idx].cmp(&b[idx]);
+ }
+ }
+ a.len().cmp(&b.len())
+}
+
+fn decode_payload(
+ packed: &[u8],
+ method: RsliMethod,
+ key16: u16,
+ unpacked_size: u32,
+) -> Result<Vec<u8>, RsliError> {
+ let expected = usize::try_from(unpacked_size).map_err(|_| RsliError::IntegerOverflow)?;
+ let out = match method {
+ RsliMethod::Stored => {
+ if packed.len() < expected {
+ return Err(RsliError::OutputSizeMismatch {
+ expected: unpacked_size,
+ got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
+ });
+ }
+ packed[..expected].to_vec()
+ }
+ RsliMethod::XorOnly => {
+ if packed.len() < expected {
+ return Err(RsliError::OutputSizeMismatch {
+ expected: unpacked_size,
+ got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
+ });
+ }
+ xor_stream(&packed[..expected], key16)
+ }
+ RsliMethod::Lzss => lzss_decompress_simple(packed, expected, None)?,
+ RsliMethod::XorLzss => lzss_decompress_simple(packed, expected, Some(key16))?,
+ RsliMethod::AdaptiveLzss => lzss_huffman_decompress(packed, expected, None)?,
+ RsliMethod::XorAdaptiveLzss => lzss_huffman_decompress(packed, expected, Some(key16))?,
+ RsliMethod::RawDeflate => decode_deflate(packed)?,
+ RsliMethod::Unknown(raw) => return Err(RsliError::UnsupportedMethod { raw }),
+ };
+ if out.len() != expected {
+ return Err(RsliError::OutputSizeMismatch {
+ expected: unpacked_size,
+ got: u32::try_from(out.len()).unwrap_or(u32::MAX),
+ });
+ }
+ Ok(out)
+}
+
+#[derive(Clone, Copy, Debug)]
+struct XorState {
+ lo: u8,
+ hi: u8,
+}
+
+impl XorState {
+ fn new(key16: u16) -> Self {
+ Self {
+ lo: u8::try_from(key16 & 0xFF).unwrap_or(u8::MAX),
+ hi: u8::try_from((key16 >> 8) & 0xFF).unwrap_or(u8::MAX),
+ }
+ }
+
+ fn decrypt_byte(&mut self, encrypted: u8) -> u8 {
+ self.lo = self.hi ^ self.lo.wrapping_shl(1);
+ let decrypted = encrypted ^ self.lo;
+ self.hi = self.lo ^ (self.hi >> 1);
+ decrypted
+ }
+}
+
+fn xor_stream(data: &[u8], key16: u16) -> Vec<u8> {
+ let mut state = XorState::new(key16);
+ data.iter().map(|byte| state.decrypt_byte(*byte)).collect()
+}
+
+fn lzss_decompress_simple(
+ data: &[u8],
+ expected_size: usize,
+ xor_key: Option<u16>,
+) -> Result<Vec<u8>, RsliError> {
+ let mut ring = [0x20u8; 0x1000];
+ let mut ring_pos = 0xFEEusize;
+ let mut out = Vec::with_capacity(expected_size);
+ let mut in_pos = 0usize;
+ let mut control = 0u8;
+ let mut bits_left = 0u8;
+ let mut xor_state = xor_key.map(XorState::new);
+
+ while out.len() < expected_size {
+ if bits_left == 0 {
+ control = read_packed_byte(data, in_pos, &mut xor_state).ok_or(
+ RsliError::DecompressionFailed("lzss-simple: unexpected EOF"),
+ )?;
+ in_pos = in_pos.saturating_add(1);
+ bits_left = 8;
+ }
+
+ if (control & 1) != 0 {
+ let byte = read_packed_byte(data, in_pos, &mut xor_state).ok_or(
+ RsliError::DecompressionFailed("lzss-simple: unexpected EOF"),
+ )?;
+ in_pos = in_pos.saturating_add(1);
+ out.push(byte);
+ ring[ring_pos] = byte;
+ ring_pos = (ring_pos + 1) & 0x0FFF;
+ } else {
+ let low = read_packed_byte(data, in_pos, &mut xor_state).ok_or(
+ RsliError::DecompressionFailed("lzss-simple: unexpected EOF"),
+ )?;
+ let high = read_packed_byte(data, in_pos.saturating_add(1), &mut xor_state).ok_or(
+ RsliError::DecompressionFailed("lzss-simple: unexpected EOF"),
+ )?;
+ in_pos = in_pos.saturating_add(2);
+ let offset = usize::from(low) | (usize::from(high & 0xF0) << 4);
+ let length = usize::from((high & 0x0F) + 3);
+ for step in 0..length {
+ let byte = ring[(offset + step) & 0x0FFF];
+ out.push(byte);
+ ring[ring_pos] = byte;
+ ring_pos = (ring_pos + 1) & 0x0FFF;
+ if out.len() >= expected_size {
+ break;
+ }
+ }
+ }
+ control >>= 1;
+ bits_left -= 1;
+ }
+ Ok(out)
+}
+
+fn read_packed_byte(data: &[u8], pos: usize, state: &mut Option<XorState>) -> Option<u8> {
+ let encrypted = data.get(pos).copied()?;
+ Some(if let Some(state) = state {
+ state.decrypt_byte(encrypted)
+ } else {
+ encrypted
+ })
+}
+
+fn decode_deflate(packed: &[u8]) -> Result<Vec<u8>, RsliError> {
+ let mut out = Vec::new();
+ let mut decoder = flate2::read::DeflateDecoder::new(packed);
+ decoder
+ .read_to_end(&mut out)
+ .map_err(|_| RsliError::DecompressionFailed("deflate"))?;
+ Ok(out)
+}
+
+const LZH_N: usize = 4096;
+const LZH_F: usize = 60;
+const LZH_THRESHOLD: usize = 2;
+const LZH_N_CHAR: usize = 256 - LZH_THRESHOLD + LZH_F;
+const LZH_T: usize = LZH_N_CHAR * 2 - 1;
+const LZH_R: usize = LZH_T - 1;
+const LZH_MAX_FREQ: u16 = 0x8000;
+
+fn lzss_huffman_decompress(
+ data: &[u8],
+ expected_size: usize,
+ xor_key: Option<u16>,
+) -> Result<Vec<u8>, RsliError> {
+ let mut decoder = LzhDecoder::new(data, xor_key);
+ decoder.decode(expected_size)
+}
+
+struct LzhDecoder<'a> {
+ bit_reader: BitReader<'a>,
+ text: [u8; LZH_N],
+ freq: [u16; LZH_T + 1],
+ parent: [usize; LZH_T + LZH_N_CHAR],
+ son: [usize; LZH_T],
+ d_code: [u8; 256],
+ d_len: [u8; 256],
+ ring_pos: usize,
+}
+
+impl<'a> LzhDecoder<'a> {
+ fn new(data: &'a [u8], xor_key: Option<u16>) -> Self {
+ let mut decoder = Self {
+ bit_reader: BitReader::new(data, xor_key),
+ text: [0x20u8; LZH_N],
+ freq: [0u16; LZH_T + 1],
+ parent: [0usize; LZH_T + LZH_N_CHAR],
+ son: [0usize; LZH_T],
+ d_code: [0u8; 256],
+ d_len: [0u8; 256],
+ ring_pos: LZH_N - LZH_F,
+ };
+ decoder.init_tables();
+ decoder.start_huff();
+ decoder
+ }
+
+ fn decode(&mut self, expected_size: usize) -> Result<Vec<u8>, RsliError> {
+ let mut out = Vec::with_capacity(expected_size);
+ while out.len() < expected_size {
+ let c = self.decode_char()?;
+ if c < 256 {
+ let byte = u8::try_from(c).map_err(|_| RsliError::IntegerOverflow)?;
+ out.push(byte);
+ self.text[self.ring_pos] = byte;
+ self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1);
+ } else {
+ let mut offset = self.decode_position()?;
+ offset = (self.ring_pos.wrapping_sub(offset).wrapping_sub(1)) & (LZH_N - 1);
+ let mut length = c.saturating_sub(253);
+ while length > 0 && out.len() < expected_size {
+ let byte = self.text[offset];
+ out.push(byte);
+ self.text[self.ring_pos] = byte;
+ self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1);
+ offset = (offset + 1) & (LZH_N - 1);
+ length -= 1;
+ }
+ }
+ }
+ Ok(out)
+ }
+
+ fn init_tables(&mut self) {
+ let d_code_group_counts = [1usize, 3, 8, 12, 24, 16];
+ let d_len_group_counts = [32usize, 48, 64, 48, 48, 16];
+ let mut group_index = 0u8;
+ let mut idx = 0usize;
+ let mut run = 32usize;
+ for count in d_code_group_counts {
+ for _ in 0..count {
+ for _ in 0..run {
+ self.d_code[idx] = group_index;
+ idx += 1;
+ }
+ group_index = group_index.wrapping_add(1);
+ }
+ run >>= 1;
+ }
+
+ let mut len = 3u8;
+ idx = 0;
+ for count in d_len_group_counts {
+ for _ in 0..count {
+ self.d_len[idx] = len;
+ idx += 1;
+ }
+ len = len.saturating_add(1);
+ }
+ }
+
+ fn start_huff(&mut self) {
+ for i in 0..LZH_N_CHAR {
+ self.freq[i] = 1;
+ self.son[i] = i + LZH_T;
+ self.parent[i + LZH_T] = i;
+ }
+ let mut i = 0usize;
+ let mut j = LZH_N_CHAR;
+ while j <= LZH_R {
+ self.freq[j] = self.freq[i].saturating_add(self.freq[i + 1]);
+ self.son[j] = i;
+ self.parent[i] = j;
+ self.parent[i + 1] = j;
+ i += 2;
+ j += 1;
+ }
+ self.freq[LZH_T] = u16::MAX;
+ self.parent[LZH_R] = 0;
+ }
+
+ fn decode_char(&mut self) -> Result<usize, RsliError> {
+ let mut node = self.son[LZH_R];
+ while node < LZH_T {
+ let bit = usize::from(self.bit_reader.read_bit()?);
+ let branch = node
+ .checked_add(bit)
+ .ok_or(RsliError::DecompressionFailed("lzss-huffman tree overflow"))?;
+ node = *self.son.get(branch).ok_or(RsliError::DecompressionFailed(
+ "lzss-huffman tree out of bounds",
+ ))?;
+ }
+ let c = node - LZH_T;
+ self.update(c);
+ Ok(c)
+ }
+
+ fn decode_position(&mut self) -> Result<usize, RsliError> {
+ let i = usize::try_from(self.bit_reader.read_bits(8)?)
+ .map_err(|_| RsliError::IntegerOverflow)?;
+ let mut c = usize::from(self.d_code[i]) << 6;
+ let mut j = usize::from(self.d_len[i]).saturating_sub(2);
+ while j > 0 {
+ j -= 1;
+ c |= usize::from(self.bit_reader.read_bit()?) << j;
+ }
+ Ok(c | (i & 0x3F))
+ }
+
+ fn update(&mut self, c: usize) {
+ if self.freq[LZH_R] == LZH_MAX_FREQ {
+ self.reconstruct();
+ }
+ let mut current = self.parent[c + LZH_T];
+ loop {
+ self.freq[current] = self.freq[current].saturating_add(1);
+ let freq = self.freq[current];
+ if current + 1 < self.freq.len() && freq > self.freq[current + 1] {
+ let mut swap_idx = current + 1;
+ while swap_idx + 1 < self.freq.len() && freq > self.freq[swap_idx + 1] {
+ swap_idx += 1;
+ }
+ self.freq.swap(current, swap_idx);
+ let left = self.son[current];
+ let right = self.son[swap_idx];
+ self.son[current] = right;
+ self.son[swap_idx] = left;
+ self.parent[left] = swap_idx;
+ if left < LZH_T {
+ self.parent[left + 1] = swap_idx;
+ }
+ self.parent[right] = current;
+ if right < LZH_T {
+ self.parent[right + 1] = current;
+ }
+ current = swap_idx;
+ }
+ current = self.parent[current];
+ if current == 0 {
+ break;
+ }
+ }
+ }
+
+ fn reconstruct(&mut self) {
+ let mut j = 0usize;
+ for i in 0..LZH_T {
+ if self.son[i] >= LZH_T {
+ self.freq[j] = (self.freq[i].saturating_add(1)) / 2;
+ self.son[j] = self.son[i];
+ j += 1;
+ }
+ }
+ let mut i = 0usize;
+ let mut current = LZH_N_CHAR;
+ while current < LZH_T {
+ let sum = self.freq[i].saturating_add(self.freq[i + 1]);
+ self.freq[current] = sum;
+ let mut insert_at = current;
+ while insert_at > 0 && sum < self.freq[insert_at - 1] {
+ insert_at -= 1;
+ }
+ for move_idx in (insert_at..current).rev() {
+ self.freq[move_idx + 1] = self.freq[move_idx];
+ self.son[move_idx + 1] = self.son[move_idx];
+ }
+ self.freq[insert_at] = sum;
+ self.son[insert_at] = i;
+ i += 2;
+ current += 1;
+ }
+ for idx in 0..LZH_T {
+ let node = self.son[idx];
+ self.parent[node] = idx;
+ if node < LZH_T {
+ self.parent[node + 1] = idx;
+ }
+ }
+ self.freq[LZH_T] = u16::MAX;
+ self.parent[LZH_R] = 0;
+ }
+}
+
+struct BitReader<'a> {
+ data: &'a [u8],
+ byte_pos: usize,
+ bit_mask: u8,
+ current_byte: u8,
+ xor_state: Option<XorState>,
+}
+
+impl<'a> BitReader<'a> {
+ fn new(data: &'a [u8], xor_key: Option<u16>) -> Self {
+ Self {
+ data,
+ byte_pos: 0,
+ bit_mask: 0x80,
+ current_byte: 0,
+ xor_state: xor_key.map(XorState::new),
+ }
+ }
+
+ fn read_bit(&mut self) -> Result<u8, RsliError> {
+ if self.bit_mask == 0x80 {
+ let Some(mut byte) = self.data.get(self.byte_pos).copied() else {
+ return Err(RsliError::DecompressionFailed(
+ "lzss-huffman: unexpected EOF",
+ ));
+ };
+ if let Some(state) = &mut self.xor_state {
+ byte = state.decrypt_byte(byte);
+ }
+ self.current_byte = byte;
+ }
+ let bit = u8::from((self.current_byte & self.bit_mask) != 0);
+ self.bit_mask >>= 1;
+ if self.bit_mask == 0 {
+ self.bit_mask = 0x80;
+ self.byte_pos = self.byte_pos.saturating_add(1);
+ }
+ Ok(bit)
+ }
+
+ fn read_bits(&mut self, bits: usize) -> Result<u32, RsliError> {
+ let mut value = 0u32;
+ for _ in 0..bits {
+ value = (value << 1) | u32::from(self.read_bit()?);
+ }
+ Ok(value)
+ }
+}
+
+fn saturating_u32_len(len: usize) -> u32 {
+ u32::try_from(len).unwrap_or(u32::MAX)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+ use std::path::{Path, PathBuf};
+
+ #[test]
+ fn parses_minimal_empty_library() {
+ let bytes = synthetic_rsli(&[], false, 0x1234, None);
+
+ let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("minimal RsLi");
+
+ assert_eq!(doc.entry_count(), 0);
+ assert_eq!(doc.header().raw[0..4], *b"NL\0\x01");
+ assert_eq!(doc.encode(WriteProfile::Lossless), bytes);
+ }
+
+ #[test]
+ fn rejects_invalid_header_fields() {
+ let valid = synthetic_rsli(&[], false, 0, None);
+
+ let mut invalid_magic = valid.clone();
+ invalid_magic[0] = b'X';
+ assert!(matches!(
+ decode(arc(invalid_magic), ReadProfile::Strict),
+ Err(RsliError::InvalidMagic { .. })
+ ));
+
+ let mut invalid_reserved = valid.clone();
+ invalid_reserved[2] = 1;
+ assert!(matches!(
+ decode(arc(invalid_reserved), ReadProfile::Strict),
+ Err(RsliError::InvalidReserved { got: 1 })
+ ));
+
+ let mut invalid_version = valid.clone();
+ invalid_version[3] = 2;
+ assert!(matches!(
+ decode(arc(invalid_version), ReadProfile::Strict),
+ Err(RsliError::UnsupportedVersion { got: 2 })
+ ));
+
+ let mut invalid_count = valid;
+ invalid_count[4..6].copy_from_slice(&(-1i16).to_le_bytes());
+ assert!(matches!(
+ decode(arc(invalid_count), ReadProfile::Strict),
+ Err(RsliError::InvalidEntryCount { got: -1 })
+ ));
+ }
+
+ #[test]
+ fn rejects_entry_table_bounds() {
+ let mut bytes = synthetic_rsli(&[], false, 0, None);
+ bytes[4..6].copy_from_slice(&1i16.to_le_bytes());
+
+ assert!(matches!(
+ decode(arc(bytes), ReadProfile::Strict),
+ Err(RsliError::EntryTableOutOfBounds { .. })
+ ));
+ }
+
+ #[test]
+ fn table_xor_transform_uses_known_vector() {
+ assert_eq!(
+ xor_stream(&[0x00, 0x01, 0x02, 0x03], 0x1234),
+ [0x7A, 0x86, 0xB2, 0x8C]
+ );
+ }
+
+ #[test]
+ fn table_xor_transform_is_symmetric() {
+ let plain = b"entry table bytes".to_vec();
+ let encrypted = xor_stream(&plain, 0x3456);
+
+ assert_ne!(encrypted, plain);
+ assert_eq!(xor_stream(&encrypted, 0x3456), plain);
+ }
+
+ #[test]
+ fn table_xor_state_spans_entries() {
+ let rows = two_plain_rows_for_transform_test();
+ let whole_stream = xor_stream(&rows.concat(), 0x2468);
+ let row_reset = rows
+ .iter()
+ .flat_map(|row| xor_stream(row, 0x2468))
+ .collect::<Vec<_>>();
+
+ assert_ne!(whole_stream, row_reset);
+
+ let bytes = synthetic_rsli(
+ &[
+ SyntheticEntry::stored(b"A", 0, b"a"),
+ SyntheticEntry::stored(b"B", 1, b"b"),
+ ],
+ true,
+ 0x2468,
+ None,
+ );
+ let doc = decode(arc(bytes), ReadProfile::Strict).expect("continuous table stream");
+ assert_eq!(doc.entry_count(), 2);
+ }
+
+ #[test]
+ fn presorted_mapping_uses_valid_permutation() {
+ let bytes = synthetic_rsli(
+ &[
+ SyntheticEntry::stored(b"B", 1, b"bee"),
+ SyntheticEntry::stored(b"A", 0, b"aye"),
+ ],
+ true,
+ 0x4321,
+ None,
+ );
+
+ let doc = decode(arc(bytes), ReadProfile::Strict).expect("valid presorted map");
+
+ assert_eq!(doc.find("A"), Some(EntryId(1)));
+ assert_eq!(doc.find("B"), Some(EntryId(0)));
+ assert_eq!(doc.load(EntryId(1)).expect("A payload"), b"aye");
+ }
+
+ #[test]
+ fn compatible_profile_rebuilds_invalid_presorted_mapping() {
+ let bytes = synthetic_rsli(
+ &[
+ SyntheticEntry::stored(b"B", 0, b"bee"),
+ SyntheticEntry::stored(b"A", 0, b"aye"),
+ ],
+ true,
+ 0x0102,
+ None,
+ );
+
+ assert!(matches!(
+ decode(arc(bytes.clone()), ReadProfile::Strict),
+ Err(RsliError::CorruptEntryTable(_))
+ ));
+
+ let doc = decode(arc(bytes), ReadProfile::Compatible).expect("compatible fallback");
+ assert_eq!(doc.find("A"), Some(EntryId(1)));
+ assert_eq!(doc.find("B"), Some(EntryId(0)));
+ }
+
+ #[test]
+ fn stored_method_uses_exact_size() {
+ let bytes = synthetic_rsli(
+ &[SyntheticEntry::stored(b"A", 0, b"abc")],
+ true,
+ 0x1111,
+ None,
+ );
+
+ let doc = decode(arc(bytes), ReadProfile::Strict).expect("stored entry");
+
+ assert_eq!(doc.load(EntryId(0)).expect("stored payload"), b"abc");
+ assert_eq!(doc.entry(EntryId(0)).expect("stored meta").packed_size, 3);
+ }
+
+ #[test]
+ fn xor_only_method_uses_entry_key() {
+ let plain = b"secret".to_vec();
+ let packed = xor_stream(&plain, 1);
+ let bytes = synthetic_rsli(
+ &[
+ SyntheticEntry::with_payload(b"A", 0x020, 1, &plain, packed),
+ SyntheticEntry::stored(b"B", 0, b"plain"),
+ ],
+ true,
+ 0x2222,
+ None,
+ );
+
+ let doc = decode(arc(bytes), ReadProfile::Strict).expect("xor entry");
+
+ assert_eq!(doc.load(EntryId(0)).expect("xor payload"), plain);
+ }
+
+ #[test]
+ fn lzss_method_decodes_literals_references_and_wrap() {
+ let bytes = synthetic_rsli(
+ &[
+ SyntheticEntry::with_payload(
+ b"LIT",
+ 0x040,
+ 0,
+ b"ABC",
+ vec![0b0000_0111, b'A', b'B', b'C'],
+ ),
+ SyntheticEntry::with_payload(
+ b"WRAP",
+ 0x040,
+ 1,
+ b" ",
+ vec![0b0000_0000, 0xFF, 0xF1],
+ ),
+ ],
+ true,
+ 0x1212,
+ None,
+ );
+
+ let doc = decode(arc(bytes), ReadProfile::Strict).expect("lzss archive");
+
+ assert_eq!(doc.load(EntryId(0)).expect("literal lzss"), b"ABC");
+ assert_eq!(doc.load(EntryId(1)).expect("wrapped reference"), b" ");
+ }
+
+ #[test]
+ fn xor_lzss_method_uses_entry_key() {
+ let plain_lzss = vec![0b0000_0111, b'X', b'Y', b'Z'];
+ let bytes = synthetic_rsli(
+ &[
+ SyntheticEntry::with_payload(b"X", 0x060, 1, b"XYZ", xor_stream(&plain_lzss, 1)),
+ SyntheticEntry::stored(b"A", 0, b"filler"),
+ ],
+ true,
+ 0x3434,
+ None,
+ );
+
+ let doc = decode(arc(bytes), ReadProfile::Strict).expect("xor lzss archive");
+
+ assert_eq!(doc.load(EntryId(0)).expect("xor lzss"), b"XYZ");
+ }
+
+ #[test]
+ fn adaptive_lzss_method_decodes_synthetic_vector() {
+ let bytes = synthetic_rsli(
+ &[SyntheticEntry::with_payload(
+ b"A",
+ 0x080,
+ 0,
+ b"t",
+ vec![0x00],
+ )],
+ true,
+ 0,
+ None,
+ );
+
+ let doc = decode(arc(bytes), ReadProfile::Strict).expect("adaptive lzss archive");
+
+ assert_eq!(doc.load(EntryId(0)).expect("adaptive lzss"), b"t");
+ }
+
+ #[test]
+ fn xor_adaptive_lzss_method_decodes_synthetic_vector() {
+ let bytes = synthetic_rsli(
+ &[
+ SyntheticEntry::with_payload(b"X", 0x0A0, 1, b"t", vec![0x02]),
+ SyntheticEntry::stored(b"A", 0, b"filler"),
+ ],
+ true,
+ 0x5656,
+ None,
+ );
+
+ let doc = decode(arc(bytes), ReadProfile::Strict).expect("xor adaptive lzss archive");
+
+ assert_eq!(doc.load(EntryId(0)).expect("xor adaptive lzss"), b"t");
+ }
+
+ #[test]
+ fn raw_deflate_method_expects_raw_stream_not_zlib_wrapper() {
+ let raw_deflate = vec![0x01, 0x03, 0x00, 0xFC, 0xFF, b'r', b'a', b'w'];
+ let bytes = synthetic_rsli(
+ &[SyntheticEntry::with_payload(
+ b"RAW",
+ 0x100,
+ 0,
+ b"raw",
+ raw_deflate,
+ )],
+ true,
+ 0,
+ None,
+ );
+ let doc = decode(arc(bytes), ReadProfile::Strict).expect("raw deflate archive");
+ assert_eq!(doc.load(EntryId(0)).expect("raw deflate"), b"raw");
+
+ let zlib_wrapped = vec![
+ 0x78, 0x01, 0x01, 0x03, 0x00, 0xFC, 0xFF, b'r', b'a', b'w', 0x02, 0x92, 0x01, 0x4B,
+ ];
+ let wrapped = synthetic_rsli(
+ &[SyntheticEntry::with_payload(
+ b"ZLIB",
+ 0x100,
+ 0,
+ b"raw",
+ zlib_wrapped,
+ )],
+ true,
+ 0,
+ None,
+ );
+ let doc = decode(arc(wrapped), ReadProfile::Strict).expect("zlib wrapped archive");
+ assert!(matches!(
+ doc.load(EntryId(0)),
+ Err(RsliError::DecompressionFailed("deflate"))
+ ));
+ }
+
+ #[test]
+ fn named_deflate_eof_plus_one_quirk_accepts_only_approved_entry() {
+ let raw_deflate = vec![0x01, 0x03, 0x00, 0xFC, 0xFF, b'r', b'a', b'w'];
+ let approved = synthetic_rsli(
+ &[SyntheticEntry::with_declared_packed_size(
+ b"INTERF8.TEX",
+ 0x100,
+ 0,
+ b"raw",
+ raw_deflate.clone(),
+ u32::try_from(raw_deflate.len() + 1).expect("declared size"),
+ )],
+ true,
+ 0,
+ None,
+ );
+
+ assert!(matches!(
+ decode(arc(approved.clone()), ReadProfile::Strict),
+ Err(RsliError::DeflateEofPlusOneQuirkRejected { id: 0 })
+ ));
+ let doc = decode(arc(approved), ReadProfile::Compatible).expect("approved EOF+1 quirk");
+ assert_eq!(doc.load(EntryId(0)).expect("approved payload"), b"raw");
+
+ let unknown = synthetic_rsli(
+ &[SyntheticEntry::with_declared_packed_size(
+ b"OTHER.TEX",
+ 0x100,
+ 0,
+ b"raw",
+ raw_deflate.clone(),
+ u32::try_from(raw_deflate.len() + 1).expect("declared size"),
+ )],
+ true,
+ 0,
+ None,
+ );
+ assert!(matches!(
+ decode(arc(unknown), ReadProfile::Compatible),
+ Err(RsliError::DeflateEofPlusOneQuirkRejected { id: 0 })
+ ));
+
+ let plus_two = synthetic_rsli(
+ &[SyntheticEntry::with_declared_packed_size(
+ b"INTERF8.TEX",
+ 0x100,
+ 0,
+ b"raw",
+ raw_deflate.clone(),
+ u32::try_from(raw_deflate.len() + 2).expect("declared size"),
+ )],
+ true,
+ 0,
+ None,
+ );
+ assert!(matches!(
+ decode(arc(plus_two), ReadProfile::Compatible),
+ Err(RsliError::PackedSizePastEof { id: 0, .. })
+ ));
+ }
+
+ #[test]
+ fn unknown_method_is_rejected_on_load() {
+ let bytes = synthetic_rsli(
+ &[SyntheticEntry::with_payload(
+ b"A",
+ 0x1E0,
+ 0,
+ b"abc",
+ b"abc".to_vec(),
+ )],
+ true,
+ 0,
+ None,
+ );
+ let doc = decode(arc(bytes), ReadProfile::Strict).expect("unknown method archive");
+
+ assert!(matches!(
+ doc.load(EntryId(0)),
+ Err(RsliError::UnsupportedMethod { raw: 0x1E0 })
+ ));
+ }
+
+ #[test]
+ fn decoded_size_mismatch_is_rejected() {
+ let bytes = synthetic_rsli(
+ &[SyntheticEntry::with_payload(
+ b"A",
+ 0x000,
+ 0,
+ b"abc",
+ b"ab".to_vec(),
+ )],
+ true,
+ 0,
+ None,
+ );
+ let doc = decode(arc(bytes), ReadProfile::Strict).expect("mismatched entry archive");
+
+ assert!(matches!(
+ doc.load(EntryId(0)),
+ Err(RsliError::OutputSizeMismatch {
+ expected: 3,
+ got: 2
+ })
+ ));
+ }
+
+ #[test]
+ fn ao_overlay_adjusts_effective_offsets() {
+ let bytes = synthetic_rsli(
+ &[SyntheticEntry::stored(b"A", 0, b"media")],
+ true,
+ 0x3333,
+ Some(4),
+ );
+
+ let doc = decode(arc(bytes), ReadProfile::Compatible).expect("AO overlay");
+ let meta = doc.entry(EntryId(0)).expect("AO meta");
+ assert_eq!(meta.data_offset, 64);
+ assert_eq!(meta.data_offset_raw, 60);
+ assert_eq!(doc.load(EntryId(0)).expect("AO payload"), b"media");
+ }
+
+ #[test]
+ fn invalid_ao_overlay_is_rejected() {
+ let mut bytes = synthetic_rsli(&[], false, 0, None);
+ bytes.extend_from_slice(b"AO");
+ bytes.extend_from_slice(&1000u32.to_le_bytes());
+
+ assert!(matches!(
+ decode(arc(bytes), ReadProfile::Compatible),
+ Err(RsliError::MediaOverlayOutOfBounds { overlay: 1000, .. })
+ ));
+ }
+
+ #[test]
+ fn unknown_header_bytes_are_lossless() {
+ let mut bytes = synthetic_rsli(
+ &[SyntheticEntry::stored(b"A", 0, b"abc")],
+ true,
+ 0x4444,
+ None,
+ );
+ bytes[6] = 0xA5;
+ bytes[24] = 0x5A;
+
+ let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("unknown header bytes");
+
+ assert_eq!(doc.header().raw[6], 0xA5);
+ assert_eq!(doc.header().raw[24], 0x5A);
+ assert_eq!(doc.encode(WriteProfile::Lossless), bytes);
+ }
+
+ #[test]
+ fn no_op_lossless_roundtrip_preserves_bytes() {
+ let bytes = synthetic_rsli(
+ &[
+ SyntheticEntry::stored(b"A", 0, b"alpha"),
+ SyntheticEntry::stored(b"B", 1, b"beta"),
+ ],
+ true,
+ 0x5555,
+ None,
+ );
+
+ let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("roundtrip archive");
+
+ assert_eq!(doc.encode(WriteProfile::Lossless), bytes);
+ }
+
+ #[test]
+ fn generated_supported_methods_decode_expected_bytes() {
+ let cases = [
+ (0x000, b"STO".as_slice(), b"ok".as_slice(), b"ok".to_vec()),
+ (
+ 0x020,
+ b"XOR".as_slice(),
+ b"ok".as_slice(),
+ xor_stream(b"ok", 0),
+ ),
+ (
+ 0x040,
+ b"LZS".as_slice(),
+ b"ok".as_slice(),
+ vec![0b0000_0011, b'o', b'k'],
+ ),
+ (
+ 0x060,
+ b"XLZ".as_slice(),
+ b"ok".as_slice(),
+ xor_stream(&[0b0000_0011, b'o', b'k'], 0),
+ ),
+ (0x080, b"ADP".as_slice(), b"t".as_slice(), vec![0x00]),
+ (
+ 0x0A0,
+ b"XAD".as_slice(),
+ b"t".as_slice(),
+ xor_stream(&[0x00], 0),
+ ),
+ (
+ 0x100,
+ b"DEF".as_slice(),
+ b"ok".as_slice(),
+ vec![0x01, 0x02, 0x00, 0xFD, 0xFF, b'o', b'k'],
+ ),
+ ];
+
+ for (idx, (method, name, expected, packed)) in cases.iter().enumerate() {
+ let bytes = synthetic_rsli(
+ &[SyntheticEntry::with_payload(
+ name,
+ *method,
+ 0,
+ expected,
+ packed.clone(),
+ )],
+ true,
+ u16::try_from(idx).expect("case index"),
+ None,
+ );
+ let doc = decode(arc(bytes), ReadProfile::Strict).expect("generated method archive");
+ assert_eq!(
+ doc.load(EntryId(0)).expect("generated method payload"),
+ *expected
+ );
+ }
+ }
+
+ #[test]
+ fn arbitrary_small_inputs_do_not_panic() {
+ for len in 0..128usize {
+ let mut bytes = vec![0u8; len];
+ if len >= 4 {
+ bytes[0..4].copy_from_slice(b"NL\0\x01");
+ }
+ if len >= 6 {
+ bytes[4..6].copy_from_slice(&((len % 8) as i16).to_le_bytes());
+ }
+ if len >= 24 {
+ bytes[20..24].copy_from_slice(&0x1357u32.to_le_bytes());
+ }
+
+ let strict =
+ std::panic::catch_unwind(|| decode(arc(bytes.clone()), ReadProfile::Strict));
+ let compatible =
+ std::panic::catch_unwind(|| decode(arc(bytes.clone()), ReadProfile::Compatible));
+ assert!(strict.is_ok());
+ assert!(compatible.is_ok());
+ }
+ }
+
+ #[test]
+ fn licensed_corpora_rsli_roundtrip_gates() {
+ let part1 = corpus_gate("IS", 2).expect("part 1 RsLi gate");
+ let part2 = corpus_gate("IS2", 2).expect("part 2 RsLi gate");
+
+ assert!(part1.entries > 0);
+ assert!(part2.entries > 0);
+ }
+
+ #[test]
+ fn licensed_part1_rsli_method_distribution_baseline() {
+ let stats = corpus_gate("IS", 2).expect("part 1 RsLi gate");
+
+ assert_eq!(
+ stats.methods,
+ RsliMethodCounts {
+ stored: 0,
+ xor_only: 0,
+ lzss: 2,
+ xor_lzss: 0,
+ adaptive_lzss: 0,
+ xor_adaptive_lzss: 0,
+ raw_deflate: 24,
+ unknown: 0,
+ }
+ );
+ }
+
+ #[test]
+ fn licensed_part2_rsli_method_distribution_baseline() {
+ let stats = corpus_gate("IS2", 2).expect("part 2 RsLi gate");
+
+ assert_eq!(
+ stats.methods,
+ RsliMethodCounts {
+ stored: 0,
+ xor_only: 0,
+ lzss: 2,
+ xor_lzss: 0,
+ adaptive_lzss: 0,
+ xor_adaptive_lzss: 0,
+ raw_deflate: 24,
+ unknown: 0,
+ }
+ );
+ }
+
+ #[test]
+ fn licensed_corpora_rsli_quirk_is_only_approved_interf8_tex() {
+ let part1 = corpus_gate("IS", 2).expect("part 1 RsLi gate");
+ let part2 = corpus_gate("IS2", 2).expect("part 2 RsLi gate");
+
+ assert_eq!(
+ part1.eof_plus_one_entries,
+ vec!["sprites.lib:INTERF8.TEX".to_string()]
+ );
+ assert_eq!(
+ part2.eof_plus_one_entries,
+ vec!["sprites.lib:INTERF8.TEX".to_string()]
+ );
+ assert_strict_profile_only_rejects_approved_quirk("IS");
+ assert_strict_profile_only_rejects_approved_quirk("IS2");
+ }
+
+ #[derive(Clone, Debug, Default, Eq, PartialEq)]
+ struct RsliMethodCounts {
+ stored: usize,
+ xor_only: usize,
+ lzss: usize,
+ xor_lzss: usize,
+ adaptive_lzss: usize,
+ xor_adaptive_lzss: usize,
+ raw_deflate: usize,
+ unknown: usize,
+ }
+
+ impl RsliMethodCounts {
+ fn add(&mut self, method: RsliMethod) {
+ match method {
+ RsliMethod::Stored => self.stored += 1,
+ RsliMethod::XorOnly => self.xor_only += 1,
+ RsliMethod::Lzss => self.lzss += 1,
+ RsliMethod::XorLzss => self.xor_lzss += 1,
+ RsliMethod::AdaptiveLzss => self.adaptive_lzss += 1,
+ RsliMethod::XorAdaptiveLzss => self.xor_adaptive_lzss += 1,
+ RsliMethod::RawDeflate => self.raw_deflate += 1,
+ RsliMethod::Unknown(_) => self.unknown += 1,
+ }
+ }
+ }
+
+ #[derive(Clone, Debug, Default, Eq, PartialEq)]
+ struct CorpusGateResult {
+ entries: usize,
+ methods: RsliMethodCounts,
+ eof_plus_one_entries: Vec<String>,
+ }
+
+ fn corpus_gate(name: &str, expected_files: usize) -> Result<CorpusGateResult, String> {
+ let files = corpus_files(name)?;
+ if files.len() != expected_files {
+ return Err(format!(
+ "{name}: expected {expected_files} RsLi files, got {}",
+ files.len()
+ ));
+ }
+
+ let mut entries = 0usize;
+ let mut methods = RsliMethodCounts::default();
+ let mut eof_plus_one_entries = Vec::new();
+ for path in &files {
+ let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
+ let doc = decode(arc(bytes.clone()), ReadProfile::Compatible)
+ .map_err(|err| format!("{}: {err}", path.display()))?;
+ entries = entries
+ .checked_add(doc.entry_count())
+ .ok_or_else(|| "entry count overflow".to_string())?;
+ for (idx, entry) in doc.entries().iter().enumerate() {
+ methods.add(entry.method);
+ if entry.method == RsliMethod::RawDeflate
+ && entry.data_offset + u64::from(entry.packed_size) == bytes.len() as u64 + 1
+ {
+ eof_plus_one_entries.push(format!(
+ "{}:{}",
+ path.file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or("<unknown>"),
+ entry.name
+ ));
+ }
+ let id = EntryId(u32::try_from(idx).map_err(|_| "entry id overflow")?);
+ let found = doc
+ .find(&entry.name)
+ .ok_or_else(|| format!("lookup failed: {}", path.display()))?;
+ if found != id {
+ return Err(format!("lookup mismatch: {}", path.display()));
+ }
+ let unpacked = doc
+ .load(id)
+ .map_err(|err| format!("{} entry #{idx}: {err}", path.display()))?;
+ if unpacked.len()
+ != usize::try_from(entry.unpacked_size).map_err(|_| "size overflow")?
+ {
+ return Err(format!("unpacked size mismatch: {}", path.display()));
+ }
+ let packed = doc
+ .load_packed(id)
+ .map_err(|err| format!("{} entry #{idx}: {err}", path.display()))?;
+ if packed.packed.is_empty() && entry.packed_size != 0 {
+ return Err(format!(
+ "packed payload unexpectedly empty: {}",
+ path.display()
+ ));
+ }
+ }
+ if doc.encode(WriteProfile::Lossless) != bytes {
+ return Err(format!("lossless roundtrip mismatch: {}", path.display()));
+ }
+ }
+ Ok(CorpusGateResult {
+ entries,
+ methods,
+ eof_plus_one_entries,
+ })
+ }
+
+ fn corpus_files(name: &str) -> Result<Vec<PathBuf>, String> {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("testdata")
+ .join(name);
+ if !root.is_dir() {
+ return Err(format!(
+ "licensed corpus root is missing: {}",
+ root.display()
+ ));
+ }
+ let mut files = Vec::new();
+ collect_rsli_files(&root, &mut files).map_err(|err| err.to_string())?;
+ files.sort();
+ Ok(files)
+ }
+
+ fn assert_strict_profile_only_rejects_approved_quirk(name: &str) {
+ for path in corpus_files(name).expect("licensed RsLi files") {
+ let bytes = fs::read(&path).expect("licensed RsLi bytes");
+ let doc = decode(arc(bytes.clone()), ReadProfile::Compatible)
+ .expect("compatible licensed RsLi");
+ let mut eof_plus_one_names = Vec::new();
+ for entry in doc.entries() {
+ if entry.method == RsliMethod::RawDeflate
+ && entry.data_offset + u64::from(entry.packed_size) == bytes.len() as u64 + 1
+ {
+ eof_plus_one_names.push(entry.name.clone());
+ }
+ }
+
+ let strict = decode(arc(bytes), ReadProfile::Strict);
+ if eof_plus_one_names.is_empty() {
+ assert!(
+ strict.is_ok(),
+ "strict profile should accept {}",
+ path.display()
+ );
+ } else {
+ assert_eq!(eof_plus_one_names, vec!["INTERF8.TEX".to_string()]);
+ assert!(
+ matches!(
+ strict,
+ Err(RsliError::DeflateEofPlusOneQuirkRejected { .. })
+ ),
+ "strict profile should only reject the approved EOF+1 quirk in {}",
+ path.display()
+ );
+ }
+ }
+ }
+
+ fn collect_rsli_files(root: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
+ for entry in fs::read_dir(root)? {
+ let path = entry?.path();
+ if path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.starts_with('.'))
+ {
+ continue;
+ }
+ if path.is_dir() {
+ collect_rsli_files(&path, out)?;
+ continue;
+ }
+ if path.is_file() {
+ let bytes = fs::read(&path)?;
+ if bytes.get(0..4) == Some(b"NL\0\x01") {
+ out.push(path);
+ }
+ }
+ }
+ Ok(())
+ }
+
+ fn arc(bytes: Vec<u8>) -> Arc<[u8]> {
+ Arc::from(bytes.into_boxed_slice())
+ }
+
+ #[derive(Clone, Debug)]
+ struct SyntheticEntry {
+ name: Vec<u8>,
+ method_raw: u32,
+ sort_to_original: i16,
+ unpacked_size: u32,
+ declared_packed_size: u32,
+ packed: Vec<u8>,
+ }
+
+ impl SyntheticEntry {
+ fn stored(name: &[u8], sort_to_original: i16, payload: &[u8]) -> Self {
+ Self::with_payload(name, 0x000, sort_to_original, payload, payload.to_vec())
+ }
+
+ fn with_payload(
+ name: &[u8],
+ method_raw: u32,
+ sort_to_original: i16,
+ unpacked: &[u8],
+ packed: Vec<u8>,
+ ) -> Self {
+ let declared_packed_size = u32::try_from(packed.len()).expect("synthetic packed size");
+ Self::with_declared_packed_size(
+ name,
+ method_raw,
+ sort_to_original,
+ unpacked,
+ packed,
+ declared_packed_size,
+ )
+ }
+
+ fn with_declared_packed_size(
+ name: &[u8],
+ method_raw: u32,
+ sort_to_original: i16,
+ unpacked: &[u8],
+ packed: Vec<u8>,
+ declared_packed_size: u32,
+ ) -> Self {
+ Self {
+ name: name.to_vec(),
+ method_raw,
+ sort_to_original,
+ unpacked_size: u32::try_from(unpacked.len()).expect("synthetic unpacked size"),
+ declared_packed_size,
+ packed,
+ }
+ }
+ }
+
+ fn synthetic_rsli(
+ entries: &[SyntheticEntry],
+ presorted: bool,
+ xor_seed: u16,
+ overlay: Option<u32>,
+ ) -> Vec<u8> {
+ let count = i16::try_from(entries.len()).expect("synthetic entry count");
+ let table_len = entries
+ .len()
+ .checked_mul(32)
+ .expect("synthetic table length");
+ let payload_offset = 32usize
+ .checked_add(table_len)
+ .expect("synthetic payload offset");
+ let overlay = overlay.unwrap_or(0);
+
+ let mut header = [0u8; 32];
+ header[0..4].copy_from_slice(b"NL\0\x01");
+ header[4..6].copy_from_slice(&count.to_le_bytes());
+ if presorted {
+ header[14..16].copy_from_slice(&0xABBAu16.to_le_bytes());
+ }
+ header[20..24].copy_from_slice(&u32::from(xor_seed).to_le_bytes());
+
+ let mut table_plain = Vec::with_capacity(table_len);
+ let mut cursor = payload_offset;
+ for entry in entries {
+ let mut row = [0u8; 32];
+ let name_len = entry.name.len().min(12);
+ row[0..name_len].copy_from_slice(&entry.name[..name_len]);
+ row[16..18].copy_from_slice(
+ &i16::try_from(entry.method_raw)
+ .expect("synthetic method fits")
+ .to_le_bytes(),
+ );
+ row[18..20].copy_from_slice(&entry.sort_to_original.to_le_bytes());
+ row[20..24].copy_from_slice(&entry.unpacked_size.to_le_bytes());
+ let raw_offset = u32::try_from(cursor)
+ .expect("synthetic offset")
+ .checked_sub(overlay)
+ .expect("synthetic overlay precedes payload");
+ row[24..28].copy_from_slice(&raw_offset.to_le_bytes());
+ row[28..32].copy_from_slice(&entry.declared_packed_size.to_le_bytes());
+ table_plain.extend_from_slice(&row);
+ cursor = cursor
+ .checked_add(entry.packed.len())
+ .expect("synthetic payload cursor");
+ }
+
+ let mut bytes = Vec::with_capacity(cursor + 6);
+ bytes.extend_from_slice(&header);
+ bytes.extend_from_slice(&xor_stream(&table_plain, xor_seed));
+ for entry in entries {
+ bytes.extend_from_slice(&entry.packed);
+ }
+ if overlay != 0 {
+ bytes.extend_from_slice(b"AO");
+ bytes.extend_from_slice(&overlay.to_le_bytes());
+ }
+ bytes
+ }
+
+ fn two_plain_rows_for_transform_test() -> Vec<[u8; 32]> {
+ let mut a = [0u8; 32];
+ let mut b = [0u8; 32];
+ a[0] = b'A';
+ b[0] = b'B';
+ a[18..20].copy_from_slice(&0i16.to_le_bytes());
+ b[18..20].copy_from_slice(&1i16.to_le_bytes());
+ vec![a, b]
+ }
+}
diff --git a/crates/fparkan-runtime/Cargo.toml b/crates/fparkan-runtime/Cargo.toml
new file mode 100644
index 0000000..17d95c1
--- /dev/null
+++ b/crates/fparkan-runtime/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "fparkan-runtime"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+fparkan-mission-format = { path = "../fparkan-mission-format" }
+fparkan-nres = { path = "../fparkan-nres" }
+fparkan-path = { path = "../fparkan-path" }
+fparkan-platform = { path = "../fparkan-platform" }
+fparkan-prototype = { path = "../fparkan-prototype" }
+fparkan-render = { path = "../fparkan-render" }
+fparkan-resource = { path = "../fparkan-resource" }
+fparkan-terrain = { path = "../fparkan-terrain" }
+fparkan-terrain-format = { path = "../fparkan-terrain-format" }
+fparkan-vfs = { path = "../fparkan-vfs" }
+fparkan-world = { path = "../fparkan-world" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
new file mode 100644
index 0000000..2a05c4a
--- /dev/null
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -0,0 +1,1099 @@
+#![forbid(unsafe_code)]
+//! Runtime orchestration for headless and rendered modes.
+
+use fparkan_mission_format::{
+ decode_tma, decode_tma_land_path, LpString, MissionDocument, MissionError, TmaProfile,
+};
+use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
+use fparkan_prototype::{
+ build_prototype_graph_report, extend_graph_report_with_visual_dependencies, EffectivePrototype,
+ PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport,
+};
+use fparkan_resource::{resource_name, CachedResourceRepository};
+use fparkan_terrain::TerrainWorld;
+use fparkan_terrain_format::{
+ decode_build_dat, decode_land_map, decode_land_msh, BuildCategory, TerrainFormatError,
+};
+use fparkan_vfs::{Vfs, VfsError};
+use fparkan_world::{
+ construct_object, new as new_world, register_object, step, InputSnapshot, ObjectDraft,
+ OriginalObjectId, World, WorldConfig, WorldSnapshot,
+};
+use std::sync::Arc;
+
+/// Engine mode.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum EngineMode {
+ /// Headless.
+ Headless,
+ /// Rendered.
+ Rendered,
+}
+
+/// Scheduler phase.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum SchedulerPhase {
+ /// Collect platform events.
+ CollectPlatformEvents,
+ /// Build input snapshot.
+ BuildInputSnapshot,
+ /// Advance clock.
+ AdvanceGameClock,
+ /// Calculate world queue.
+ CalculateWorldQueue,
+ /// Apply deferred operations.
+ ApplyDeferredOperations,
+ /// Update animation/effects.
+ UpdateAnimationAndEffects,
+ /// Publish render snapshot.
+ PublishRenderSnapshot,
+ /// Render world.
+ RenderWorld,
+ /// End frame callbacks.
+ EndFrameCallbacks,
+ /// Maintenance.
+ Maintenance,
+}
+
+/// Engine config.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct EngineConfig {
+ /// Mode.
+ pub mode: EngineMode,
+}
+
+/// Injectable engine services used by composition roots.
+#[derive(Clone, Default)]
+pub struct EngineServices {
+ /// Resource filesystem.
+ pub vfs: Option<Arc<dyn Vfs>>,
+}
+
+impl EngineServices {
+ /// Creates services with a VFS.
+ #[must_use]
+ pub fn new(vfs: Arc<dyn Vfs>) -> Self {
+ Self { vfs: Some(vfs) }
+ }
+}
+
+/// Mission request.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MissionRequest {
+ /// Mission key/path.
+ pub key: String,
+}
+
+/// Mission loading phase captured for diagnostics and acceptance tests.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum MissionLoadPhase {
+ /// Resolve services and mission request context.
+ Context,
+ /// Decode and validate TMA.
+ Tma,
+ /// Decode and validate terrain map assets.
+ Map,
+ /// Expand object roots into a prototype graph.
+ Graph,
+ /// Prepare all reachable visual/resource dependencies.
+ Assets,
+ /// Construct all object drafts before registration.
+ Construct,
+ /// Register constructed objects.
+ Register,
+}
+
+/// Raw placed transform preserved by the mission loader.
+#[derive(Clone, Debug, PartialEq)]
+pub struct PlacedTransformProfile {
+ /// Object index in TMA order.
+ pub object_index: usize,
+ /// Raw position vector.
+ pub position: [f32; 3],
+ /// Raw orientation vector. No Euler order is inferred here.
+ pub orientation_raw: [f32; 3],
+ /// Raw scale vector.
+ pub scale: [f32; 3],
+}
+
+/// Mission loading trace.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct MissionLoadTrace {
+ /// Observed phases in execution order.
+ pub phases: Vec<MissionLoadPhase>,
+ /// Number of object drafts constructed before the first registration.
+ pub drafts_before_registration: usize,
+ /// Number of objects registered.
+ pub registered_objects: usize,
+ /// Raw transform profile for placed objects.
+ pub transforms: Vec<PlacedTransformProfile>,
+}
+
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+struct MissionLoadOptions {
+ fail_after_registered_objects: Option<usize>,
+}
+
+/// Loaded mission.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct LoadedMission {
+ /// Mission key.
+ pub key: String,
+ /// Decoded mission path count.
+ pub path_count: usize,
+ /// Decoded clan count.
+ pub clan_count: usize,
+ /// Decoded placed object count.
+ pub object_count: usize,
+ /// Decoded extra record count.
+ pub extra_count: usize,
+ /// `Land.msh` path.
+ pub land_msh_path: String,
+ /// `Land.map` path.
+ pub land_map_path: String,
+ /// Build category count.
+ pub build_category_count: usize,
+ /// Runtime navigation area count.
+ pub areal_count: usize,
+ /// Runtime surface triangle count.
+ pub surface_count: usize,
+ /// Registered world object count.
+ pub registered_objects: usize,
+ /// Mission resource roots that point to unit DAT files.
+ pub graph_unit_reference_count: usize,
+ /// Mission resource roots that point directly to prototype keys.
+ pub graph_direct_reference_count: usize,
+ /// Component records reached from unit DAT roots.
+ pub graph_unit_component_count: usize,
+ /// Mission prototype graph root count.
+ pub graph_root_count: usize,
+ /// Expanded prototype requests resolved to effective prototypes.
+ pub graph_resolved_count: usize,
+ /// Reached mesh dependency count.
+ pub graph_mesh_dependency_count: usize,
+ /// Graph failure count.
+ pub graph_failure_count: usize,
+ /// WEAR requests derived from graph meshes.
+ pub graph_wear_request_count: usize,
+ /// WEAR entries decoded.
+ pub graph_wear_resolved_count: usize,
+ /// WEAR material slots requested.
+ pub graph_material_slot_count: usize,
+ /// MAT0 entries decoded.
+ pub graph_material_resolved_count: usize,
+ /// Texture requests derived from MAT0 phases.
+ pub graph_texture_request_count: usize,
+ /// Texm texture entries decoded.
+ pub graph_texture_resolved_count: usize,
+ /// Lightmap requests declared by WEAR tables.
+ pub graph_lightmap_request_count: usize,
+ /// Lightmap Texm entries decoded.
+ pub graph_lightmap_resolved_count: usize,
+}
+
+/// Frame result.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct FrameResult {
+ /// Snapshot.
+ pub snapshot: WorldSnapshot,
+}
+
+/// Engine.
+pub struct Engine {
+ config: EngineConfig,
+ services: EngineServices,
+ world: World,
+ loaded: Option<LoadedMissionState>,
+}
+
+struct LoadedMissionState {
+ summary: LoadedMission,
+ mission: MissionDocument,
+ terrain: TerrainWorld,
+ build_categories: Vec<BuildCategory>,
+ prototype_graph: PrototypeGraph,
+ prototype_report: PrototypeGraphReport,
+ resolved_prototypes: Vec<EffectivePrototype>,
+}
+
+/// Engine error.
+#[derive(Debug)]
+pub enum EngineError {
+ /// Engine was created without a resource VFS.
+ MissingVfs,
+ /// Invalid resource path.
+ Path {
+ /// Path role.
+ role: &'static str,
+ /// Raw value.
+ value: String,
+ /// Source error.
+ source: PathError,
+ },
+ /// VFS error.
+ Vfs {
+ /// Resource path.
+ path: String,
+ /// Source error.
+ source: VfsError,
+ },
+ /// `NRes` decode error.
+ Nres {
+ /// Resource path.
+ path: String,
+ /// Source error.
+ source: fparkan_nres::NresError,
+ },
+ /// Mission decode error.
+ Mission {
+ /// Resource path.
+ path: String,
+ /// Source error.
+ source: MissionError,
+ },
+ /// Terrain disk format error.
+ TerrainFormat {
+ /// Resource path.
+ path: String,
+ /// Source error.
+ source: TerrainFormatError,
+ },
+ /// Terrain runtime build error.
+ Terrain(fparkan_terrain::TerrainError),
+ /// Prototype graph errors.
+ PrototypeGraph {
+ /// Root failures.
+ failures: Vec<PrototypeGraphFailure>,
+ },
+ /// World error.
+ World(fparkan_world::WorldError),
+ /// Staged mission world was torn down after a registration-phase failure.
+ RegistrationTeardown {
+ /// Registered objects before the forced failure.
+ registered_objects: usize,
+ /// Objects released by normal world shutdown.
+ released_objects: usize,
+ /// Managers were released after objects.
+ managers_released: bool,
+ },
+}
+
+impl From<fparkan_world::WorldError> for EngineError {
+ fn from(value: fparkan_world::WorldError) -> Self {
+ Self::World(value)
+ }
+}
+
+impl std::fmt::Display for EngineError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::MissingVfs => write!(f, "mission loading requires a VFS service"),
+ Self::Path {
+ role,
+ value,
+ source,
+ } => {
+ write!(f, "invalid {role} path '{value}': {source}")
+ }
+ Self::Vfs { path, source } => write!(f, "{path}: {source}"),
+ Self::Nres { path, source } => write!(f, "{path}: {source}"),
+ Self::Mission { path, source } => write!(f, "{path}: {source}"),
+ Self::TerrainFormat { path, source } => write!(f, "{path}: {source}"),
+ Self::Terrain(source) => write!(f, "{source}"),
+ Self::PrototypeGraph { failures } => {
+ write!(f, "mission prototype graph has {} failures", failures.len())
+ }
+ Self::World(source) => write!(f, "{source}"),
+ Self::RegistrationTeardown {
+ registered_objects,
+ released_objects,
+ managers_released,
+ } => write!(
+ f,
+ "mission registration failed after {registered_objects} objects; teardown released {released_objects}, managers_released={managers_released}"
+ ),
+ }
+ }
+}
+
+impl std::error::Error for EngineError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Path { source, .. } => Some(source),
+ Self::Vfs { source, .. } => Some(source),
+ Self::Nres { source, .. } => Some(source),
+ Self::Mission { source, .. } => Some(source),
+ Self::TerrainFormat { source, .. } => Some(source),
+ Self::Terrain(source) => Some(source),
+ Self::World(source) => Some(source),
+ Self::MissingVfs | Self::PrototypeGraph { .. } | Self::RegistrationTeardown { .. } => {
+ None
+ }
+ }
+ }
+}
+
+/// Creates engine.
+///
+/// # Errors
+///
+/// Currently this constructor is infallible, but it returns
+/// [`EngineError`] to keep the composition-root API stable as services become
+/// mandatory.
+pub fn create(config: EngineConfig, services: EngineServices) -> Result<Engine, EngineError> {
+ Ok(Engine {
+ config,
+ services,
+ world: new_world(WorldConfig),
+ loaded: None,
+ })
+}
+
+/// Loads mission transactionally.
+///
+/// # Errors
+///
+/// Returns [`EngineError`] when VFS services are missing, mission paths are
+/// invalid, required files cannot be read, disk formats fail validation, terrain
+/// runtime data cannot be built, prototype graph roots do not resolve, or
+/// object registration fails.
+pub fn load_mission(
+ engine: &mut Engine,
+ request: MissionRequest,
+) -> Result<LoadedMission, EngineError> {
+ load_mission_with_trace(engine, request).map(|(loaded, _trace)| loaded)
+}
+
+/// Loads mission transactionally and returns a diagnostic trace.
+///
+/// # Errors
+///
+/// Returns [`EngineError`] under the same conditions as [`load_mission`].
+pub fn load_mission_with_trace(
+ engine: &mut Engine,
+ request: MissionRequest,
+) -> Result<(LoadedMission, MissionLoadTrace), EngineError> {
+ load_mission_with_options(engine, request, MissionLoadOptions::default())
+}
+
+#[allow(clippy::too_many_lines)]
+fn load_mission_with_options(
+ engine: &mut Engine,
+ request: MissionRequest,
+ options: MissionLoadOptions,
+) -> Result<(LoadedMission, MissionLoadTrace), EngineError> {
+ let mut trace = MissionLoadTrace::default();
+ trace.phases.push(MissionLoadPhase::Context);
+ let vfs = engine.services.vfs.clone().ok_or(EngineError::MissingVfs)?;
+ let mission_path = normalize_engine_path("mission", &request.key)?;
+ let mission_bytes = read_vfs(&vfs, &mission_path)?;
+
+ trace.phases.push(MissionLoadPhase::Map);
+ let land_path = decode_tma_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| {
+ EngineError::Mission {
+ path: mission_path.as_str().to_string(),
+ source,
+ }
+ })?;
+ let (land_msh_path, land_map_path) = terrain_paths_from_land_path(&land_path)?;
+ let land_msh_nres = decode_nres(&vfs, &land_msh_path)?;
+ let land_map_nres = decode_nres(&vfs, &land_map_path)?;
+ let land_msh =
+ decode_land_msh(&land_msh_nres).map_err(|source| EngineError::TerrainFormat {
+ path: land_msh_path.as_str().to_string(),
+ source,
+ })?;
+ let land_map =
+ decode_land_map(&land_map_nres).map_err(|source| EngineError::TerrainFormat {
+ path: land_map_path.as_str().to_string(),
+ source,
+ })?;
+ let terrain =
+ TerrainWorld::from_land_assets(&land_msh, &land_map).map_err(EngineError::Terrain)?;
+
+ let build_dat_path = normalize_engine_path("BuildDat", "BuildDat.lst")?;
+ let build_dat = read_vfs(&vfs, &build_dat_path)?;
+ let build_categories =
+ decode_build_dat(&build_dat).map_err(|source| EngineError::TerrainFormat {
+ path: build_dat_path.as_str().to_string(),
+ source,
+ })?;
+ trace.phases.push(MissionLoadPhase::Tma);
+ let mission =
+ decode_tma(mission_bytes, TmaProfile::Strict).map_err(|source| EngineError::Mission {
+ path: mission_path.as_str().to_string(),
+ source,
+ })?;
+ let verified_terrain_paths = terrain_paths(&mission)?;
+ debug_assert_eq!(verified_terrain_paths.0.as_str(), land_msh_path.as_str());
+ debug_assert_eq!(verified_terrain_paths.1.as_str(), land_map_path.as_str());
+ trace.transforms = mission
+ .objects
+ .iter()
+ .enumerate()
+ .map(|(object_index, object)| PlacedTransformProfile {
+ object_index,
+ position: object.position,
+ orientation_raw: object.orientation,
+ scale: object.scale,
+ })
+ .collect();
+ trace.phases.push(MissionLoadPhase::Graph);
+ let repository = CachedResourceRepository::new(vfs.clone());
+ let graph_roots: Vec<_> = mission
+ .objects
+ .iter()
+ .map(|object| resource_name(&object.resource_name.raw))
+ .collect();
+ let (prototype_graph, resolved_prototypes, mut prototype_report) =
+ build_prototype_graph_report(&repository, vfs.as_ref(), &graph_roots);
+ extend_graph_report_with_visual_dependencies(
+ &repository,
+ &mut prototype_report,
+ &resolved_prototypes,
+ );
+ if !prototype_report.is_success() {
+ return Err(EngineError::PrototypeGraph {
+ failures: prototype_report.failures.clone(),
+ });
+ }
+ trace.phases.push(MissionLoadPhase::Assets);
+
+ let mut new_runtime_world = new_world(WorldConfig);
+ let mut handles = Vec::with_capacity(mission.objects.len());
+ trace.phases.push(MissionLoadPhase::Construct);
+ for (index, _object) in mission.objects.iter().enumerate() {
+ let original_id = u32::try_from(index).ok().map(OriginalObjectId);
+ let handle = construct_object(&mut new_runtime_world, ObjectDraft { original_id })?;
+ handles.push(handle);
+ }
+ trace.drafts_before_registration = handles.len();
+ trace.phases.push(MissionLoadPhase::Register);
+ for handle in &handles {
+ if options.fail_after_registered_objects == Some(trace.registered_objects) {
+ let report = fparkan_world::shutdown(new_runtime_world);
+ return Err(EngineError::RegistrationTeardown {
+ registered_objects: trace.registered_objects,
+ released_objects: report.released_objects.len(),
+ managers_released: report.managers_released,
+ });
+ }
+ register_object(&mut new_runtime_world, *handle)?;
+ trace.registered_objects += 1;
+ }
+
+ let summary = LoadedMission {
+ key: request.key,
+ path_count: mission.paths.len(),
+ clan_count: mission.clans.len(),
+ object_count: mission.objects.len(),
+ extra_count: mission.extras.len(),
+ land_msh_path: land_msh_path.as_str().to_string(),
+ land_map_path: land_map_path.as_str().to_string(),
+ build_category_count: build_categories.len(),
+ areal_count: terrain.areal_count(),
+ surface_count: terrain.surface_count(),
+ registered_objects: handles.len(),
+ graph_unit_reference_count: prototype_report.unit_reference_count,
+ graph_direct_reference_count: prototype_report.direct_reference_count,
+ graph_unit_component_count: prototype_report.unit_component_count,
+ graph_root_count: prototype_report.root_count,
+ graph_resolved_count: prototype_report.resolved_count,
+ graph_mesh_dependency_count: prototype_report.mesh_dependency_count,
+ graph_failure_count: prototype_report.failures.len(),
+ graph_wear_request_count: prototype_report.wear_request_count,
+ graph_wear_resolved_count: prototype_report.wear_resolved_count,
+ graph_material_slot_count: prototype_report.material_slot_count,
+ graph_material_resolved_count: prototype_report.material_resolved_count,
+ graph_texture_request_count: prototype_report.texture_request_count,
+ graph_texture_resolved_count: prototype_report.texture_resolved_count,
+ graph_lightmap_request_count: prototype_report.lightmap_request_count,
+ graph_lightmap_resolved_count: prototype_report.lightmap_resolved_count,
+ };
+
+ engine.world = new_runtime_world;
+ engine.loaded = Some(LoadedMissionState {
+ summary: summary.clone(),
+ mission,
+ terrain,
+ build_categories,
+ prototype_graph,
+ prototype_report,
+ resolved_prototypes,
+ });
+ Ok((summary, trace))
+}
+
+/// Steps headless mode.
+///
+/// # Errors
+///
+/// Returns [`EngineError`] when the world step fails.
+pub fn step_headless(
+ engine: &mut Engine,
+ input: InputSnapshot,
+) -> Result<FrameResult, EngineError> {
+ let snapshot = step(&mut engine.world, &input)?;
+ Ok(FrameResult { snapshot })
+}
+
+/// Steps rendered mode.
+///
+/// # Errors
+///
+/// Returns [`EngineError`] when the world step fails.
+pub fn frame(engine: &mut Engine) -> Result<FrameResult, EngineError> {
+ match engine.config.mode {
+ EngineMode::Headless | EngineMode::Rendered => step_headless(engine, InputSnapshot),
+ }
+}
+
+/// Shuts down engine.
+///
+/// # Errors
+///
+/// Currently shutdown is infallible, but the `Result` preserves the lifecycle
+/// API for future service teardown failures.
+pub fn shutdown(_engine: Engine) -> Result<(), EngineError> {
+ Ok(())
+}
+
+/// Returns the loaded mission summary.
+#[must_use]
+pub fn loaded_mission(engine: &Engine) -> Option<&LoadedMission> {
+ engine.loaded.as_ref().map(|state| &state.summary)
+}
+
+/// Returns the decoded mission document for the loaded mission.
+#[must_use]
+pub fn loaded_mission_document(engine: &Engine) -> Option<&MissionDocument> {
+ engine.loaded.as_ref().map(|state| &state.mission)
+}
+
+/// Returns terrain runtime data for the loaded mission.
+#[must_use]
+pub fn loaded_terrain(engine: &Engine) -> Option<&TerrainWorld> {
+ engine.loaded.as_ref().map(|state| &state.terrain)
+}
+
+/// Returns decoded build categories for the loaded game root.
+#[must_use]
+pub fn loaded_build_categories(engine: &Engine) -> Option<&[BuildCategory]> {
+ engine
+ .loaded
+ .as_ref()
+ .map(|state| state.build_categories.as_slice())
+}
+
+/// Returns the loaded prototype graph.
+#[must_use]
+pub fn loaded_prototype_graph(engine: &Engine) -> Option<&PrototypeGraph> {
+ engine.loaded.as_ref().map(|state| &state.prototype_graph)
+}
+
+/// Returns the loaded prototype graph report.
+#[must_use]
+pub fn loaded_prototype_graph_report(engine: &Engine) -> Option<&PrototypeGraphReport> {
+ engine.loaded.as_ref().map(|state| &state.prototype_report)
+}
+
+/// Returns resolved effective prototypes for the loaded mission.
+#[must_use]
+pub fn loaded_resolved_prototypes(engine: &Engine) -> Option<&[EffectivePrototype]> {
+ engine
+ .loaded
+ .as_ref()
+ .map(|state| state.resolved_prototypes.as_slice())
+}
+
+fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPath, EngineError> {
+ normalize_relative(value.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| {
+ EngineError::Path {
+ role,
+ value: value.to_string(),
+ source,
+ }
+ })
+}
+
+fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, EngineError> {
+ vfs.read(path).map_err(|source| EngineError::Vfs {
+ path: path.as_str().to_string(),
+ source,
+ })
+}
+
+fn decode_nres(
+ vfs: &Arc<dyn Vfs>,
+ path: &NormalizedPath,
+) -> Result<fparkan_nres::NresDocument, EngineError> {
+ let bytes = read_vfs(vfs, path)?;
+ fparkan_nres::decode(bytes, fparkan_nres::ReadProfile::Compatible).map_err(|source| {
+ EngineError::Nres {
+ path: path.as_str().to_string(),
+ source,
+ }
+ })
+}
+
+fn terrain_paths(
+ mission: &MissionDocument,
+) -> Result<(NormalizedPath, NormalizedPath), EngineError> {
+ terrain_paths_from_land_path(&mission.land_path)
+}
+
+fn terrain_paths_from_land_path(
+ land_path: &LpString,
+) -> Result<(NormalizedPath, NormalizedPath), EngineError> {
+ let land_path_raw = String::from_utf8_lossy(&land_path.raw).to_string();
+ let normalized =
+ normalize_relative(&land_path.raw, PathPolicy::StrictLegacy).map_err(|source| {
+ EngineError::Path {
+ role: "mission land",
+ value: land_path_raw.clone(),
+ source,
+ }
+ })?;
+ let Some((parent, _stem)) = normalized.as_str().rsplit_once('/') else {
+ return Err(EngineError::Path {
+ role: "mission land",
+ value: normalized.as_str().to_string(),
+ source: PathError::Empty,
+ });
+ };
+ let mesh = normalize_engine_path("Land.msh", &format!("{parent}/Land.msh"))?;
+ let map = normalize_engine_path("Land.map", &format!("{parent}/Land.map"))?;
+ Ok((mesh, map))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_vfs::{DirectoryVfs, VfsEntry, VfsMetadata};
+ use std::path::{Path, PathBuf};
+
+ #[test]
+ fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() {
+ let mut engine = create(
+ EngineConfig {
+ mode: EngineMode::Headless,
+ },
+ EngineServices::default(),
+ )
+ .expect("engine");
+ let before = step_headless(&mut engine, InputSnapshot).expect("step");
+ let err = load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".to_string(),
+ },
+ )
+ .expect_err("missing VFS");
+ assert!(matches!(err, EngineError::MissingVfs));
+ let after = step_headless(&mut engine, InputSnapshot).expect("step");
+ assert_eq!(before.snapshot.objects, after.snapshot.objects);
+ }
+
+ #[test]
+ fn load_trace_records_preparation_before_registration_and_raw_transforms() {
+ let root = workspace_root().join("testdata").join("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
+ let mut engine = create(
+ EngineConfig {
+ mode: EngineMode::Headless,
+ },
+ EngineServices::new(vfs),
+ )
+ .expect("engine");
+
+ let (loaded, trace) = load_mission_with_trace(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".to_string(),
+ },
+ )
+ .expect("load mission with trace");
+
+ assert_eq!(
+ trace.phases,
+ vec![
+ MissionLoadPhase::Context,
+ MissionLoadPhase::Map,
+ MissionLoadPhase::Tma,
+ MissionLoadPhase::Graph,
+ MissionLoadPhase::Assets,
+ MissionLoadPhase::Construct,
+ MissionLoadPhase::Register,
+ ]
+ );
+ assert_eq!(trace.drafts_before_registration, loaded.object_count);
+ assert_eq!(trace.registered_objects, loaded.object_count);
+ assert_eq!(trace.transforms.len(), loaded.object_count);
+ assert!(trace.transforms.iter().all(|transform| transform
+ .orientation_raw
+ .iter()
+ .all(|component| component.is_finite())));
+ }
+
+ #[test]
+ fn missing_map_and_missing_reachable_resource_fail_before_registration() {
+ let root = workspace_root().join("testdata").join("IS");
+ for (denied, mission) in [
+ (
+ DenyRule::Suffix("Land.map"),
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".to_string(),
+ },
+ ),
+ (
+ DenyRule::Suffix("objects.rlb"),
+ MissionRequest {
+ key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
+ },
+ ),
+ ] {
+ let vfs: Arc<dyn Vfs> = Arc::new(DenyVfs {
+ inner: DirectoryVfs::new(&root),
+ denied,
+ });
+ let mut engine = create(
+ EngineConfig {
+ mode: EngineMode::Headless,
+ },
+ EngineServices::new(vfs),
+ )
+ .expect("engine");
+ let before = step_headless(&mut engine, InputSnapshot).expect("before");
+ let err = load_mission(&mut engine, mission).expect_err("load error");
+ match denied {
+ DenyRule::Suffix("Land.map") => assert!(matches!(err, EngineError::Vfs { .. })),
+ DenyRule::Suffix("objects.rlb") => {
+ assert!(matches!(err, EngineError::PrototypeGraph { .. }))
+ }
+ DenyRule::Suffix(unexpected) => panic!("unexpected deny rule {unexpected}"),
+ }
+ assert!(loaded_mission(&engine).is_none());
+ let after = step_headless(&mut engine, InputSnapshot).expect("after");
+ assert_eq!(before.snapshot.objects, after.snapshot.objects);
+ }
+ }
+
+ #[test]
+ fn registration_phase_failure_uses_normal_teardown_and_keeps_engine_world() {
+ let root = workspace_root().join("testdata").join("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
+ let mut engine = create(
+ EngineConfig {
+ mode: EngineMode::Headless,
+ },
+ EngineServices::new(vfs),
+ )
+ .expect("engine");
+ let before = step_headless(&mut engine, InputSnapshot).expect("before");
+
+ let err = load_mission_with_options(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
+ },
+ MissionLoadOptions {
+ fail_after_registered_objects: Some(1),
+ },
+ )
+ .expect_err("forced registration failure");
+
+ assert!(matches!(
+ err,
+ EngineError::RegistrationTeardown {
+ registered_objects: 1,
+ released_objects: 1,
+ managers_released: true,
+ }
+ ));
+ assert!(loaded_mission(&engine).is_none());
+ let after = step_headless(&mut engine, InputSnapshot).expect("after");
+ assert_eq!(before.snapshot.objects, after.snapshot.objects);
+ }
+
+ #[test]
+ fn selected_is_and_is2_missions_execute_10000_deterministic_ticks() {
+ for case in [
+ HeadlessCase {
+ root: "IS",
+ mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma",
+ object_count: 33,
+ expected_hash: [
+ 0x19, 0xdc, 0xd3, 0x9b, 0x35, 0xad, 0x90, 0x6c, 0x92, 0x2d, 0x83, 0x7b, 0x7a,
+ 0xb3, 0xa6, 0x15, 0xa6, 0x15, 0x92, 0x2d, 0x83, 0x7b, 0x7a, 0xb3, 0xe9, 0xcd,
+ 0x9a, 0x56, 0x48, 0xb6, 0x0c, 0xee,
+ ],
+ },
+ HeadlessCase {
+ root: "IS2",
+ mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
+ object_count: 10,
+ expected_hash: [
+ 0x59, 0x6e, 0x88, 0xcc, 0xd0, 0x3a, 0xd9, 0x68, 0x1b, 0x2d, 0xcb, 0x0d, 0x91,
+ 0x19, 0x5a, 0x27, 0x5a, 0x27, 0x1b, 0x2d, 0xcb, 0x0d, 0x91, 0x19, 0x44, 0x66,
+ 0x68, 0x9d, 0x6c, 0xb4, 0x2c, 0x37,
+ ],
+ },
+ ] {
+ let first = run_headless_case(case);
+ let second = run_headless_case(case);
+ assert_eq!(first, second);
+ assert_eq!(first.tick.0, 10_000);
+ assert_eq!(first.objects.len(), case.object_count);
+ assert_eq!(first.hash.0, case.expected_hash);
+ }
+ }
+
+ #[test]
+ fn licensed_corpora_load_all_mission_foundations() {
+ let root = workspace_root();
+ let part1 = load_all(&root.join("testdata").join("IS"));
+ assert_eq!(part1.missions, 29);
+ assert_eq!(part1.paths, 34);
+ assert_eq!(part1.clans, 101);
+ assert_eq!(part1.objects, 864);
+ assert_eq!(part1.extras, 28);
+ assert_eq!(part1.unit_references, 463);
+ assert_eq!(part1.direct_references, 401);
+ assert_eq!(part1.unit_components, 4_300);
+ assert_eq!(part1.prototype_requests, 4_701);
+ assert_eq!(part1.material_slots, 36_954);
+ assert_eq!(part1.texture_requests, 48_806);
+ assert_eq!(part1.lightmap_requests, 139);
+ assert_eq!(part1.graph_failures, 0);
+ assert_eq!(part1.wear_requests, part1.prototype_requests);
+ assert_eq!(part1.wear_requests, part1.wear_resolved);
+ assert_eq!(part1.material_slots, part1.material_resolved);
+ assert_eq!(part1.texture_requests, part1.texture_resolved);
+ assert_eq!(part1.lightmap_requests, part1.lightmap_resolved);
+
+ let part2 = load_all(&root.join("testdata").join("IS2"));
+ assert_eq!(part2.missions, 31);
+ assert_eq!(part2.paths, 61);
+ assert_eq!(part2.clans, 91);
+ assert_eq!(part2.objects, 885);
+ assert_eq!(part2.extras, 41);
+ assert_eq!(part2.unit_references, 561);
+ assert_eq!(part2.direct_references, 324);
+ assert_eq!(part2.unit_components, 5_521);
+ assert_eq!(part2.prototype_requests, 5_845);
+ assert_eq!(part2.material_slots, 50_888);
+ assert_eq!(part2.texture_requests, 68_603);
+ assert_eq!(part2.lightmap_requests, 214);
+ assert_eq!(part2.graph_failures, 0);
+ assert_eq!(part2.wear_requests, part2.prototype_requests);
+ assert_eq!(part2.wear_requests, part2.wear_resolved);
+ assert_eq!(part2.material_slots, part2.material_resolved);
+ assert_eq!(part2.texture_requests, part2.texture_resolved);
+ assert_eq!(part2.lightmap_requests, part2.lightmap_resolved);
+ }
+
+ #[derive(Default)]
+ struct LoadTotals {
+ missions: usize,
+ paths: usize,
+ clans: usize,
+ objects: usize,
+ extras: usize,
+ unit_references: usize,
+ direct_references: usize,
+ unit_components: usize,
+ prototype_requests: usize,
+ wear_requests: usize,
+ wear_resolved: usize,
+ material_slots: usize,
+ material_resolved: usize,
+ texture_requests: usize,
+ texture_resolved: usize,
+ lightmap_requests: usize,
+ lightmap_resolved: usize,
+ graph_failures: usize,
+ }
+
+ #[derive(Clone, Copy)]
+ struct HeadlessCase {
+ root: &'static str,
+ mission: &'static str,
+ object_count: usize,
+ expected_hash: [u8; 32],
+ }
+
+ fn run_headless_case(case: HeadlessCase) -> WorldSnapshot {
+ let root = workspace_root().join("testdata").join(case.root);
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
+ let mut engine = create(
+ EngineConfig {
+ mode: EngineMode::Headless,
+ },
+ EngineServices::new(vfs),
+ )
+ .expect("engine");
+ let loaded = load_mission(
+ &mut engine,
+ MissionRequest {
+ key: case.mission.to_string(),
+ },
+ )
+ .expect("load selected mission");
+ assert_eq!(loaded.object_count, case.object_count);
+
+ let mut snapshot = None;
+ for _ in 0..10_000 {
+ snapshot = Some(
+ step_headless(&mut engine, InputSnapshot)
+ .expect("selected mission deterministic tick")
+ .snapshot,
+ );
+ }
+ snapshot.expect("at least one tick")
+ }
+
+ fn load_all(root: &Path) -> LoadTotals {
+ assert!(root.is_dir(), "missing licensed corpus {}", root.display());
+ let mut missions = mission_paths(root);
+ missions.sort();
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
+ let mut totals = LoadTotals::default();
+ for mission in missions {
+ let mut engine = create(
+ EngineConfig {
+ mode: EngineMode::Headless,
+ },
+ EngineServices::new(vfs.clone()),
+ )
+ .expect("engine");
+ let loaded = load_mission(&mut engine, MissionRequest { key: mission })
+ .expect("load mission foundation");
+ assert_eq!(loaded.object_count, loaded.registered_objects);
+ assert_eq!(loaded.object_count, loaded.graph_root_count);
+ assert_eq!(
+ loaded.graph_direct_reference_count + loaded.graph_unit_component_count,
+ loaded.graph_resolved_count
+ );
+ assert_eq!(loaded.graph_failure_count, 0);
+ assert_eq!(
+ loaded.graph_wear_request_count,
+ loaded.graph_wear_resolved_count
+ );
+ assert_eq!(
+ loaded.graph_material_slot_count,
+ loaded.graph_material_resolved_count
+ );
+ assert_eq!(
+ loaded.graph_texture_request_count,
+ loaded.graph_texture_resolved_count
+ );
+ assert_eq!(
+ loaded.graph_lightmap_request_count,
+ loaded.graph_lightmap_resolved_count
+ );
+ assert_eq!(loaded.build_category_count, 12);
+ assert!(loaded.areal_count > 0);
+ assert!(loaded.surface_count > 0);
+ totals.missions += 1;
+ totals.paths += loaded.path_count;
+ totals.clans += loaded.clan_count;
+ totals.objects += loaded.object_count;
+ totals.extras += loaded.extra_count;
+ totals.unit_references += loaded.graph_unit_reference_count;
+ totals.direct_references += loaded.graph_direct_reference_count;
+ totals.unit_components += loaded.graph_unit_component_count;
+ totals.prototype_requests += loaded.graph_resolved_count;
+ totals.wear_requests += loaded.graph_wear_request_count;
+ totals.wear_resolved += loaded.graph_wear_resolved_count;
+ totals.material_slots += loaded.graph_material_slot_count;
+ totals.material_resolved += loaded.graph_material_resolved_count;
+ totals.texture_requests += loaded.graph_texture_request_count;
+ totals.texture_resolved += loaded.graph_texture_resolved_count;
+ totals.lightmap_requests += loaded.graph_lightmap_request_count;
+ totals.lightmap_resolved += loaded.graph_lightmap_resolved_count;
+ totals.graph_failures += loaded.graph_failure_count;
+ }
+ totals
+ }
+
+ fn mission_paths(root: &Path) -> Vec<String> {
+ let mut out = Vec::new();
+ collect_missions(root, root, &mut out);
+ out
+ }
+
+ fn collect_missions(root: &Path, dir: &Path, out: &mut Vec<String>) {
+ let mut children: Vec<PathBuf> = std::fs::read_dir(dir)
+ .expect("read dir")
+ .map(|entry| entry.expect("entry").path())
+ .collect();
+ children.sort();
+ for child in children {
+ if child.is_dir() {
+ collect_missions(root, &child, out);
+ } else if child
+ .file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.eq_ignore_ascii_case("data.tma"))
+ {
+ let rel = child.strip_prefix(root).expect("relative");
+ let rel = rel.to_str().expect("utf8 path").replace('\\', "/");
+ out.push(rel);
+ }
+ }
+ }
+
+ fn workspace_root() -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .and_then(Path::parent)
+ .expect("workspace root")
+ .to_path_buf()
+ }
+
+ #[derive(Clone, Copy)]
+ enum DenyRule {
+ Suffix(&'static str),
+ }
+
+ struct DenyVfs {
+ inner: DirectoryVfs,
+ denied: DenyRule,
+ }
+
+ impl DenyVfs {
+ fn denied(&self, path: &NormalizedPath) -> bool {
+ match self.denied {
+ DenyRule::Suffix(suffix) => path
+ .as_str()
+ .to_ascii_uppercase()
+ .ends_with(&suffix.to_ascii_uppercase()),
+ }
+ }
+ }
+
+ impl Vfs for DenyVfs {
+ fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
+ if self.denied(path) {
+ return Err(VfsError::NotFound(path.as_str().to_string()));
+ }
+ self.inner.metadata(path)
+ }
+
+ fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
+ if self.denied(path) {
+ return Err(VfsError::NotFound(path.as_str().to_string()));
+ }
+ self.inner.read(path)
+ }
+
+ fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
+ self.inner.list(prefix).map(|entries| {
+ entries
+ .into_iter()
+ .filter(|entry| !self.denied(&entry.path))
+ .collect()
+ })
+ }
+ }
+}
diff --git a/crates/fparkan-terrain-format/Cargo.toml b/crates/fparkan-terrain-format/Cargo.toml
new file mode 100644
index 0000000..f23b357
--- /dev/null
+++ b/crates/fparkan-terrain-format/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "fparkan-terrain-format"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+fparkan-binary = { path = "../fparkan-binary" }
+fparkan-nres = { path = "../fparkan-nres" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-terrain-format/src/lib.rs b/crates/fparkan-terrain-format/src/lib.rs
new file mode 100644
index 0000000..8b97d79
--- /dev/null
+++ b/crates/fparkan-terrain-format/src/lib.rs
@@ -0,0 +1,1910 @@
+#![forbid(unsafe_code)]
+//! Terrain disk format primitives.
+
+use fparkan_binary::{checked_count_bytes, Cursor, DecodeError};
+use fparkan_nres::{EntryId, EntryMeta, NresDocument, NresError};
+
+const TYPE_AREAL_MAP: u32 = 12;
+const TYPE_NODES: u32 = 1;
+const TYPE_SLOTS: u32 = 2;
+const TYPE_POSITIONS: u32 = 3;
+const TYPE_NORMALS: u32 = 4;
+const TYPE_UV0: u32 = 5;
+const TYPE_ACCELERATOR: u32 = 11;
+const TYPE_AUX14: u32 = 14;
+const TYPE_AUX18: u32 = 18;
+const TYPE_FACES: u32 = 21;
+const REQUIRED_TYPES: [u32; 9] = [
+ TYPE_NODES,
+ TYPE_SLOTS,
+ TYPE_POSITIONS,
+ TYPE_NORMALS,
+ TYPE_UV0,
+ TYPE_AUX18,
+ TYPE_AUX14,
+ TYPE_ACCELERATOR,
+ TYPE_FACES,
+];
+const AREAL_PREFIX_SIZE: usize = 56;
+const SLOT_HEADER_SIZE: usize = 0x8c;
+const SLOT_STRIDE: usize = 68;
+const GRID_HIT_COUNT_BITS: u32 = 10;
+const GRID_POOL_OFFSET_MASK: u32 = (1 << 22) - 1;
+
+/// Full surface mask.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct FullSurfaceMask(pub u32);
+
+/// Compact surface mask.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct CompactSurfaceMask(pub u16);
+
+/// Material class mask.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct MaterialClassMask(pub u8);
+
+/// Terrain face with 28-byte source layout.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TerrainFace28 {
+ /// Full 32-bit surface mask/flags from bytes 0..4.
+ pub flags: FullSurfaceMask,
+ /// Opaque tag at bytes 4..6.
+ pub material_tag: u16,
+ /// Opaque tag at bytes 6..8.
+ pub aux_tag: u16,
+ /// Vertex indices at bytes 8..14.
+ pub vertices: [u16; 3],
+ /// Neighbor face indices at bytes 14..20.
+ pub neighbors: [Option<u16>; 3],
+ /// Preserved bytes 20..28.
+ pub tail_raw: [u8; 8],
+ /// Preserved raw bytes.
+ pub raw: [u8; 28],
+}
+
+/// Terrain stream descriptor.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TerrainStream {
+ /// Stream type id.
+ pub type_id: u32,
+ /// Entry attributes.
+ pub attributes: TerrainStreamAttributes,
+ /// Payload size.
+ pub size: u32,
+}
+
+/// Opaque stream attributes.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct TerrainStreamAttributes {
+ /// Attribute 1.
+ pub attr1: u32,
+ /// Attribute 2.
+ pub attr2: u32,
+ /// Attribute 3.
+ pub attr3: u32,
+}
+
+/// Slot table metadata.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TerrainSlotTable {
+ /// Raw 0x8c-byte header.
+ pub header_raw: Vec<u8>,
+ /// Slot records.
+ pub slots_raw: Vec<[u8; SLOT_STRIDE]>,
+}
+
+/// Land mesh document.
+#[derive(Clone, Debug, PartialEq)]
+pub struct LandMeshDocument {
+ /// Stream descriptors in archive order.
+ pub streams: Vec<TerrainStream>,
+ /// Raw node/slot mapping bytes.
+ pub nodes_raw: Vec<u8>,
+ /// Slot table.
+ pub slots: TerrainSlotTable,
+ /// Positions from type 3.
+ pub positions: Vec<[f32; 3]>,
+ /// Packed normals from type 4.
+ pub normals: Vec<[i8; 4]>,
+ /// Packed UV from type 5.
+ pub uv0: Vec<[i16; 2]>,
+ /// Type 11 accelerator words.
+ pub accelerator: Vec<[u8; 4]>,
+ /// Type 14 auxiliary words.
+ pub aux14: Vec<[u8; 4]>,
+ /// Type 18 auxiliary words.
+ pub aux18: Vec<[u8; 4]>,
+ /// Faces.
+ pub faces: Vec<TerrainFace28>,
+}
+
+/// Decoded `Land.map` document.
+#[derive(Clone, Debug, PartialEq)]
+pub struct LandMapDocument {
+ /// Type 12 entry attributes.
+ pub entry: TerrainStream,
+ /// Areal count declared by entry attribute 1.
+ pub areal_count: u32,
+ /// Decoded areals.
+ pub areals: Vec<Areal>,
+ /// Fast lookup grid.
+ pub grid: ArealGrid,
+}
+
+/// Logical terrain area.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Areal {
+ /// Preserved 56-byte prefix.
+ pub prefix_raw: [u8; AREAL_PREFIX_SIZE],
+ /// Anchor position.
+ pub anchor: [f32; 3],
+ /// Preserved float at prefix offset 12.
+ pub reserved_12: f32,
+ /// Area metric from the source file.
+ pub area_metric: f32,
+ /// Area normal.
+ pub normal: [f32; 3],
+ /// Logic flag.
+ pub logic_flag: u32,
+ /// Preserved integer at prefix offset 36.
+ pub reserved_36: u32,
+ /// Area class identifier.
+ pub class_id: u32,
+ /// Preserved integer at prefix offset 44.
+ pub reserved_44: u32,
+ /// Boundary vertices.
+ pub vertices: Vec<[f32; 3]>,
+ /// Edge and polygon links.
+ pub links: Vec<EdgeLink>,
+ /// Polygon payload blocks.
+ pub polygon_blocks: Vec<ArealPolygonBlock>,
+}
+
+/// Neighbor link for an areal edge or polygon slot.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct EdgeLink {
+ /// Raw signed area reference.
+ pub raw_area_ref: i32,
+ /// Raw signed edge reference.
+ pub raw_edge_ref: i32,
+ /// Referenced area, or `None` for `(-1, -1)`.
+ pub area_ref: Option<u32>,
+ /// Referenced edge/link slot in the target area, or `None` for `(-1, -1)`.
+ pub edge_ref: Option<u32>,
+}
+
+/// Preserved polygon block.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ArealPolygonBlock {
+ /// Leading `n` value.
+ pub n: u32,
+ /// Raw block following `n`.
+ pub body_raw: Vec<u8>,
+}
+
+/// Fast area lookup grid.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ArealGrid {
+ /// Number of cells on X axis.
+ pub cells_x: u32,
+ /// Number of cells on Y axis.
+ pub cells_y: u32,
+ /// Per-cell decoded candidates.
+ pub cells: Vec<ArealGridCell>,
+ /// Concatenated candidate pool used by compact lookup.
+ pub candidate_pool: Vec<u32>,
+ /// Per-cell compact descriptor: high 10 bits are hit count, low 22 bits are pool offset.
+ pub compact_cells: Vec<u32>,
+}
+
+/// Candidate list for one areal grid cell.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ArealGridCell {
+ /// Area identifiers referenced by this cell.
+ pub area_ids: Vec<u32>,
+}
+
+/// Build category from `BuildDat.lst`.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct BuildCategory {
+ /// Category name from the section header.
+ pub name: String,
+ /// Known category mask.
+ pub mask: u32,
+ /// Unit DAT paths listed in the section.
+ pub unit_paths: Vec<String>,
+}
+
+/// Terrain format error.
+#[derive(Debug)]
+pub enum TerrainFormatError {
+ /// Binary decode error.
+ Decode(DecodeError),
+ /// Nested `NRes` error.
+ Nres(NresError),
+ /// Invalid `Land.map` archive entry count.
+ InvalidLandMapEntryCount {
+ /// Observed entry count.
+ entry_count: usize,
+ },
+ /// Invalid `Land.map` entry type.
+ InvalidLandMapEntryType {
+ /// Observed type id.
+ type_id: u32,
+ },
+ /// Missing required stream.
+ MissingStream {
+ /// Stream type id.
+ type_id: u32,
+ },
+ /// Duplicate required stream.
+ DuplicateStream {
+ /// Stream type id.
+ type_id: u32,
+ },
+ /// Invalid stream stride.
+ InvalidStride {
+ /// Stream type id.
+ type_id: u32,
+ /// Observed stride.
+ stride: u32,
+ /// Expected stride.
+ expected: u32,
+ },
+ /// Invalid stream size.
+ InvalidSize {
+ /// Stream type id.
+ type_id: u32,
+ /// Observed size.
+ size: usize,
+ /// Expected stride or framing.
+ stride: usize,
+ },
+ /// Stream count does not match payload size.
+ CountMismatch {
+ /// Stream type id.
+ type_id: u32,
+ /// Attribute count.
+ attr_count: u32,
+ /// Payload-derived count.
+ payload_count: usize,
+ },
+ /// Invalid vertex.
+ InvalidVertexIndex {
+ /// Face index.
+ face: usize,
+ /// Vertex index.
+ vertex: u16,
+ /// Position count.
+ position_count: usize,
+ },
+ /// Invalid neighbor.
+ InvalidNeighborIndex {
+ /// Face index.
+ face: usize,
+ /// Neighbor index.
+ neighbor: u16,
+ /// Face count.
+ face_count: usize,
+ },
+ /// Invalid areal link.
+ InvalidArealLink {
+ /// Source area index.
+ area: usize,
+ /// Source link index.
+ link: usize,
+ /// Raw area reference.
+ area_ref: i32,
+ /// Raw edge reference.
+ edge_ref: i32,
+ },
+ /// Invalid grid dimensions.
+ InvalidGridSize {
+ /// Cells on X axis.
+ cells_x: u32,
+ /// Cells on Y axis.
+ cells_y: u32,
+ },
+ /// Invalid area reference in a grid cell.
+ InvalidGridAreaRef {
+ /// Linear cell index.
+ cell: usize,
+ /// Referenced area.
+ area_ref: u32,
+ /// Total area count.
+ area_count: usize,
+ },
+ /// Invalid `BuildDat.lst` text encoding.
+ InvalidBuildDatUtf8,
+ /// Invalid `BuildDat.lst` section structure.
+ InvalidBuildDatStructure {
+ /// One-based line number.
+ line: usize,
+ /// Reason.
+ reason: &'static str,
+ },
+ /// Unknown `BuildDat.lst` category name.
+ UnknownBuildCategory {
+ /// One-based line number.
+ line: usize,
+ /// Category name.
+ name: String,
+ },
+ /// Integer overflow.
+ IntegerOverflow,
+}
+
+impl From<DecodeError> for TerrainFormatError {
+ fn from(value: DecodeError) -> Self {
+ Self::Decode(value)
+ }
+}
+
+impl From<NresError> for TerrainFormatError {
+ fn from(value: NresError) -> Self {
+ Self::Nres(value)
+ }
+}
+
+impl std::fmt::Display for TerrainFormatError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Decode(source) => write!(f, "{source}"),
+ Self::Nres(source) => write!(f, "{source}"),
+ Self::InvalidLandMapEntryCount { entry_count } => {
+ write!(f, "Land.map must contain exactly one entry, got {entry_count}")
+ }
+ Self::InvalidLandMapEntryType { type_id } => {
+ write!(f, "Land.map entry type must be 12, got {type_id}")
+ }
+ Self::MissingStream { type_id } => write!(f, "missing Land.msh stream {type_id}"),
+ Self::DuplicateStream { type_id } => write!(f, "duplicate Land.msh stream {type_id}"),
+ Self::InvalidStride {
+ type_id,
+ stride,
+ expected,
+ } => write!(
+ f,
+ "invalid Land.msh stream {type_id} stride {stride}, expected {expected}"
+ ),
+ Self::InvalidSize {
+ type_id,
+ size,
+ stride,
+ } => write!(
+ f,
+ "invalid Land.msh stream {type_id} size {size}, stride/framing {stride}"
+ ),
+ Self::CountMismatch {
+ type_id,
+ attr_count,
+ payload_count,
+ } => write!(
+ f,
+ "Land.msh stream {type_id} count mismatch: attr={attr_count}, payload={payload_count}"
+ ),
+ Self::InvalidVertexIndex {
+ face,
+ vertex,
+ position_count,
+ } => write!(
+ f,
+ "Land.msh face {face} vertex {vertex} outside {position_count} positions"
+ ),
+ Self::InvalidNeighborIndex {
+ face,
+ neighbor,
+ face_count,
+ } => write!(
+ f,
+ "Land.msh face {face} neighbor {neighbor} outside {face_count} faces"
+ ),
+ Self::InvalidArealLink {
+ area,
+ link,
+ area_ref,
+ edge_ref,
+ } => write!(
+ f,
+ "Land.map area {area} link {link} has invalid reference ({area_ref}, {edge_ref})"
+ ),
+ Self::InvalidGridSize { cells_x, cells_y } => {
+ write!(f, "Land.map invalid grid size {cells_x}x{cells_y}")
+ }
+ Self::InvalidGridAreaRef {
+ cell,
+ area_ref,
+ area_count,
+ } => write!(
+ f,
+ "Land.map grid cell {cell} references area {area_ref} outside {area_count} areas"
+ ),
+ Self::InvalidBuildDatUtf8 => write!(f, "BuildDat.lst is not valid UTF-8/ASCII text"),
+ Self::InvalidBuildDatStructure { line, reason } => {
+ write!(f, "invalid BuildDat.lst structure at line {line}: {reason}")
+ }
+ Self::UnknownBuildCategory { line, name } => {
+ write!(f, "unknown BuildDat.lst category '{name}' at line {line}")
+ }
+ Self::IntegerOverflow => write!(f, "integer overflow"),
+ }
+ }
+}
+
+impl std::error::Error for TerrainFormatError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Decode(source) => Some(source),
+ Self::Nres(source) => Some(source),
+ Self::InvalidLandMapEntryCount { .. }
+ | Self::InvalidLandMapEntryType { .. }
+ | Self::MissingStream { .. }
+ | Self::DuplicateStream { .. }
+ | Self::InvalidStride { .. }
+ | Self::InvalidSize { .. }
+ | Self::CountMismatch { .. }
+ | Self::InvalidVertexIndex { .. }
+ | Self::InvalidNeighborIndex { .. }
+ | Self::InvalidArealLink { .. }
+ | Self::InvalidGridSize { .. }
+ | Self::InvalidGridAreaRef { .. }
+ | Self::InvalidBuildDatUtf8
+ | Self::InvalidBuildDatStructure { .. }
+ | Self::UnknownBuildCategory { .. }
+ | Self::IntegerOverflow => None,
+ }
+ }
+}
+
+/// Decodes a `Land.msh` `NRes` document.
+///
+/// # Errors
+///
+/// Returns [`TerrainFormatError`] when required streams are missing, stream
+/// strides/counts do not match, or face vertex/neighbor references are invalid.
+pub fn decode_land_msh(nres: &NresDocument) -> Result<LandMeshDocument, TerrainFormatError> {
+ for type_id in REQUIRED_TYPES {
+ require_single_stream(nres, type_id)?;
+ }
+
+ let nodes = stream_payload(nres, TYPE_NODES)?;
+ let slots = stream_payload(nres, TYPE_SLOTS)?;
+ let positions = stream_payload(nres, TYPE_POSITIONS)?;
+ let normals = stream_payload(nres, TYPE_NORMALS)?;
+ let uv0 = stream_payload(nres, TYPE_UV0)?;
+ let accelerator = stream_payload(nres, TYPE_ACCELERATOR)?;
+ let aux14 = stream_payload(nres, TYPE_AUX14)?;
+ let aux18 = stream_payload(nres, TYPE_AUX18)?;
+ let faces = stream_payload(nres, TYPE_FACES)?;
+
+ validate_stream(nres, TYPE_NODES, 38, nodes.len() / 38)?;
+ validate_slots(nres, slots)?;
+ let positions = parse_positions(nres, positions)?;
+ let normals = parse_i8x4_stream(nres, TYPE_NORMALS, normals)?;
+ let uv0 = parse_i16x2_stream(nres, TYPE_UV0, uv0)?;
+ let accelerator = parse_word_stream(nres, TYPE_ACCELERATOR, accelerator)?;
+ let aux14 = parse_word_stream(nres, TYPE_AUX14, aux14)?;
+ let aux18 = parse_word_stream(nres, TYPE_AUX18, aux18)?;
+ let faces = parse_faces(nres, faces)?;
+ validate_faces(&faces, positions.len())?;
+
+ Ok(LandMeshDocument {
+ streams: nres
+ .entries()
+ .iter()
+ .map(|entry| TerrainStream {
+ type_id: entry.meta().type_id,
+ attributes: attributes(entry.meta()),
+ size: entry.meta().data_size,
+ })
+ .collect(),
+ nodes_raw: nodes.to_vec(),
+ slots: parse_slot_table(slots),
+ positions,
+ normals,
+ uv0,
+ accelerator,
+ aux14,
+ aux18,
+ faces,
+ })
+}
+
+/// Decodes a `Land.map` `NRes` document.
+///
+/// # Errors
+///
+/// Returns [`TerrainFormatError`] when the archive does not contain exactly one
+/// type 12 entry, the payload framing is invalid, references are out of range,
+/// or the parser does not finish exactly at EOF.
+pub fn decode_land_map(nres: &NresDocument) -> Result<LandMapDocument, TerrainFormatError> {
+ if nres.entry_count() != 1 {
+ return Err(TerrainFormatError::InvalidLandMapEntryCount {
+ entry_count: nres.entry_count(),
+ });
+ }
+ let entry = &nres.entries()[0];
+ let meta = entry.meta();
+ if meta.type_id != TYPE_AREAL_MAP {
+ return Err(TerrainFormatError::InvalidLandMapEntryType {
+ type_id: meta.type_id,
+ });
+ }
+ let payload = nres.payload(entry.id())?;
+ let areal_count =
+ usize::try_from(meta.attr1).map_err(|_| TerrainFormatError::IntegerOverflow)?;
+ let mut cursor = Cursor::new(payload);
+ let mut areals = Vec::with_capacity(areal_count);
+ for area_index in 0..areal_count {
+ areals.push(parse_areal(&mut cursor, area_index)?);
+ }
+ validate_areal_links(&areals)?;
+ let grid = parse_areal_grid(&mut cursor, areals.len())?;
+ cursor.require_eof()?;
+
+ Ok(LandMapDocument {
+ entry: TerrainStream {
+ type_id: meta.type_id,
+ attributes: attributes(meta),
+ size: meta.data_size,
+ },
+ areal_count: meta.attr1,
+ areals,
+ grid,
+ })
+}
+
+/// Decodes `Build.dat`.
+///
+/// # Errors
+///
+/// Returns [`TerrainFormatError`] when the file contains malformed sections,
+/// unknown category names, invalid counts, or invalid quoted unit paths.
+pub fn decode_build_dat(bytes: &[u8]) -> Result<Vec<BuildCategory>, TerrainFormatError> {
+ let text = std::str::from_utf8(bytes).map_err(|_| TerrainFormatError::InvalidBuildDatUtf8)?;
+ let mut categories = Vec::new();
+ let mut iter = text.lines().enumerate().peekable();
+
+ while let Some((line_index, raw_line)) = iter.next() {
+ let line_no = line_index + 1;
+ let line = raw_line.trim();
+ if line.is_empty() || line.starts_with("//") {
+ continue;
+ }
+
+ let (name, count) = parse_build_header(line_no, line)?;
+ let mask =
+ build_category_mask(name).ok_or_else(|| TerrainFormatError::UnknownBuildCategory {
+ line: line_no,
+ name: name.to_string(),
+ })?;
+ let mut unit_paths = Vec::with_capacity(count);
+ for _ in 0..count {
+ let Some((path_line_index, path_line_raw)) = iter.next() else {
+ return Err(TerrainFormatError::InvalidBuildDatStructure {
+ line: line_no,
+ reason: "section ended before declared path count",
+ });
+ };
+ let path_line_no = path_line_index + 1;
+ let path_line = path_line_raw.trim();
+ unit_paths.push(parse_quoted_path(path_line_no, path_line)?);
+ }
+ categories.push(BuildCategory {
+ name: name.to_string(),
+ mask,
+ unit_paths,
+ });
+ }
+
+ Ok(categories)
+}
+
+/// Converts full mask to compact mask with explicit bit preservation policy.
+#[must_use]
+pub fn full_to_compact(mask: FullSurfaceMask) -> CompactSurfaceMask {
+ let mut compact = 0u16;
+ for (full_bit, compact_bit) in SURFACE_MASK_MAP {
+ if mask.0 & full_bit != 0 {
+ compact |= compact_bit;
+ }
+ }
+ CompactSurfaceMask(compact)
+}
+
+/// Converts compact mask to full mask.
+#[must_use]
+pub fn compact_to_full(mask: CompactSurfaceMask) -> FullSurfaceMask {
+ let mut full = 0u32;
+ for (full_bit, compact_bit) in SURFACE_MASK_MAP {
+ if mask.0 & compact_bit != 0 {
+ full |= full_bit;
+ }
+ }
+ FullSurfaceMask(full)
+}
+
+/// Converts full mask to compact material class mask.
+#[must_use]
+pub fn full_to_material_class(mask: FullSurfaceMask) -> MaterialClassMask {
+ let mut compact = 0u8;
+ for (full_bit, compact_bit) in MATERIAL_MASK_MAP {
+ if mask.0 & full_bit != 0 {
+ compact |= compact_bit;
+ }
+ }
+ MaterialClassMask(compact)
+}
+
+/// Validates face references.
+///
+/// # Errors
+///
+/// Returns [`TerrainFormatError`] when a face references a vertex or neighbor
+/// outside the decoded document.
+pub fn validate_faces(
+ faces: &[TerrainFace28],
+ vertex_count: usize,
+) -> Result<(), TerrainFormatError> {
+ for (face_index, face) in faces.iter().enumerate() {
+ for vertex in face.vertices {
+ if usize::from(vertex) >= vertex_count {
+ return Err(TerrainFormatError::InvalidVertexIndex {
+ face: face_index,
+ vertex,
+ position_count: vertex_count,
+ });
+ }
+ }
+ for neighbor in face.neighbors.iter().flatten() {
+ if usize::from(*neighbor) >= faces.len() {
+ return Err(TerrainFormatError::InvalidNeighborIndex {
+ face: face_index,
+ neighbor: *neighbor,
+ face_count: faces.len(),
+ });
+ }
+ }
+ }
+ Ok(())
+}
+
+const BUILD_CATEGORY_MASKS: &[(&str, u32)] = &[
+ ("Bunker_Small", 0x8001_0000),
+ ("Bunker_Medium", 0x8002_0000),
+ ("Bunker_Large", 0x8004_0000),
+ ("Generator", 0x8000_0002),
+ ("Mine", 0x8000_0004),
+ ("Storage", 0x8000_0008),
+ ("Plant", 0x8000_0010),
+ ("Hangar", 0x8000_0040),
+ ("MainTeleport", 0x8000_0200),
+ ("Institute", 0x8000_0400),
+ ("Tower_Medium", 0x8010_0000),
+ ("Tower_Large", 0x8020_0000),
+];
+
+const SURFACE_MASK_MAP: &[(u32, u16)] = &[
+ (0x0000_0001, 0x0001),
+ (0x0000_0008, 0x0002),
+ (0x0000_0010, 0x0004),
+ (0x0000_0020, 0x0008),
+ (0x0000_1000, 0x0010),
+ (0x0000_4000, 0x0020),
+ (0x0000_0002, 0x0040),
+ (0x0000_0400, 0x0080),
+ (0x0000_0800, 0x0100),
+ (0x0002_0000, 0x0200),
+ (0x0000_2000, 0x0400),
+ (0x0000_0200, 0x0800),
+ (0x0000_0004, 0x1000),
+ (0x0000_0040, 0x2000),
+ (0x0020_0000, 0x8000),
+];
+
+const MATERIAL_MASK_MAP: &[(u32, u8)] = &[
+ (0x0000_0100, 0x01),
+ (0x0000_8000, 0x02),
+ (0x0001_0000, 0x04),
+ (0x0004_0000, 0x08),
+ (0x0008_0000, 0x10),
+ (0x0000_0080, 0x20),
+];
+
+fn parse_build_header(line: usize, text: &str) -> Result<(&str, usize), TerrainFormatError> {
+ let mut parts = text.split_ascii_whitespace();
+ let name = parts
+ .next()
+ .ok_or(TerrainFormatError::InvalidBuildDatStructure {
+ line,
+ reason: "missing category name",
+ })?;
+ let count_raw = parts
+ .next()
+ .ok_or(TerrainFormatError::InvalidBuildDatStructure {
+ line,
+ reason: "missing category count",
+ })?;
+ if parts.next().is_some() {
+ return Err(TerrainFormatError::InvalidBuildDatStructure {
+ line,
+ reason: "extra fields in category header",
+ });
+ }
+ let count =
+ count_raw
+ .parse::<usize>()
+ .map_err(|_| TerrainFormatError::InvalidBuildDatStructure {
+ line,
+ reason: "invalid category count",
+ })?;
+ Ok((name, count))
+}
+
+fn parse_quoted_path(line: usize, text: &str) -> Result<String, TerrainFormatError> {
+ if text.len() < 2 || !text.starts_with('"') || !text.ends_with('"') {
+ return Err(TerrainFormatError::InvalidBuildDatStructure {
+ line,
+ reason: "unit path must be quoted",
+ });
+ }
+ let path = &text[1..text.len() - 1];
+ if path.is_empty() {
+ return Err(TerrainFormatError::InvalidBuildDatStructure {
+ line,
+ reason: "unit path must not be empty",
+ });
+ }
+ if !path.bytes().all(is_build_path_byte) {
+ return Err(TerrainFormatError::InvalidBuildDatStructure {
+ line,
+ reason: "unit path contains invalid byte",
+ });
+ }
+ Ok(path.to_string())
+}
+
+fn is_build_path_byte(byte: u8) -> bool {
+ byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'/' | b'\\' | b'-')
+}
+
+fn build_category_mask(name: &str) -> Option<u32> {
+ BUILD_CATEGORY_MASKS
+ .iter()
+ .find_map(|(category, mask)| (*category == name).then_some(*mask))
+}
+
+fn require_single_stream(nres: &NresDocument, type_id: u32) -> Result<EntryId, TerrainFormatError> {
+ let mut found = None;
+ for entry in nres
+ .entries()
+ .iter()
+ .filter(|entry| entry.meta().type_id == type_id)
+ {
+ if found.is_some() {
+ return Err(TerrainFormatError::DuplicateStream { type_id });
+ }
+ found = Some(entry.id());
+ }
+ found.ok_or(TerrainFormatError::MissingStream { type_id })
+}
+
+fn stream_payload(nres: &NresDocument, type_id: u32) -> Result<&[u8], TerrainFormatError> {
+ let id = require_single_stream(nres, type_id)?;
+ nres.payload(id).map_err(Into::into)
+}
+
+fn stream_meta(nres: &NresDocument, type_id: u32) -> Result<&EntryMeta, TerrainFormatError> {
+ let id = require_single_stream(nres, type_id)?;
+ nres.entry(id)
+ .map(fparkan_nres::NresEntry::meta)
+ .ok_or(TerrainFormatError::MissingStream { type_id })
+}
+
+fn validate_stream(
+ nres: &NresDocument,
+ type_id: u32,
+ stride: usize,
+ count: usize,
+) -> Result<(), TerrainFormatError> {
+ let meta = stream_meta(nres, type_id)?;
+ let expected = u32::try_from(stride).map_err(|_| TerrainFormatError::IntegerOverflow)?;
+ if meta.attr3 != expected {
+ return Err(TerrainFormatError::InvalidStride {
+ type_id,
+ stride: meta.attr3,
+ expected,
+ });
+ }
+ let attr_count =
+ usize::try_from(meta.attr1).map_err(|_| TerrainFormatError::IntegerOverflow)?;
+ if attr_count != count {
+ return Err(TerrainFormatError::CountMismatch {
+ type_id,
+ attr_count: meta.attr1,
+ payload_count: count,
+ });
+ }
+ Ok(())
+}
+
+fn validate_slots(nres: &NresDocument, payload: &[u8]) -> Result<(), TerrainFormatError> {
+ let meta = stream_meta(nres, TYPE_SLOTS)?;
+ if payload.len() < SLOT_HEADER_SIZE {
+ return Err(TerrainFormatError::InvalidSize {
+ type_id: TYPE_SLOTS,
+ size: payload.len(),
+ stride: SLOT_HEADER_SIZE,
+ });
+ }
+ let tail = payload.len() - SLOT_HEADER_SIZE;
+ if !tail.is_multiple_of(SLOT_STRIDE) {
+ return Err(TerrainFormatError::InvalidSize {
+ type_id: TYPE_SLOTS,
+ size: payload.len(),
+ stride: SLOT_STRIDE,
+ });
+ }
+ let slots = tail / SLOT_STRIDE;
+ let attr_count =
+ usize::try_from(meta.attr1).map_err(|_| TerrainFormatError::IntegerOverflow)?;
+ if attr_count != slots {
+ return Err(TerrainFormatError::CountMismatch {
+ type_id: TYPE_SLOTS,
+ attr_count: meta.attr1,
+ payload_count: slots,
+ });
+ }
+ Ok(())
+}
+
+fn parse_slot_table(payload: &[u8]) -> TerrainSlotTable {
+ let mut slots_raw = Vec::new();
+ for chunk in payload[SLOT_HEADER_SIZE..].chunks_exact(SLOT_STRIDE) {
+ let mut raw = [0; SLOT_STRIDE];
+ raw.copy_from_slice(chunk);
+ slots_raw.push(raw);
+ }
+ TerrainSlotTable {
+ header_raw: payload[..SLOT_HEADER_SIZE].to_vec(),
+ slots_raw,
+ }
+}
+
+fn parse_positions(
+ nres: &NresDocument,
+ payload: &[u8],
+) -> Result<Vec<[f32; 3]>, TerrainFormatError> {
+ if !payload.len().is_multiple_of(12) {
+ return Err(TerrainFormatError::InvalidSize {
+ type_id: TYPE_POSITIONS,
+ size: payload.len(),
+ stride: 12,
+ });
+ }
+ let count = payload.len() / 12;
+ validate_stream(nres, TYPE_POSITIONS, 12, count)?;
+ let mut out = Vec::with_capacity(count);
+ for chunk in payload.chunks_exact(12) {
+ out.push([
+ read_f32(chunk, 0)?,
+ read_f32(chunk, 4)?,
+ read_f32(chunk, 8)?,
+ ]);
+ }
+ Ok(out)
+}
+
+fn parse_i8x4_stream(
+ nres: &NresDocument,
+ type_id: u32,
+ payload: &[u8],
+) -> Result<Vec<[i8; 4]>, TerrainFormatError> {
+ if !payload.len().is_multiple_of(4) {
+ return Err(TerrainFormatError::InvalidSize {
+ type_id,
+ size: payload.len(),
+ stride: 4,
+ });
+ }
+ let count = payload.len() / 4;
+ validate_stream(nres, type_id, 4, count)?;
+ Ok(payload
+ .chunks_exact(4)
+ .map(|chunk| {
+ [
+ i8::from_le_bytes([chunk[0]]),
+ i8::from_le_bytes([chunk[1]]),
+ i8::from_le_bytes([chunk[2]]),
+ i8::from_le_bytes([chunk[3]]),
+ ]
+ })
+ .collect())
+}
+
+fn parse_i16x2_stream(
+ nres: &NresDocument,
+ type_id: u32,
+ payload: &[u8],
+) -> Result<Vec<[i16; 2]>, TerrainFormatError> {
+ if !payload.len().is_multiple_of(4) {
+ return Err(TerrainFormatError::InvalidSize {
+ type_id,
+ size: payload.len(),
+ stride: 4,
+ });
+ }
+ let count = payload.len() / 4;
+ validate_stream(nres, type_id, 4, count)?;
+ let mut out = Vec::with_capacity(count);
+ for chunk in payload.chunks_exact(4) {
+ out.push([read_i16(chunk, 0)?, read_i16(chunk, 2)?]);
+ }
+ Ok(out)
+}
+
+fn parse_word_stream(
+ nres: &NresDocument,
+ type_id: u32,
+ payload: &[u8],
+) -> Result<Vec<[u8; 4]>, TerrainFormatError> {
+ if !payload.len().is_multiple_of(4) {
+ return Err(TerrainFormatError::InvalidSize {
+ type_id,
+ size: payload.len(),
+ stride: 4,
+ });
+ }
+ let count = payload.len() / 4;
+ validate_stream(nres, type_id, 4, count)?;
+ Ok(payload
+ .chunks_exact(4)
+ .map(|chunk| [chunk[0], chunk[1], chunk[2], chunk[3]])
+ .collect())
+}
+
+fn parse_faces(
+ nres: &NresDocument,
+ payload: &[u8],
+) -> Result<Vec<TerrainFace28>, TerrainFormatError> {
+ if !payload.len().is_multiple_of(28) {
+ return Err(TerrainFormatError::InvalidSize {
+ type_id: TYPE_FACES,
+ size: payload.len(),
+ stride: 28,
+ });
+ }
+ let count = payload.len() / 28;
+ validate_stream(nres, TYPE_FACES, 28, count)?;
+ let mut out = Vec::with_capacity(count);
+ for chunk in payload.chunks_exact(28) {
+ let mut raw = [0; 28];
+ raw.copy_from_slice(chunk);
+ let mut tail_raw = [0; 8];
+ tail_raw.copy_from_slice(&chunk[20..28]);
+ out.push(TerrainFace28 {
+ flags: FullSurfaceMask(read_u32(chunk, 0)?),
+ material_tag: read_u16(chunk, 4)?,
+ aux_tag: read_u16(chunk, 6)?,
+ vertices: [
+ read_u16(chunk, 8)?,
+ read_u16(chunk, 10)?,
+ read_u16(chunk, 12)?,
+ ],
+ neighbors: [
+ neighbor(read_u16(chunk, 14)?),
+ neighbor(read_u16(chunk, 16)?),
+ neighbor(read_u16(chunk, 18)?),
+ ],
+ tail_raw,
+ raw,
+ });
+ }
+ Ok(out)
+}
+
+fn neighbor(raw: u16) -> Option<u16> {
+ (raw != u16::MAX).then_some(raw)
+}
+
+fn parse_areal(cursor: &mut Cursor<'_>, _area_index: usize) -> Result<Areal, TerrainFormatError> {
+ let prefix = cursor.read_exact(AREAL_PREFIX_SIZE)?;
+ let mut prefix_raw = [0; AREAL_PREFIX_SIZE];
+ prefix_raw.copy_from_slice(prefix);
+ let vertex_count = read_u32(prefix, 48)?;
+ let poly_count = read_u32(prefix, 52)?;
+ let vertices = parse_areal_vertices(cursor, vertex_count)?;
+ let link_count = vertex_count
+ .checked_add(
+ poly_count
+ .checked_mul(3)
+ .ok_or(TerrainFormatError::IntegerOverflow)?,
+ )
+ .ok_or(TerrainFormatError::IntegerOverflow)?;
+ let links = parse_edge_links(cursor, link_count)?;
+ let polygon_blocks = parse_polygon_blocks(cursor, poly_count)?;
+
+ Ok(Areal {
+ prefix_raw,
+ anchor: [
+ read_f32(prefix, 0)?,
+ read_f32(prefix, 4)?,
+ read_f32(prefix, 8)?,
+ ],
+ reserved_12: read_f32(prefix, 12)?,
+ area_metric: read_f32(prefix, 16)?,
+ normal: [
+ read_f32(prefix, 20)?,
+ read_f32(prefix, 24)?,
+ read_f32(prefix, 28)?,
+ ],
+ logic_flag: read_u32(prefix, 32)?,
+ reserved_36: read_u32(prefix, 36)?,
+ class_id: read_u32(prefix, 40)?,
+ reserved_44: read_u32(prefix, 44)?,
+ vertices,
+ links,
+ polygon_blocks,
+ })
+}
+
+fn parse_areal_vertices(
+ cursor: &mut Cursor<'_>,
+ vertex_count: u32,
+) -> Result<Vec<[f32; 3]>, TerrainFormatError> {
+ checked_count_bytes(u64::from(vertex_count), 12, cursor.remaining() as u64)?;
+ let count = usize::try_from(vertex_count).map_err(|_| TerrainFormatError::IntegerOverflow)?;
+ let mut vertices = Vec::with_capacity(count);
+ for _ in 0..count {
+ vertices.push([
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ ]);
+ }
+ Ok(vertices)
+}
+
+fn parse_edge_links(
+ cursor: &mut Cursor<'_>,
+ link_count: u32,
+) -> Result<Vec<EdgeLink>, TerrainFormatError> {
+ checked_count_bytes(u64::from(link_count), 8, cursor.remaining() as u64)?;
+ let count = usize::try_from(link_count).map_err(|_| TerrainFormatError::IntegerOverflow)?;
+ let mut links = Vec::with_capacity(count);
+ for _ in 0..count {
+ let raw_area_ref = cursor.read_i32_le()?;
+ let raw_edge_ref = cursor.read_i32_le()?;
+ let (area_ref, edge_ref) = match (raw_area_ref, raw_edge_ref) {
+ (-1, -1) => (None, None),
+ (area, edge) if area >= 0 && edge >= 0 => {
+ let area = u32::try_from(area).map_err(|_| TerrainFormatError::IntegerOverflow)?;
+ let edge = u32::try_from(edge).map_err(|_| TerrainFormatError::IntegerOverflow)?;
+ (Some(area), Some(edge))
+ }
+ _ => (None, None),
+ };
+ links.push(EdgeLink {
+ raw_area_ref,
+ raw_edge_ref,
+ area_ref,
+ edge_ref,
+ });
+ }
+ Ok(links)
+}
+
+fn parse_polygon_blocks(
+ cursor: &mut Cursor<'_>,
+ poly_count: u32,
+) -> Result<Vec<ArealPolygonBlock>, TerrainFormatError> {
+ let count = usize::try_from(poly_count).map_err(|_| TerrainFormatError::IntegerOverflow)?;
+ let mut blocks = Vec::with_capacity(count);
+ for _ in 0..count {
+ let n = cursor.read_u32_le()?;
+ let word_count = u64::from(n)
+ .checked_mul(3)
+ .and_then(|count| count.checked_add(1))
+ .ok_or(TerrainFormatError::IntegerOverflow)?;
+ let byte_count = checked_count_bytes(word_count, 4, cursor.remaining() as u64)?;
+ blocks.push(ArealPolygonBlock {
+ n,
+ body_raw: cursor.read_exact(byte_count)?.to_vec(),
+ });
+ }
+ Ok(blocks)
+}
+
+fn validate_areal_links(areals: &[Areal]) -> Result<(), TerrainFormatError> {
+ for (area_index, area) in areals.iter().enumerate() {
+ for (link_index, link) in area.links.iter().enumerate() {
+ match (link.area_ref, link.edge_ref) {
+ (None, None) if link.raw_area_ref == -1 && link.raw_edge_ref == -1 => {}
+ (Some(area_ref), Some(edge_ref)) => {
+ let Some(target) = usize::try_from(area_ref)
+ .ok()
+ .and_then(|index| areals.get(index))
+ else {
+ return Err(invalid_areal_link(area_index, link_index, link));
+ };
+ let edge_index = usize::try_from(edge_ref)
+ .map_err(|_| TerrainFormatError::IntegerOverflow)?;
+ if edge_index >= target.links.len() {
+ return Err(invalid_areal_link(area_index, link_index, link));
+ }
+ }
+ _ => return Err(invalid_areal_link(area_index, link_index, link)),
+ }
+ }
+ }
+ Ok(())
+}
+
+fn invalid_areal_link(area: usize, link: usize, edge_link: &EdgeLink) -> TerrainFormatError {
+ TerrainFormatError::InvalidArealLink {
+ area,
+ link,
+ area_ref: edge_link.raw_area_ref,
+ edge_ref: edge_link.raw_edge_ref,
+ }
+}
+
+fn parse_areal_grid(
+ cursor: &mut Cursor<'_>,
+ area_count: usize,
+) -> Result<ArealGrid, TerrainFormatError> {
+ let cells_x = cursor.read_u32_le()?;
+ let cells_y = cursor.read_u32_le()?;
+ let cell_count = cells_x
+ .checked_mul(cells_y)
+ .ok_or(TerrainFormatError::IntegerOverflow)?;
+ if cell_count == 0 {
+ return Err(TerrainFormatError::InvalidGridSize { cells_x, cells_y });
+ }
+ let cell_count_usize =
+ usize::try_from(cell_count).map_err(|_| TerrainFormatError::IntegerOverflow)?;
+ let mut cells = Vec::with_capacity(cell_count_usize);
+ let mut candidate_pool = Vec::new();
+ let mut compact_cells = Vec::with_capacity(cell_count_usize);
+ for cell_index in 0..cell_count_usize {
+ let hit_count = cursor.read_u16_le()?;
+ let pool_offset =
+ u32::try_from(candidate_pool.len()).map_err(|_| TerrainFormatError::IntegerOverflow)?;
+ if u32::from(hit_count) >= (1 << GRID_HIT_COUNT_BITS) || pool_offset > GRID_POOL_OFFSET_MASK
+ {
+ return Err(TerrainFormatError::IntegerOverflow);
+ }
+ let mut area_ids = Vec::with_capacity(usize::from(hit_count));
+ for _ in 0..hit_count {
+ let area_ref = u32::from(cursor.read_u16_le()?);
+ if usize::try_from(area_ref).map_or(true, |index| index >= area_count) {
+ return Err(TerrainFormatError::InvalidGridAreaRef {
+ cell: cell_index,
+ area_ref,
+ area_count,
+ });
+ }
+ area_ids.push(area_ref);
+ candidate_pool.push(area_ref);
+ }
+ compact_cells.push((u32::from(hit_count) << 22) | pool_offset);
+ cells.push(ArealGridCell { area_ids });
+ }
+ Ok(ArealGrid {
+ cells_x,
+ cells_y,
+ cells,
+ candidate_pool,
+ compact_cells,
+ })
+}
+
+fn attributes(meta: &EntryMeta) -> TerrainStreamAttributes {
+ TerrainStreamAttributes {
+ attr1: meta.attr1,
+ attr2: meta.attr2,
+ attr3: meta.attr3,
+ }
+}
+
+fn read_u16(bytes: &[u8], offset: usize) -> Result<u16, TerrainFormatError> {
+ let raw = bytes
+ .get(offset..offset + 2)
+ .ok_or(TerrainFormatError::IntegerOverflow)?;
+ Ok(u16::from_le_bytes([raw[0], raw[1]]))
+}
+
+fn read_i16(bytes: &[u8], offset: usize) -> Result<i16, TerrainFormatError> {
+ let raw = bytes
+ .get(offset..offset + 2)
+ .ok_or(TerrainFormatError::IntegerOverflow)?;
+ Ok(i16::from_le_bytes([raw[0], raw[1]]))
+}
+
+fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, TerrainFormatError> {
+ let raw = bytes
+ .get(offset..offset + 4)
+ .ok_or(TerrainFormatError::IntegerOverflow)?;
+ Ok(u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]))
+}
+
+fn read_f32(bytes: &[u8], offset: usize) -> Result<f32, TerrainFormatError> {
+ Ok(f32::from_bits(read_u32(bytes, offset)?))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_nres::ReadProfile;
+ use std::path::{Path, PathBuf};
+ use std::sync::Arc;
+
+ static SLOT_HEADER_ZERO: [u8; SLOT_HEADER_SIZE] = [0; SLOT_HEADER_SIZE];
+ static STREAM12_ZERO: [u8; 12] = [0; 12];
+
+ #[test]
+ fn decodes_minimal_land_msh() {
+ let nres =
+ decode_nres(&minimal_land_msh(&face([0, 1, 2], [None, None, None]))).expect("nres");
+ let document = decode_land_msh(&nres).expect("land mesh");
+
+ assert_eq!(document.positions.len(), 3);
+ assert_eq!(document.faces.len(), 1);
+ assert_eq!(document.faces[0].vertices, [0, 1, 2]);
+ assert_eq!(document.faces[0].neighbors, [None, None, None]);
+ }
+
+ #[test]
+ fn land_msh_required_streams_are_order_independent_and_stride_checked() {
+ let face = face([0, 1, 2], [None, None, None]);
+ let positions = minimal_positions_payload();
+ let entries = minimal_land_msh_entries(&face, &positions);
+ let shuffled = [
+ entries[8], entries[2], entries[0], entries[7], entries[4], entries[3], entries[6],
+ entries[5], entries[1],
+ ];
+ let nres = decode_nres(&build_nres(&shuffled)).expect("nres");
+ let document = decode_land_msh(&nres).expect("land mesh");
+ assert_eq!(document.positions.len(), 3);
+ assert_eq!(
+ document
+ .streams
+ .iter()
+ .map(|stream| stream.type_id)
+ .collect::<Vec<_>>(),
+ vec![
+ TYPE_FACES,
+ TYPE_POSITIONS,
+ TYPE_NODES,
+ TYPE_ACCELERATOR,
+ TYPE_UV0,
+ TYPE_NORMALS,
+ TYPE_AUX14,
+ TYPE_AUX18,
+ TYPE_SLOTS,
+ ]
+ );
+
+ let bad_stride = [
+ entries[0],
+ entries[1],
+ entries[2],
+ entry(TYPE_NORMALS, 3, 8, &[0; 12]),
+ entries[4],
+ entries[5],
+ entries[6],
+ entries[7],
+ entries[8],
+ ];
+ let nres = decode_nres(&build_nres(&bad_stride)).expect("nres");
+ assert!(matches!(
+ decode_land_msh(&nres),
+ Err(TerrainFormatError::InvalidStride {
+ type_id: TYPE_NORMALS,
+ ..
+ })
+ ));
+ }
+
+ #[test]
+ fn rejects_invalid_vertex_index() {
+ let nres =
+ decode_nres(&minimal_land_msh(&face([0, 1, 3], [None, None, None]))).expect("nres");
+ let err = decode_land_msh(&nres).expect_err("invalid vertex");
+
+ assert!(matches!(
+ err,
+ TerrainFormatError::InvalidVertexIndex { vertex: 3, .. }
+ ));
+ }
+
+ #[test]
+ fn rejects_invalid_neighbor_index() {
+ let nres =
+ decode_nres(&minimal_land_msh(&face([0, 1, 2], [Some(1), None, None]))).expect("nres");
+ let err = decode_land_msh(&nres).expect_err("invalid neighbor");
+
+ assert!(matches!(
+ err,
+ TerrainFormatError::InvalidNeighborIndex { neighbor: 1, .. }
+ ));
+ }
+
+ #[test]
+ fn face_layout_preserves_tail_and_all_surface_mask_mappings_are_explicit() {
+ let mut raw_face = face([0, 1, 2], [None, None, None]);
+ raw_face[20..28].copy_from_slice(b"UNKNOWN!");
+ let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
+ let document = decode_land_msh(&nres).expect("land mesh");
+ assert_eq!(document.faces[0].tail_raw, *b"UNKNOWN!");
+ assert_eq!(document.faces[0].raw, raw_face);
+
+ for (full, compact) in SURFACE_MASK_MAP {
+ assert_eq!(
+ full_to_compact(FullSurfaceMask(*full)),
+ CompactSurfaceMask(*compact)
+ );
+ assert_eq!(
+ compact_to_full(CompactSurfaceMask(*compact)),
+ FullSurfaceMask(*full)
+ );
+ }
+ assert_eq!(
+ full_to_compact(FullSurfaceMask(0x0000_0008)),
+ CompactSurfaceMask(0x0002)
+ );
+ assert_eq!(
+ full_to_compact(FullSurfaceMask(0x0020_0000)),
+ CompactSurfaceMask(0x8000)
+ );
+ assert_eq!(
+ compact_to_full(CompactSurfaceMask(0x8000)),
+ FullSurfaceMask(0x0020_0000)
+ );
+ assert_eq!(
+ full_to_material_class(FullSurfaceMask(0x0000_8000 | 0x0000_0080)),
+ MaterialClassMask(0x22)
+ );
+ }
+
+ #[test]
+ fn decodes_minimal_land_map() {
+ let nres = decode_nres(&minimal_land_map([(-1, -1), (-1, -1)], 0)).expect("nres");
+ let document = decode_land_map(&nres).expect("land map");
+
+ assert_eq!(document.areal_count, 1);
+ assert_eq!(document.areals.len(), 1);
+ assert_eq!(document.areals[0].vertices.len(), 2);
+ assert_eq!(document.areals[0].links.len(), 2);
+ assert_eq!(document.grid.cells_x, 1);
+ assert_eq!(document.grid.cells_y, 1);
+ assert_eq!(document.grid.cells[0].area_ids, [0]);
+ assert_eq!(document.grid.compact_cells, [0x0040_0000]);
+ }
+
+ #[test]
+ fn land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof() {
+ let nres = decode_nres(&minimal_land_map_with_poly(1, true)).expect("nres");
+ let document = decode_land_map(&nres).expect("land map");
+ assert_eq!(document.areals[0].prefix_raw.len(), AREAL_PREFIX_SIZE);
+ assert_eq!(document.areals[0].anchor, [0.0, 0.0, 0.0]);
+ assert_eq!(document.areals[0].area_metric, 2.0);
+ assert_eq!(document.areals[0].links[0].area_ref, None);
+ assert_eq!(document.areals[0].polygon_blocks.len(), 1);
+ assert_eq!(document.areals[0].links.len(), 5);
+ assert_eq!(document.grid.cells_x, 1);
+ assert_eq!(document.grid.cells_y, 1);
+
+ let nres = decode_nres(&minimal_land_map_with_vertex_count(3)).expect("nres");
+ assert!(decode_land_map(&nres).is_err());
+
+ let nres = decode_nres(&minimal_land_map_with_poly(1_000_000, true)).expect("nres");
+ assert!(decode_land_map(&nres).is_err());
+
+ let nres = decode_nres(&minimal_land_map_with_poly(0, false)).expect("nres");
+ assert!(matches!(
+ decode_land_map(&nres),
+ Err(TerrainFormatError::InvalidGridSize { cells_x: 0, .. })
+ ));
+
+ let nres = decode_nres(&minimal_land_map_with_payload_tail()).expect("nres");
+ assert!(decode_land_map(&nres).is_err());
+ }
+
+ #[test]
+ fn rejects_invalid_areal_link() {
+ let nres = decode_nres(&minimal_land_map([(1, 0), (-1, -1)], 0)).expect("nres");
+ let err = decode_land_map(&nres).expect_err("invalid link");
+
+ assert!(matches!(
+ err,
+ TerrainFormatError::InvalidArealLink {
+ area: 0,
+ link: 0,
+ area_ref: 1,
+ edge_ref: 0
+ }
+ ));
+ }
+
+ #[test]
+ fn rejects_invalid_grid_area_ref() {
+ let nres = decode_nres(&minimal_land_map([(-1, -1), (-1, -1)], 1)).expect("nres");
+ let err = decode_land_map(&nres).expect_err("invalid grid");
+
+ assert!(matches!(
+ err,
+ TerrainFormatError::InvalidGridAreaRef {
+ cell: 0,
+ area_ref: 1,
+ area_count: 1
+ }
+ ));
+ }
+
+ #[test]
+ fn decodes_synthetic_build_dat() {
+ let bytes = br#"
+// comment
+Bunker_Small 2
+ "UNITS\BUILDS\BUNKER\sbunk01.dat"
+ "UNITS\BUILDS\BUNKER\sbunk02.dat"
+Generator 1
+ "UNITS\BUILDS\GENER\gener01.dat"
+"#;
+ let categories = decode_build_dat(bytes).expect("BuildDat");
+
+ assert_eq!(categories.len(), 2);
+ assert_eq!(categories[0].name, "Bunker_Small");
+ assert_eq!(categories[0].mask, 0x8001_0000);
+ assert_eq!(categories[0].unit_paths.len(), 2);
+ assert_eq!(categories[1].name, "Generator");
+ assert_eq!(categories[1].mask, 0x8000_0002);
+ }
+
+ #[test]
+ fn rejects_unknown_build_category() {
+ let err = decode_build_dat(br#"Unknown 0"#).expect_err("unknown category");
+
+ assert!(matches!(
+ err,
+ TerrainFormatError::UnknownBuildCategory { line: 1, .. }
+ ));
+ }
+
+ #[test]
+ fn rejects_build_category_count_mismatch() {
+ let err = decode_build_dat(
+ br#"Bunker_Small 2
+ "UNITS\BUILDS\BUNKER\sbunk01.dat"
+"#,
+ )
+ .expect_err("count mismatch");
+
+ assert!(matches!(
+ err,
+ TerrainFormatError::InvalidBuildDatStructure { line: 1, .. }
+ ));
+ }
+
+ #[test]
+ fn licensed_corpus_land_msh_validate() {
+ for (corpus, expected_files, expected_vertices, expected_faces) in [
+ ("IS", 33_usize, 299_450_usize, 275_882_usize),
+ ("IS2", 32_usize, 188_024_usize, 184_454_usize),
+ ] {
+ let Some(root) = corpus_root(corpus) else {
+ continue;
+ };
+ let mut files = 0usize;
+ let mut vertices = 0usize;
+ let mut faces = 0usize;
+ for path in files_under(&root) {
+ if !path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.eq_ignore_ascii_case("Land.msh"))
+ {
+ continue;
+ }
+ let bytes = std::fs::read(&path).expect("read Land.msh");
+ let nres = fparkan_nres::decode(
+ Arc::from(bytes.into_boxed_slice()),
+ ReadProfile::Compatible,
+ )
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ let document =
+ decode_land_msh(&nres).unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ files += 1;
+ vertices += document.positions.len();
+ faces += document.faces.len();
+ assert_eq!(
+ document
+ .streams
+ .iter()
+ .map(|stream| stream.type_id)
+ .collect::<Vec<_>>(),
+ REQUIRED_TYPES,
+ "{corpus} {path:?} stream order"
+ );
+ }
+
+ assert_eq!(files, expected_files, "{corpus} Land.msh count");
+ assert_eq!(vertices, expected_vertices, "{corpus} vertex count");
+ assert_eq!(faces, expected_faces, "{corpus} face count");
+ }
+ }
+
+ #[test]
+ fn licensed_corpus_build_dat_validate() {
+ for (corpus, expected_ai_prefix) in [("IS", false), ("IS2", true)] {
+ let Some(root) = corpus_root(corpus) else {
+ continue;
+ };
+ let path = root.join("BuildDat.lst");
+ let bytes = std::fs::read(&path).expect("read BuildDat.lst");
+ let categories =
+ decode_build_dat(&bytes).unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+
+ assert_eq!(categories.len(), BUILD_CATEGORY_MASKS.len(), "{corpus}");
+ assert_eq!(
+ categories
+ .iter()
+ .map(|category| (category.name.as_str(), category.mask))
+ .collect::<Vec<_>>(),
+ BUILD_CATEGORY_MASKS,
+ "{corpus} category order/masks"
+ );
+ assert_eq!(
+ categories
+ .iter()
+ .map(|category| category.unit_paths.len())
+ .sum::<usize>(),
+ 32,
+ "{corpus} unit path count"
+ );
+ assert!(
+ categories
+ .iter()
+ .all(
+ |category| category.unit_paths.iter().all(|path| path.starts_with(
+ if expected_ai_prefix {
+ "UNITS\\BUILDS\\AI\\"
+ } else {
+ "UNITS\\BUILDS\\"
+ }
+ ) && path
+ .to_ascii_lowercase()
+ .ends_with(".dat"))
+ ),
+ "{corpus} unit path prefixes"
+ );
+ }
+ }
+
+ #[test]
+ fn licensed_corpus_land_map_validate() {
+ for (corpus, expected_files, expected_areals, expected_vertices, expected_max_hits) in [
+ ("IS", 33_usize, 34_662_usize, 197_698_usize, 20_usize),
+ ("IS2", 32_usize, 18_984_usize, 114_968_usize, 14_usize),
+ ] {
+ let Some(root) = corpus_root(corpus) else {
+ continue;
+ };
+ let mut files = 0usize;
+ let mut areals = 0usize;
+ let mut vertices = 0usize;
+ let mut max_hits = 0usize;
+ for path in files_under(&root) {
+ if !path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.eq_ignore_ascii_case("Land.map"))
+ {
+ continue;
+ }
+ let bytes = std::fs::read(&path).expect("read Land.map");
+ let nres = fparkan_nres::decode(
+ Arc::from(bytes.into_boxed_slice()),
+ ReadProfile::Compatible,
+ )
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ let document =
+ decode_land_map(&nres).unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ files += 1;
+ areals += document.areals.len();
+ vertices += document
+ .areals
+ .iter()
+ .map(|area| area.vertices.len())
+ .sum::<usize>();
+ max_hits = max_hits.max(
+ document
+ .grid
+ .cells
+ .iter()
+ .map(|cell| cell.area_ids.len())
+ .max()
+ .unwrap_or(0),
+ );
+ assert_eq!(document.grid.cells_x, 128, "{corpus} {path:?} cells_x");
+ assert_eq!(document.grid.cells_y, 128, "{corpus} {path:?} cells_y");
+ assert!(
+ document
+ .areals
+ .iter()
+ .all(|area| area.polygon_blocks.is_empty()),
+ "{corpus} {path:?} polygon blocks"
+ );
+ }
+
+ assert_eq!(files, expected_files, "{corpus} Land.map count");
+ assert_eq!(areals, expected_areals, "{corpus} areal count");
+ assert_eq!(vertices, expected_vertices, "{corpus} areal vertex count");
+ assert_eq!(max_hits, expected_max_hits, "{corpus} max grid hits");
+ }
+ }
+
+ fn decode_nres(bytes: &[u8]) -> Result<NresDocument, fparkan_nres::NresError> {
+ fparkan_nres::decode(
+ Arc::from(bytes.to_vec().into_boxed_slice()),
+ ReadProfile::Compatible,
+ )
+ }
+
+ fn minimal_land_msh(face: &[u8; 28]) -> Vec<u8> {
+ let positions = minimal_positions_payload();
+ build_nres(&minimal_land_msh_entries(face, &positions))
+ }
+
+ fn minimal_positions_payload() -> Vec<u8> {
+ [
+ 0.0_f32.to_le_bytes(),
+ 0.0_f32.to_le_bytes(),
+ 0.0_f32.to_le_bytes(),
+ 1.0_f32.to_le_bytes(),
+ 0.0_f32.to_le_bytes(),
+ 0.0_f32.to_le_bytes(),
+ 0.0_f32.to_le_bytes(),
+ 1.0_f32.to_le_bytes(),
+ 0.0_f32.to_le_bytes(),
+ ]
+ .concat()
+ }
+
+ fn minimal_land_msh_entries<'a>(face: &'a [u8; 28], positions: &'a [u8]) -> [TestEntry<'a>; 9] {
+ [
+ entry(TYPE_NODES, 0, 38, &[]),
+ entry(TYPE_SLOTS, 0, 0, &SLOT_HEADER_ZERO),
+ entry(TYPE_POSITIONS, 3, 12, positions),
+ entry(TYPE_NORMALS, 3, 4, &STREAM12_ZERO),
+ entry(TYPE_UV0, 3, 4, &STREAM12_ZERO),
+ entry(TYPE_AUX18, 0, 4, &[]),
+ entry(TYPE_AUX14, 0, 4, &[]),
+ entry(TYPE_ACCELERATOR, 0, 4, &[]),
+ entry(TYPE_FACES, 1, 28, face),
+ ]
+ }
+
+ fn minimal_land_map(links: [(i32, i32); 2], grid_area_ref: u16) -> Vec<u8> {
+ let mut payload = Vec::new();
+ push_f32(&mut payload, 0.0);
+ push_f32(&mut payload, 0.0);
+ push_f32(&mut payload, 0.0);
+ push_f32(&mut payload, 0.0);
+ push_f32(&mut payload, 2.0);
+ push_f32(&mut payload, 0.0);
+ push_f32(&mut payload, 1.0);
+ push_f32(&mut payload, 0.0);
+ push_u32(&mut payload, 0);
+ push_u32(&mut payload, 0);
+ push_u32(&mut payload, 7);
+ push_u32(&mut payload, 0);
+ push_u32(&mut payload, 2);
+ push_u32(&mut payload, 0);
+ push_f32(&mut payload, 0.0);
+ push_f32(&mut payload, 0.0);
+ push_f32(&mut payload, 0.0);
+ push_f32(&mut payload, 1.0);
+ push_f32(&mut payload, 0.0);
+ push_f32(&mut payload, 0.0);
+ for (area_ref, edge_ref) in links {
+ push_i32(&mut payload, area_ref);
+ push_i32(&mut payload, edge_ref);
+ }
+ push_u32(&mut payload, 1);
+ push_u32(&mut payload, 1);
+ push_u16(&mut payload, 1);
+ push_u16(&mut payload, grid_area_ref);
+ build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)])
+ }
+
+ fn minimal_land_map_with_poly(poly_n: u32, valid_grid: bool) -> Vec<u8> {
+ let mut payload = Vec::new();
+ push_areal_prefix(&mut payload, 2, 1);
+ push_f32(&mut payload, 0.0);
+ push_f32(&mut payload, 0.0);
+ push_f32(&mut payload, 0.0);
+ push_f32(&mut payload, 1.0);
+ push_f32(&mut payload, 0.0);
+ push_f32(&mut payload, 0.0);
+ for _ in 0..5 {
+ push_i32(&mut payload, -1);
+ push_i32(&mut payload, -1);
+ }
+ push_u32(&mut payload, poly_n);
+ match poly_n {
+ 0 => payload.extend_from_slice(&[0; 4]),
+ 1 => payload.extend_from_slice(&[0; 16]),
+ _ => {}
+ }
+ if valid_grid {
+ push_u32(&mut payload, 1);
+ push_u32(&mut payload, 1);
+ push_u16(&mut payload, 1);
+ push_u16(&mut payload, 0);
+ } else {
+ push_u32(&mut payload, 0);
+ push_u32(&mut payload, 1);
+ }
+ build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)])
+ }
+
+ fn minimal_land_map_with_vertex_count(vertex_count: u32) -> Vec<u8> {
+ let mut payload = Vec::new();
+ push_areal_prefix(&mut payload, vertex_count, 0);
+ build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)])
+ }
+
+ fn minimal_land_map_with_payload_tail() -> Vec<u8> {
+ let mut payload = Vec::new();
+ push_areal_prefix(&mut payload, 2, 0);
+ push_f32(&mut payload, 0.0);
+ push_f32(&mut payload, 0.0);
+ push_f32(&mut payload, 0.0);
+ push_f32(&mut payload, 1.0);
+ push_f32(&mut payload, 0.0);
+ push_f32(&mut payload, 0.0);
+ for _ in 0..2 {
+ push_i32(&mut payload, -1);
+ push_i32(&mut payload, -1);
+ }
+ push_u32(&mut payload, 1);
+ push_u32(&mut payload, 1);
+ push_u16(&mut payload, 1);
+ push_u16(&mut payload, 0);
+ payload.push(0);
+ build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)])
+ }
+
+ fn push_areal_prefix(payload: &mut Vec<u8>, vertex_count: u32, poly_count: u32) {
+ push_f32(payload, 0.0);
+ push_f32(payload, 0.0);
+ push_f32(payload, 0.0);
+ push_f32(payload, 0.0);
+ push_f32(payload, 2.0);
+ push_f32(payload, 0.0);
+ push_f32(payload, 1.0);
+ push_f32(payload, 0.0);
+ push_u32(payload, 0);
+ push_u32(payload, 0);
+ push_u32(payload, 7);
+ push_u32(payload, 0);
+ push_u32(payload, vertex_count);
+ push_u32(payload, poly_count);
+ }
+
+ fn face(vertices: [u16; 3], neighbors: [Option<u16>; 3]) -> [u8; 28] {
+ let mut out = [0; 28];
+ out[8..10].copy_from_slice(&vertices[0].to_le_bytes());
+ out[10..12].copy_from_slice(&vertices[1].to_le_bytes());
+ out[12..14].copy_from_slice(&vertices[2].to_le_bytes());
+ for (idx, neighbor) in neighbors.iter().enumerate() {
+ let raw = neighbor.unwrap_or(u16::MAX);
+ let offset = 14 + idx * 2;
+ out[offset..offset + 2].copy_from_slice(&raw.to_le_bytes());
+ }
+ out[20..28].copy_from_slice(b"TAILFACE");
+ out
+ }
+
+ fn entry(type_id: u32, attr1: u32, attr3: u32, payload: &[u8]) -> TestEntry<'_> {
+ TestEntry {
+ type_id,
+ attr1,
+ attr3,
+ payload,
+ }
+ }
+
+ #[derive(Clone, Copy)]
+ struct TestEntry<'a> {
+ type_id: u32,
+ attr1: u32,
+ attr3: u32,
+ payload: &'a [u8],
+ }
+
+ fn build_nres(entries: &[TestEntry<'_>]) -> Vec<u8> {
+ let mut out = vec![0; 16];
+ let mut offsets = Vec::with_capacity(entries.len());
+ for entry in entries {
+ offsets.push(u32::try_from(out.len()).expect("offset"));
+ out.extend_from_slice(entry.payload);
+ let padding = (8 - (out.len() % 8)) % 8;
+ out.resize(out.len() + padding, 0);
+ }
+ let order: Vec<usize> = (0..entries.len()).collect();
+ for (idx, entry) in entries.iter().enumerate() {
+ push_u32(&mut out, entry.type_id);
+ push_u32(&mut out, entry.attr1);
+ push_u32(&mut out, 0);
+ push_u32(
+ &mut out,
+ u32::try_from(entry.payload.len()).expect("payload"),
+ );
+ push_u32(&mut out, entry.attr3);
+ let mut name_raw = [0; 36];
+ let name = format!("Res{}", entry.type_id);
+ copy_cstr(&mut name_raw, name.as_bytes());
+ out.extend_from_slice(&name_raw);
+ push_u32(&mut out, offsets[idx]);
+ push_u32(&mut out, u32::try_from(order[idx]).expect("sort index"));
+ }
+ out[0..4].copy_from_slice(b"NRes");
+ out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
+ out[8..12].copy_from_slice(&u32::try_from(entries.len()).expect("count").to_le_bytes());
+ let total_size = u32::try_from(out.len()).expect("total size");
+ out[12..16].copy_from_slice(&total_size.to_le_bytes());
+ out
+ }
+
+ fn copy_cstr(dst: &mut [u8], src: &[u8]) {
+ let len = dst.len().saturating_sub(1).min(src.len());
+ dst[..len].copy_from_slice(&src[..len]);
+ }
+
+ fn push_u32(out: &mut Vec<u8>, value: u32) {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn push_i32(out: &mut Vec<u8>, value: i32) {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn push_u16(out: &mut Vec<u8>, value: u16) {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn push_f32(out: &mut Vec<u8>, value: f32) {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn corpus_root(name: &str) -> Option<PathBuf> {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("testdata")
+ .join(name);
+ root.is_dir().then_some(root)
+ }
+
+ fn files_under(root: &Path) -> Vec<PathBuf> {
+ let mut out = Vec::new();
+ let mut stack = vec![root.to_path_buf()];
+ while let Some(path) = stack.pop() {
+ let Ok(read_dir) = std::fs::read_dir(path) else {
+ continue;
+ };
+ for entry in read_dir.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ stack.push(path);
+ } else {
+ out.push(path);
+ }
+ }
+ }
+ out.sort();
+ out
+ }
+}
diff --git a/crates/fparkan-terrain/Cargo.toml b/crates/fparkan-terrain/Cargo.toml
new file mode 100644
index 0000000..c874c52
--- /dev/null
+++ b/crates/fparkan-terrain/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "fparkan-terrain"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+fparkan-terrain-format = { path = "../fparkan-terrain-format" }
+
+[dev-dependencies]
+fparkan-nres = { path = "../fparkan-nres" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs
new file mode 100644
index 0000000..b28fca6
--- /dev/null
+++ b/crates/fparkan-terrain/src/lib.rs
@@ -0,0 +1,1079 @@
+#![forbid(unsafe_code)]
+//! Validated terrain runtime queries.
+
+use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument};
+use std::collections::VecDeque;
+
+/// Terrain world.
+#[derive(Clone, Debug, Default)]
+pub struct TerrainWorld {
+ areals: Vec<RuntimeAreal>,
+ grid: RuntimeGrid,
+ adjacency: Vec<Vec<ArealId>>,
+ surfaces: Vec<RuntimeTriangle>,
+}
+
+/// Surface hit.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SurfaceHit {
+ /// Height.
+ pub height: f32,
+ /// Hit position.
+ pub position: [f32; 3],
+ /// Ray distance parameter.
+ pub distance: f32,
+ /// Source face index.
+ pub face: usize,
+}
+
+/// Areal id.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub struct ArealId(pub u32);
+
+/// Route request.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct RouteRequest {
+ /// Start.
+ pub start: [f32; 3],
+ /// Goal.
+ pub goal: [f32; 3],
+}
+
+/// Areal route.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ArealRoute {
+ /// Areas.
+ pub areas: Vec<ArealId>,
+}
+
+/// Terrain error.
+#[derive(Debug)]
+pub enum TerrainError {
+ /// Query is not supported by current data.
+ Unsupported,
+ /// Area count exceeds runtime id range.
+ TooManyAreals {
+ /// Area count.
+ count: usize,
+ },
+ /// Grid references an out-of-range area.
+ InvalidGridReference {
+ /// Referenced area.
+ area: u32,
+ /// Area count.
+ area_count: usize,
+ },
+ /// Areal graph references an out-of-range area.
+ InvalidArealReference {
+ /// Source area.
+ source: usize,
+ /// Referenced area.
+ target: u32,
+ /// Area count.
+ area_count: usize,
+ },
+ /// Terrain face references an out-of-range vertex.
+ InvalidSurfaceVertex {
+ /// Source face.
+ face: usize,
+ /// Referenced vertex.
+ vertex: u16,
+ /// Position count.
+ position_count: usize,
+ },
+}
+
+impl std::fmt::Display for TerrainError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Unsupported => write!(f, "terrain query unsupported by current data"),
+ Self::TooManyAreals { count } => write!(f, "too many areals: {count}"),
+ Self::InvalidGridReference { area, area_count } => {
+ write!(f, "grid references area {area} outside {area_count} areas")
+ }
+ Self::InvalidArealReference {
+ source,
+ target,
+ area_count,
+ } => write!(
+ f,
+ "area {source} references area {target} outside {area_count} areas"
+ ),
+ Self::InvalidSurfaceVertex {
+ face,
+ vertex,
+ position_count,
+ } => write!(
+ f,
+ "terrain face {face} references vertex {vertex} outside {position_count} positions"
+ ),
+ }
+ }
+}
+
+impl std::error::Error for TerrainError {}
+
+/// Surface query.
+pub trait SurfaceQuery {
+ /// Height at position.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`TerrainError`] when the current world lacks surface geometry.
+ fn height_at(&self, position: [f32; 2]) -> Result<Option<f32>, TerrainError>;
+
+ /// Raycast.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`TerrainError`] when the current world lacks surface geometry.
+ fn raycast(
+ &self,
+ origin: [f32; 3],
+ direction: [f32; 3],
+ mask: FullSurfaceMask,
+ ) -> Result<Option<SurfaceHit>, TerrainError>;
+}
+
+/// Navigation query.
+pub trait NavigationQuery {
+ /// Locate areal.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`TerrainError`] when runtime indexes are invalid.
+ fn locate_areal(&self, position: [f32; 3]) -> Result<Option<ArealId>, TerrainError>;
+
+ /// Route.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`TerrainError`] when runtime indexes are invalid.
+ fn route(&self, request: RouteRequest) -> Result<Option<ArealRoute>, TerrainError>;
+}
+
+impl TerrainWorld {
+ /// Builds navigation runtime data from a decoded `Land.map`.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`TerrainError`] if ids or references cannot be represented by
+ /// runtime indexes.
+ pub fn from_land_map(map: &LandMapDocument) -> Result<Self, TerrainError> {
+ let areal_count = map.areals.len();
+ if u32::try_from(areal_count).is_err() {
+ return Err(TerrainError::TooManyAreals { count: areal_count });
+ }
+ let mut areals = Vec::with_capacity(areal_count);
+ for (index, areal) in map.areals.iter().enumerate() {
+ let id = ArealId(
+ u32::try_from(index)
+ .map_err(|_| TerrainError::TooManyAreals { count: areal_count })?,
+ );
+ areals.push(RuntimeAreal {
+ id,
+ polygon: areal
+ .vertices
+ .iter()
+ .map(|vertex| [vertex[0], vertex[2]])
+ .collect(),
+ });
+ }
+
+ let mut adjacency = vec![Vec::new(); areal_count];
+ for (source_index, areal) in map.areals.iter().enumerate() {
+ for link in &areal.links {
+ let Some(target) = link.area_ref else {
+ continue;
+ };
+ let target_index =
+ usize::try_from(target).map_err(|_| TerrainError::InvalidArealReference {
+ source: source_index,
+ target,
+ area_count: areal_count,
+ })?;
+ if target_index >= areal_count {
+ return Err(TerrainError::InvalidArealReference {
+ source: source_index,
+ target,
+ area_count: areal_count,
+ });
+ }
+ let id = ArealId(target);
+ if !adjacency[source_index].contains(&id) {
+ adjacency[source_index].push(id);
+ }
+ }
+ adjacency[source_index].sort_by_key(|id| id.0);
+ }
+
+ let grid = RuntimeGrid::from_land_map(map)?;
+ Ok(Self {
+ areals,
+ grid,
+ adjacency,
+ surfaces: Vec::new(),
+ })
+ }
+
+ /// Builds surface runtime data from a decoded `Land.msh`.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`TerrainError`] if a face cannot be represented by runtime
+ /// indexes.
+ pub fn from_land_msh(mesh: &LandMeshDocument) -> Result<Self, TerrainError> {
+ Ok(Self {
+ surfaces: build_surfaces(mesh)?,
+ ..Self::default()
+ })
+ }
+
+ /// Builds terrain runtime data from decoded `Land.msh` and `Land.map`.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`TerrainError`] if surface or navigation runtime indexes are
+ /// invalid.
+ pub fn from_land_assets(
+ mesh: &LandMeshDocument,
+ map: &LandMapDocument,
+ ) -> Result<Self, TerrainError> {
+ let mut world = Self::from_land_map(map)?;
+ world.surfaces = build_surfaces(mesh)?;
+ Ok(world)
+ }
+
+ /// Returns the number of navigation areas.
+ #[must_use]
+ pub fn areal_count(&self) -> usize {
+ self.areals.len()
+ }
+
+ /// Returns the number of surface triangles.
+ #[must_use]
+ pub fn surface_count(&self) -> usize {
+ self.surfaces.len()
+ }
+
+ fn locate_by_candidates(
+ &self,
+ position: [f32; 3],
+ candidates: &[ArealId],
+ ) -> Result<Option<ArealId>, TerrainError> {
+ let point = [position[0], position[2]];
+ for candidate in candidates {
+ let Some(areal) = usize::try_from(candidate.0)
+ .ok()
+ .and_then(|index| self.areals.get(index))
+ else {
+ return Err(TerrainError::InvalidGridReference {
+ area: candidate.0,
+ area_count: self.areals.len(),
+ });
+ };
+ if areal.contains(point) {
+ return Ok(Some(areal.id));
+ }
+ }
+ Ok(None)
+ }
+
+ fn route_ids(&self, start: ArealId, goal: ArealId) -> Result<Option<ArealRoute>, TerrainError> {
+ let start_index =
+ usize::try_from(start.0).map_err(|_| TerrainError::InvalidArealReference {
+ source: 0,
+ target: start.0,
+ area_count: self.areals.len(),
+ })?;
+ let goal_index =
+ usize::try_from(goal.0).map_err(|_| TerrainError::InvalidArealReference {
+ source: start_index,
+ target: goal.0,
+ area_count: self.areals.len(),
+ })?;
+ if start_index >= self.areals.len() {
+ return Err(TerrainError::InvalidArealReference {
+ source: start_index,
+ target: start.0,
+ area_count: self.areals.len(),
+ });
+ }
+ if goal_index >= self.areals.len() {
+ return Err(TerrainError::InvalidArealReference {
+ source: start_index,
+ target: goal.0,
+ area_count: self.areals.len(),
+ });
+ }
+ if start == goal {
+ return Ok(Some(ArealRoute { areas: vec![start] }));
+ }
+
+ let mut previous = vec![None; self.areals.len()];
+ let mut visited = vec![false; self.areals.len()];
+ let mut queue = VecDeque::new();
+ visited[start_index] = true;
+ queue.push_back(start_index);
+
+ while let Some(current) = queue.pop_front() {
+ for next in &self.adjacency[current] {
+ let next_index =
+ usize::try_from(next.0).map_err(|_| TerrainError::InvalidArealReference {
+ source: current,
+ target: next.0,
+ area_count: self.areals.len(),
+ })?;
+ if next_index >= self.areals.len() {
+ return Err(TerrainError::InvalidArealReference {
+ source: current,
+ target: next.0,
+ area_count: self.areals.len(),
+ });
+ }
+ if visited[next_index] {
+ continue;
+ }
+ visited[next_index] = true;
+ previous[next_index] = Some(current);
+ if next_index == goal_index {
+ return Ok(Some(reconstruct_route(&previous, start_index, goal_index)));
+ }
+ queue.push_back(next_index);
+ }
+ }
+ Ok(None)
+ }
+}
+
+impl SurfaceQuery for TerrainWorld {
+ fn height_at(&self, position: [f32; 2]) -> Result<Option<f32>, TerrainError> {
+ if self.surfaces.is_empty() {
+ return Err(TerrainError::Unsupported);
+ }
+ let mut best = None;
+ for triangle in &self.surfaces {
+ if let Some(height) = triangle.height_at(position) {
+ best = Some(best.map_or(height, |current: f32| current.max(height)));
+ }
+ }
+ Ok(best)
+ }
+
+ fn raycast(
+ &self,
+ origin: [f32; 3],
+ direction: [f32; 3],
+ mask: FullSurfaceMask,
+ ) -> Result<Option<SurfaceHit>, TerrainError> {
+ if self.surfaces.is_empty() {
+ return Err(TerrainError::Unsupported);
+ }
+ let mut best: Option<SurfaceHit> = None;
+ for triangle in &self.surfaces {
+ if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 {
+ continue;
+ }
+ let Some(distance) = triangle.raycast(origin, direction) else {
+ continue;
+ };
+ if best.is_some_and(|hit| hit.distance <= distance) {
+ continue;
+ }
+ let position = [
+ origin[0] + direction[0] * distance,
+ origin[1] + direction[1] * distance,
+ origin[2] + direction[2] * distance,
+ ];
+ best = Some(SurfaceHit {
+ height: position[1],
+ position,
+ distance,
+ face: triangle.face,
+ });
+ }
+ Ok(best)
+ }
+}
+
+impl NavigationQuery for TerrainWorld {
+ fn locate_areal(&self, position: [f32; 3]) -> Result<Option<ArealId>, TerrainError> {
+ if let Some(candidates) = self.grid.candidates(position) {
+ if let Some(id) = self.locate_by_candidates(position, candidates)? {
+ return Ok(Some(id));
+ }
+ }
+ let all: Vec<ArealId> = self.areals.iter().map(|areal| areal.id).collect();
+ self.locate_by_candidates(position, &all)
+ }
+
+ fn route(&self, request: RouteRequest) -> Result<Option<ArealRoute>, TerrainError> {
+ let Some(start) = self.locate_areal(request.start)? else {
+ return Ok(None);
+ };
+ let Some(goal) = self.locate_areal(request.goal)? else {
+ return Ok(None);
+ };
+ self.route_ids(start, goal)
+ }
+}
+
+#[derive(Clone, Debug)]
+struct RuntimeTriangle {
+ face: usize,
+ mask: FullSurfaceMask,
+ vertices: [[f32; 3]; 3],
+}
+
+impl RuntimeTriangle {
+ fn height_at(&self, position: [f32; 2]) -> Option<f32> {
+ let a = [self.vertices[0][0], self.vertices[0][2]];
+ let b = [self.vertices[1][0], self.vertices[1][2]];
+ let c = [self.vertices[2][0], self.vertices[2][2]];
+ let weights = barycentric_2d(position, a, b, c)?;
+ if weights
+ .iter()
+ .all(|weight| *weight >= -1.0e-4 && *weight <= 1.0001)
+ {
+ Some(
+ weights[0] * self.vertices[0][1]
+ + weights[1] * self.vertices[1][1]
+ + weights[2] * self.vertices[2][1],
+ )
+ } else {
+ None
+ }
+ }
+
+ fn raycast(&self, origin: [f32; 3], direction: [f32; 3]) -> Option<f32> {
+ let edge1 = sub3(self.vertices[1], self.vertices[0]);
+ let edge2 = sub3(self.vertices[2], self.vertices[0]);
+ let pvec = cross3(direction, edge2);
+ let det = dot3(edge1, pvec);
+ if det.abs() <= 1.0e-6 {
+ return None;
+ }
+ let inv_det = 1.0 / det;
+ let tvec = sub3(origin, self.vertices[0]);
+ let u = dot3(tvec, pvec) * inv_det;
+ if !(-1.0e-5..=1.00001).contains(&u) {
+ return None;
+ }
+ let qvec = cross3(tvec, edge1);
+ let v = dot3(direction, qvec) * inv_det;
+ if v < -1.0e-5 || u + v > 1.00001 {
+ return None;
+ }
+ let distance = dot3(edge2, qvec) * inv_det;
+ (distance >= 0.0).then_some(distance)
+ }
+}
+
+#[derive(Clone, Debug)]
+struct RuntimeAreal {
+ id: ArealId,
+ polygon: Vec<[f32; 2]>,
+}
+
+impl RuntimeAreal {
+ fn contains(&self, point: [f32; 2]) -> bool {
+ if self.polygon.len() < 3 {
+ return false;
+ }
+ if self.on_boundary(point) {
+ return true;
+ }
+
+ let mut inside = false;
+ let mut prev = self.polygon[self.polygon.len() - 1];
+ for current in &self.polygon {
+ let crosses = (current[1] > point[1]) != (prev[1] > point[1]);
+ if crosses {
+ let x_intersect = (prev[0] - current[0]) * (point[1] - current[1])
+ / (prev[1] - current[1])
+ + current[0];
+ if point[0] < x_intersect {
+ inside = !inside;
+ }
+ }
+ prev = *current;
+ }
+ inside
+ }
+
+ fn on_boundary(&self, point: [f32; 2]) -> bool {
+ let mut prev = self.polygon[self.polygon.len() - 1];
+ for current in &self.polygon {
+ if point_on_segment(point, prev, *current) {
+ return true;
+ }
+ prev = *current;
+ }
+ false
+ }
+}
+
+#[derive(Clone, Debug, Default)]
+struct RuntimeGrid {
+ cells_x: u32,
+ cells_y: u32,
+ min: [f32; 2],
+ max: [f32; 2],
+ cells: Vec<Vec<ArealId>>,
+}
+
+impl RuntimeGrid {
+ fn from_land_map(map: &LandMapDocument) -> Result<Self, TerrainError> {
+ let mut min = [f32::INFINITY, f32::INFINITY];
+ let mut max = [f32::NEG_INFINITY, f32::NEG_INFINITY];
+ for areal in &map.areals {
+ for vertex in &areal.vertices {
+ min[0] = min[0].min(vertex[0]);
+ min[1] = min[1].min(vertex[2]);
+ max[0] = max[0].max(vertex[0]);
+ max[1] = max[1].max(vertex[2]);
+ }
+ }
+ if !min[0].is_finite() || !min[1].is_finite() || !max[0].is_finite() || !max[1].is_finite()
+ {
+ min = [0.0, 0.0];
+ max = [1.0, 1.0];
+ }
+ if (min[0] - max[0]).abs() <= f32::EPSILON {
+ max[0] += 1.0;
+ }
+ if (min[1] - max[1]).abs() <= f32::EPSILON {
+ max[1] += 1.0;
+ }
+
+ let mut cells = Vec::with_capacity(map.grid.cells.len());
+ for cell in &map.grid.cells {
+ let mut ids = Vec::with_capacity(cell.area_ids.len());
+ for area in &cell.area_ids {
+ let index =
+ usize::try_from(*area).map_err(|_| TerrainError::InvalidGridReference {
+ area: *area,
+ area_count: map.areals.len(),
+ })?;
+ if index >= map.areals.len() {
+ return Err(TerrainError::InvalidGridReference {
+ area: *area,
+ area_count: map.areals.len(),
+ });
+ }
+ ids.push(ArealId(*area));
+ }
+ cells.push(ids);
+ }
+ Ok(Self {
+ cells_x: map.grid.cells_x,
+ cells_y: map.grid.cells_y,
+ min,
+ max,
+ cells,
+ })
+ }
+
+ fn candidates(&self, position: [f32; 3]) -> Option<&[ArealId]> {
+ if self.cells_x == 0 || self.cells_y == 0 || self.cells.is_empty() {
+ return None;
+ }
+ let point = [position[0], position[2]];
+ if point[0] < self.min[0]
+ || point[0] > self.max[0]
+ || point[1] < self.min[1]
+ || point[1] > self.max[1]
+ {
+ return None;
+ }
+ let nx = normalized_cell(point[0], self.min[0], self.max[0], self.cells_x);
+ let ny = normalized_cell(point[1], self.min[1], self.max[1], self.cells_y);
+ let index_u32 = ny.checked_mul(self.cells_x)?.checked_add(nx)?;
+ let index = usize::try_from(index_u32).ok()?;
+ self.cells.get(index).map(Vec::as_slice)
+ }
+}
+
+fn build_surfaces(mesh: &LandMeshDocument) -> Result<Vec<RuntimeTriangle>, TerrainError> {
+ let mut triangles = Vec::with_capacity(mesh.faces.len());
+ for (face_index, face) in mesh.faces.iter().enumerate() {
+ let vertices = [
+ surface_vertex(mesh, face_index, face.vertices[0])?,
+ surface_vertex(mesh, face_index, face.vertices[1])?,
+ surface_vertex(mesh, face_index, face.vertices[2])?,
+ ];
+ triangles.push(RuntimeTriangle {
+ face: face_index,
+ mask: face.flags,
+ vertices,
+ });
+ }
+ Ok(triangles)
+}
+
+fn surface_vertex(
+ mesh: &LandMeshDocument,
+ face: usize,
+ vertex: u16,
+) -> Result<[f32; 3], TerrainError> {
+ mesh.positions
+ .get(usize::from(vertex))
+ .copied()
+ .ok_or(TerrainError::InvalidSurfaceVertex {
+ face,
+ vertex,
+ position_count: mesh.positions.len(),
+ })
+}
+
+fn barycentric_2d(
+ point: [f32; 2],
+ first: [f32; 2],
+ second: [f32; 2],
+ third: [f32; 2],
+) -> Option<[f32; 3]> {
+ let edge_second = [second[0] - first[0], second[1] - first[1]];
+ let edge_third = [third[0] - first[0], third[1] - first[1]];
+ let point_delta = [point[0] - first[0], point[1] - first[1]];
+ let denom = edge_second[0] * edge_third[1] - edge_third[0] * edge_second[1];
+ if denom.abs() <= 1.0e-6 {
+ return None;
+ }
+ let inv = 1.0 / denom;
+ let second_weight = (point_delta[0] * edge_third[1] - edge_third[0] * point_delta[1]) * inv;
+ let third_weight = (edge_second[0] * point_delta[1] - point_delta[0] * edge_second[1]) * inv;
+ let first_weight = 1.0 - second_weight - third_weight;
+ Some([first_weight, second_weight, third_weight])
+}
+
+fn sub3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
+ [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
+}
+
+fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
+ [
+ a[1] * b[2] - a[2] * b[1],
+ a[2] * b[0] - a[0] * b[2],
+ a[0] * b[1] - a[1] * b[0],
+ ]
+}
+
+fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
+ a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
+}
+
+fn normalized_cell(value: f32, min: f32, max: f32, cells: u32) -> u32 {
+ let span = max - min;
+ if span <= 0.0 {
+ return 0;
+ }
+ if value <= min {
+ return 0;
+ }
+ if value >= max {
+ return cells.saturating_sub(1);
+ }
+ let value = f64::from(value);
+ let min = f64::from(min);
+ let span = f64::from(span);
+ for cell in 0..cells {
+ let upper = min + span * f64::from(cell + 1) / f64::from(cells);
+ if value <= upper {
+ return cell;
+ }
+ }
+ cells.saturating_sub(1)
+}
+
+fn point_on_segment(point: [f32; 2], a: [f32; 2], b: [f32; 2]) -> bool {
+ let cross = (point[1] - a[1]) * (b[0] - a[0]) - (point[0] - a[0]) * (b[1] - a[1]);
+ if cross.abs() > 1.0e-4 {
+ return false;
+ }
+ let min_x = a[0].min(b[0]) - 1.0e-4;
+ let max_x = a[0].max(b[0]) + 1.0e-4;
+ let min_y = a[1].min(b[1]) - 1.0e-4;
+ let max_y = a[1].max(b[1]) + 1.0e-4;
+ point[0] >= min_x && point[0] <= max_x && point[1] >= min_y && point[1] <= max_y
+}
+
+fn reconstruct_route(previous: &[Option<usize>], start: usize, goal: usize) -> ArealRoute {
+ let mut route = Vec::new();
+ let mut current = goal;
+ route.push(ArealId(u32::try_from(current).unwrap_or(u32::MAX)));
+ while current != start {
+ let Some(prev) = previous[current] else {
+ break;
+ };
+ current = prev;
+ route.push(ArealId(u32::try_from(current).unwrap_or(u32::MAX)));
+ }
+ route.reverse();
+ ArealRoute { areas: route }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_nres::ReadProfile;
+ use std::path::{Path, PathBuf};
+ use std::sync::Arc;
+
+ #[test]
+ fn locates_areal_and_routes_synthetic_neighbors() {
+ let map = synthetic_land_map();
+ let world = TerrainWorld::from_land_map(&map).expect("world");
+
+ assert_eq!(world.areal_count(), 2);
+ assert_eq!(
+ world.locate_areal([0.25, 0.0, 0.25]).expect("locate"),
+ Some(ArealId(0))
+ );
+ assert_eq!(
+ world.locate_areal([1.75, 0.0, 0.25]).expect("locate"),
+ Some(ArealId(1))
+ );
+ assert_eq!(
+ world
+ .route(RouteRequest {
+ start: [0.25, 0.0, 0.25],
+ goal: [1.75, 0.0, 0.25],
+ })
+ .expect("route"),
+ Some(ArealRoute {
+ areas: vec![ArealId(0), ArealId(1)]
+ })
+ );
+ }
+
+ #[test]
+ fn missing_start_or_goal_returns_no_route() {
+ let world = TerrainWorld::from_land_map(&synthetic_land_map()).expect("world");
+
+ assert_eq!(
+ world
+ .route(RouteRequest {
+ start: [10.0, 0.0, 10.0],
+ goal: [1.75, 0.0, 0.25],
+ })
+ .expect("route"),
+ None
+ );
+ }
+
+ #[test]
+ fn synthetic_surface_height_and_raycast_work() {
+ let world = TerrainWorld::from_land_msh(&synthetic_land_mesh()).expect("world");
+
+ assert_eq!(world.surface_count(), 2);
+ assert_eq!(world.height_at([0.25, 0.25]).expect("height"), Some(0.5));
+ assert_eq!(world.height_at([10.0, 10.0]).expect("height"), None);
+
+ let hit = world
+ .raycast(
+ [0.25, 2.0, 0.25],
+ [0.0, -1.0, 0.0],
+ FullSurfaceMask(0x0000_0001),
+ )
+ .expect("raycast")
+ .expect("hit");
+ assert_eq!(hit.face, 0);
+ assert!((hit.height - 0.5).abs() < 1.0e-5);
+ assert!((hit.distance - 1.5).abs() < 1.0e-5);
+
+ assert_eq!(
+ world
+ .raycast(
+ [0.25, 2.0, 0.25],
+ [0.0, -1.0, 0.0],
+ FullSurfaceMask(0x8000_0000)
+ )
+ .expect("raycast"),
+ None
+ );
+ }
+
+ #[test]
+ fn licensed_corpus_land_maps_build_navigation_worlds() {
+ for (corpus, expected_files, expected_areals) in [
+ ("IS", 33_usize, 34_662_usize),
+ ("IS2", 32_usize, 18_984_usize),
+ ] {
+ let Some(root) = corpus_root(corpus) else {
+ continue;
+ };
+ let mut files = 0usize;
+ let mut areals = 0usize;
+ let mut located_centers = 0usize;
+ for path in files_under(&root) {
+ if !path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.eq_ignore_ascii_case("Land.map"))
+ {
+ continue;
+ }
+ let bytes = std::fs::read(&path).expect("read Land.map");
+ let nres = fparkan_nres::decode(
+ Arc::from(bytes.into_boxed_slice()),
+ ReadProfile::Compatible,
+ )
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ let map = fparkan_terrain_format::decode_land_map(&nres)
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ let world = TerrainWorld::from_land_map(&map)
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ files += 1;
+ areals += world.areal_count();
+ for (index, areal) in map.areals.iter().take(8).enumerate() {
+ if let Some(point) = polygon_probe_point(&areal.vertices) {
+ let located = world
+ .locate_areal([point[0], point[1], point[2]])
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ assert!(
+ located.is_some(),
+ "{corpus} {path:?} area {index} probe point was not located"
+ );
+ located_centers += 1;
+ }
+ }
+ }
+
+ assert_eq!(files, expected_files, "{corpus} Land.map count");
+ assert_eq!(areals, expected_areals, "{corpus} areal count");
+ assert!(
+ located_centers >= expected_files,
+ "{corpus} located center coverage"
+ );
+ }
+ }
+
+ #[test]
+ fn licensed_corpus_land_meshes_build_surface_worlds() {
+ for (corpus, expected_files, expected_faces) in [
+ ("IS", 33_usize, 275_882_usize),
+ ("IS2", 32_usize, 184_454_usize),
+ ] {
+ let Some(root) = corpus_root(corpus) else {
+ continue;
+ };
+ let mut files = 0usize;
+ let mut faces = 0usize;
+ for path in files_under(&root) {
+ if !path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.eq_ignore_ascii_case("Land.msh"))
+ {
+ continue;
+ }
+ let bytes = std::fs::read(&path).expect("read Land.msh");
+ let nres = fparkan_nres::decode(
+ Arc::from(bytes.into_boxed_slice()),
+ ReadProfile::Compatible,
+ )
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ let mesh = fparkan_terrain_format::decode_land_msh(&nres)
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ let world = TerrainWorld::from_land_msh(&mesh)
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ files += 1;
+ faces += world.surface_count();
+ }
+
+ assert_eq!(files, expected_files, "{corpus} Land.msh count");
+ assert_eq!(faces, expected_faces, "{corpus} surface face count");
+ }
+ }
+
+ fn synthetic_land_mesh() -> LandMeshDocument {
+ use fparkan_terrain_format::{TerrainSlotTable, TerrainStream};
+
+ let face0 = terrain_face(FullSurfaceMask(0x0000_0001), [0, 1, 2]);
+ let face1 = terrain_face(FullSurfaceMask(0x0000_0002), [1, 3, 2]);
+ LandMeshDocument {
+ streams: Vec::<TerrainStream>::new(),
+ nodes_raw: Vec::new(),
+ slots: TerrainSlotTable {
+ header_raw: Vec::new(),
+ slots_raw: Vec::new(),
+ },
+ positions: vec![
+ [0.0, 0.0, 0.0],
+ [1.0, 1.0, 0.0],
+ [0.0, 1.0, 1.0],
+ [1.0, 2.0, 1.0],
+ ],
+ normals: Vec::new(),
+ uv0: Vec::new(),
+ accelerator: Vec::new(),
+ aux14: Vec::new(),
+ aux18: Vec::new(),
+ faces: vec![face0, face1],
+ }
+ }
+
+ fn terrain_face(
+ flags: FullSurfaceMask,
+ vertices: [u16; 3],
+ ) -> fparkan_terrain_format::TerrainFace28 {
+ use fparkan_terrain_format::TerrainFace28;
+
+ TerrainFace28 {
+ flags,
+ material_tag: 0,
+ aux_tag: 0,
+ vertices,
+ neighbors: [None, None, None],
+ tail_raw: [0; 8],
+ raw: [0; 28],
+ }
+ }
+
+ fn synthetic_land_map() -> LandMapDocument {
+ use fparkan_terrain_format::{
+ Areal, ArealGrid, ArealGridCell, EdgeLink, TerrainStream, TerrainStreamAttributes,
+ };
+
+ LandMapDocument {
+ entry: TerrainStream {
+ type_id: 12,
+ attributes: TerrainStreamAttributes::default(),
+ size: 0,
+ },
+ areal_count: 2,
+ areals: vec![
+ Areal {
+ prefix_raw: [0; 56],
+ anchor: [0.5, 0.0, 0.5],
+ reserved_12: 0.0,
+ area_metric: 1.0,
+ normal: [0.0, 1.0, 0.0],
+ logic_flag: 0,
+ reserved_36: 0,
+ class_id: 0,
+ reserved_44: 0,
+ vertices: vec![
+ [0.0, 0.0, 0.0],
+ [1.0, 0.0, 0.0],
+ [1.0, 0.0, 1.0],
+ [0.0, 0.0, 1.0],
+ ],
+ links: vec![
+ EdgeLink {
+ raw_area_ref: -1,
+ raw_edge_ref: -1,
+ area_ref: None,
+ edge_ref: None,
+ },
+ EdgeLink {
+ raw_area_ref: 1,
+ raw_edge_ref: 3,
+ area_ref: Some(1),
+ edge_ref: Some(3),
+ },
+ EdgeLink {
+ raw_area_ref: -1,
+ raw_edge_ref: -1,
+ area_ref: None,
+ edge_ref: None,
+ },
+ EdgeLink {
+ raw_area_ref: -1,
+ raw_edge_ref: -1,
+ area_ref: None,
+ edge_ref: None,
+ },
+ ],
+ polygon_blocks: Vec::new(),
+ },
+ Areal {
+ prefix_raw: [0; 56],
+ anchor: [1.5, 0.0, 0.5],
+ reserved_12: 0.0,
+ area_metric: 1.0,
+ normal: [0.0, 1.0, 0.0],
+ logic_flag: 0,
+ reserved_36: 0,
+ class_id: 0,
+ reserved_44: 0,
+ vertices: vec![
+ [1.0, 0.0, 0.0],
+ [2.0, 0.0, 0.0],
+ [2.0, 0.0, 1.0],
+ [1.0, 0.0, 1.0],
+ ],
+ links: vec![
+ EdgeLink {
+ raw_area_ref: -1,
+ raw_edge_ref: -1,
+ area_ref: None,
+ edge_ref: None,
+ },
+ EdgeLink {
+ raw_area_ref: -1,
+ raw_edge_ref: -1,
+ area_ref: None,
+ edge_ref: None,
+ },
+ EdgeLink {
+ raw_area_ref: -1,
+ raw_edge_ref: -1,
+ area_ref: None,
+ edge_ref: None,
+ },
+ EdgeLink {
+ raw_area_ref: 0,
+ raw_edge_ref: 1,
+ area_ref: Some(0),
+ edge_ref: Some(1),
+ },
+ ],
+ polygon_blocks: Vec::new(),
+ },
+ ],
+ grid: ArealGrid {
+ cells_x: 2,
+ cells_y: 1,
+ cells: vec![
+ ArealGridCell { area_ids: vec![0] },
+ ArealGridCell { area_ids: vec![1] },
+ ],
+ candidate_pool: vec![0, 1],
+ compact_cells: vec![0x0040_0000, 0x0040_0001],
+ },
+ }
+ }
+
+ fn polygon_probe_point(vertices: &[[f32; 3]]) -> Option<[f32; 3]> {
+ vertices.first().copied()
+ }
+
+ fn corpus_root(name: &str) -> Option<PathBuf> {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("testdata")
+ .join(name);
+ root.is_dir().then_some(root)
+ }
+
+ fn files_under(root: &Path) -> Vec<PathBuf> {
+ let mut out = Vec::new();
+ let mut stack = vec![root.to_path_buf()];
+ while let Some(path) = stack.pop() {
+ let Ok(read_dir) = std::fs::read_dir(path) else {
+ continue;
+ };
+ for entry in read_dir.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ stack.push(path);
+ } else {
+ out.push(path);
+ }
+ }
+ }
+ out.sort();
+ out
+ }
+}
diff --git a/crates/fparkan-test-support/Cargo.toml b/crates/fparkan-test-support/Cargo.toml
new file mode 100644
index 0000000..56a079f
--- /dev/null
+++ b/crates/fparkan-test-support/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "fparkan-test-support"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+fparkan-render = { path = "../fparkan-render" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-test-support/src/lib.rs b/crates/fparkan-test-support/src/lib.rs
new file mode 100644
index 0000000..cb4f552
--- /dev/null
+++ b/crates/fparkan-test-support/src/lib.rs
@@ -0,0 +1,25 @@
+#![forbid(unsafe_code)]
+//! Dev-only synthetic builders and fake ports.
+
+use fparkan_render::{FrameOutput, RenderBackend, RenderCommandList, RenderError};
+
+/// Fake clock.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct FakeClock {
+ /// Current tick.
+ pub tick: u64,
+}
+
+/// Recording backend.
+#[derive(Clone, Debug, Default)]
+pub struct RecordingRenderBackend {
+ /// Recorded command lists.
+ pub captures: Vec<RenderCommandList>,
+}
+
+impl RenderBackend for RecordingRenderBackend {
+ fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
+ self.captures.push(commands.clone());
+ Ok(FrameOutput)
+ }
+}
diff --git a/crates/fparkan-texm/Cargo.toml b/crates/fparkan-texm/Cargo.toml
new file mode 100644
index 0000000..b3171f7
--- /dev/null
+++ b/crates/fparkan-texm/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "fparkan-texm"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+
+[dev-dependencies]
+fparkan-nres = { path = "../fparkan-nres" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-texm/src/lib.rs b/crates/fparkan-texm/src/lib.rs
new file mode 100644
index 0000000..6adc8b1
--- /dev/null
+++ b/crates/fparkan-texm/src/lib.rs
@@ -0,0 +1,1187 @@
+#![forbid(unsafe_code)]
+//! Stage-3 Texm texture contract.
+
+use std::sync::Arc;
+
+const TEXM_MAGIC: u32 = 0x6D78_6554;
+const PAGE_MAGIC: u32 = 0x6567_6150;
+
+/// Pixel format.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum PixelFormat {
+ /// Indexed 8.
+ Indexed8,
+ /// RGB565.
+ Rgb565,
+ /// RGB556.
+ Rgb556,
+ /// ARGB4444.
+ Argb4444,
+ /// Luminance alpha 8:8.
+ L8A8,
+ /// RGB888 with preserved service byte in disk payload.
+ Rgb888x,
+ /// ARGB8888.
+ Argb8888,
+}
+
+/// Texm disk document.
+#[derive(Clone, Debug)]
+pub struct TexmDocument {
+ bytes: Arc<[u8]>,
+ texture: Texture,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum DiskPixelFormat {
+ Indexed8,
+ Rgb565,
+ Rgb556,
+ Argb4444,
+ L8A8,
+ Rgb888x,
+ Argb8888,
+}
+
+impl DiskPixelFormat {
+ 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::L8A8),
+ 888 => Some(Self::Rgb888x),
+ 8888 => Some(Self::Argb8888),
+ _ => None,
+ }
+ }
+
+ fn bytes_per_pixel(self) -> usize {
+ match self {
+ Self::Indexed8 => 1,
+ Self::Rgb565 | Self::Rgb556 | Self::Argb4444 | Self::L8A8 => 2,
+ Self::Rgb888x | Self::Argb8888 => 4,
+ }
+ }
+}
+
+#[derive(Clone, Debug)]
+struct Header {
+ width: u32,
+ height: u32,
+ format: DiskPixelFormat,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct MipLevel {
+ width: u32,
+ height: u32,
+ offset: usize,
+ size: usize,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct DiskPageRect {
+ x: i16,
+ w: i16,
+ y: i16,
+ h: i16,
+}
+
+#[derive(Clone, Debug)]
+struct Texture {
+ header: Header,
+ palette: Option<[u8; 1024]>,
+ mip_levels: Vec<MipLevel>,
+ page_rects: Vec<DiskPageRect>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct DecodedMip {
+ width: u32,
+ height: u32,
+ rgba8: Vec<u8>,
+}
+
+/// Borrowed mip level view.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct MipLevelView<'a> {
+ /// Mip level index.
+ pub level: u32,
+ /// Width.
+ pub width: u32,
+ /// Height.
+ pub height: u32,
+ /// Raw disk bytes for this level.
+ pub bytes: &'a [u8],
+}
+
+/// Page rectangle.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct PageRect {
+ /// X origin.
+ pub x: i16,
+ /// Width.
+ pub w: i16,
+ /// Y origin.
+ pub y: i16,
+ /// Height.
+ pub h: i16,
+}
+
+/// Page rectangle scaling policy.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub enum PageScalePolicy {
+ /// Scale origin with floor and end with ceil, preserving coverage.
+ #[default]
+ FloorOriginCeilEnd,
+}
+
+/// RGBA8 image.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RgbaImage {
+ /// Width.
+ pub width: u32,
+ /// Height.
+ pub height: u32,
+ /// Packed RGBA8 pixels.
+ pub rgba8: Vec<u8>,
+}
+
+/// Texture upload plan.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TextureUploadPlan {
+ /// Pixel format.
+ pub format: PixelFormat,
+ /// Original texture width.
+ pub width: u32,
+ /// Original texture height.
+ pub height: u32,
+ /// Selected mip levels.
+ pub mips: Vec<UploadMip>,
+ /// Page rectangles copied from disk metadata.
+ pub page_rects: Vec<PageRect>,
+}
+
+/// Upload mip description.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct UploadMip {
+ /// Original mip level index.
+ pub level: u32,
+ /// Width.
+ pub width: u32,
+ /// Height.
+ pub height: u32,
+ /// Byte offset in the original disk document.
+ pub offset: usize,
+ /// Byte size.
+ pub size: usize,
+}
+
+/// Mip skip policy.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct MipSkipPolicy {
+ /// Number of top mip levels to skip.
+ pub skip_top_levels: u32,
+}
+
+/// Texm decode error.
+#[derive(Debug)]
+pub enum TexmError {
+ /// Legacy parser error.
+ Format(String),
+ /// Requested mip level is absent.
+ MipLevelOutOfRange {
+ /// Requested level.
+ requested: u32,
+ /// Available mip count.
+ mip_count: usize,
+ },
+ /// Mip payload range is outside the document.
+ MipDataOutOfBounds {
+ /// Byte offset.
+ offset: usize,
+ /// Byte size.
+ size: usize,
+ /// Document size.
+ document_size: usize,
+ },
+ /// All mip levels were skipped.
+ EmptyUploadPlan,
+}
+
+impl std::fmt::Display for TexmError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Format(message) => write!(f, "{message}"),
+ Self::MipLevelOutOfRange {
+ requested,
+ mip_count,
+ } => write!(
+ f,
+ "Texm mip level out of range: requested={requested}, mip_count={mip_count}"
+ ),
+ Self::MipDataOutOfBounds {
+ offset,
+ size,
+ document_size,
+ } => write!(
+ f,
+ "Texm mip bytes out of bounds: offset={offset}, size={size}, document_size={document_size}"
+ ),
+ Self::EmptyUploadPlan => write!(f, "Texm upload plan contains no mip levels"),
+ }
+ }
+}
+
+impl std::error::Error for TexmError {}
+
+/// Decodes Texm disk bytes.
+///
+/// # Errors
+///
+/// Returns [`TexmError`] when the header, format, mip chain, palette, or Page
+/// chunk is malformed.
+pub fn decode_texm(bytes: Arc<[u8]>) -> Result<TexmDocument, TexmError> {
+ let texture = parse_texm(&bytes)?;
+ Ok(TexmDocument { bytes, texture })
+}
+
+/// Decodes one mip level into RGBA8 using the CPU reference decoder.
+///
+/// # Errors
+///
+/// Returns [`TexmError`] when `level` is outside the mip chain or mip bytes are
+/// malformed.
+pub fn decode_mip_rgba8(document: &TexmDocument, level: u32) -> Result<RgbaImage, TexmError> {
+ let decoded = decode_mip_rgba8_internal(
+ &document.texture,
+ &document.bytes,
+ usize::try_from(level).map_err(|_| TexmError::MipLevelOutOfRange {
+ requested: level,
+ mip_count: document.texture.mip_levels.len(),
+ })?,
+ )?;
+ Ok(RgbaImage {
+ width: decoded.width,
+ height: decoded.height,
+ rgba8: decoded.rgba8,
+ })
+}
+
+/// Builds an upload plan without mutating the disk document.
+///
+/// # Errors
+///
+/// Returns [`TexmError::EmptyUploadPlan`] when the policy skips every mip.
+pub fn plan_upload(
+ document: &TexmDocument,
+ policy: MipSkipPolicy,
+) -> Result<TextureUploadPlan, TexmError> {
+ let skip = usize::try_from(policy.skip_top_levels).map_err(|_| TexmError::EmptyUploadPlan)?;
+ let mips = document
+ .texture
+ .mip_levels
+ .iter()
+ .enumerate()
+ .skip(skip)
+ .map(|(level, mip)| {
+ Ok(UploadMip {
+ level: u32::try_from(level).map_err(|_| TexmError::EmptyUploadPlan)?,
+ width: mip.width,
+ height: mip.height,
+ offset: mip.offset,
+ size: mip.size,
+ })
+ })
+ .collect::<Result<Vec<_>, TexmError>>()?;
+ if mips.is_empty() {
+ return Err(TexmError::EmptyUploadPlan);
+ }
+ Ok(TextureUploadPlan {
+ format: map_format(document.texture.header.format),
+ width: document.texture.header.width,
+ height: document.texture.header.height,
+ mips,
+ page_rects: document
+ .texture
+ .page_rects
+ .iter()
+ .copied()
+ .map(map_page_rect)
+ .collect(),
+ })
+}
+
+/// Returns Page rectangles scaled to a selected mip level.
+///
+/// # Errors
+///
+/// Returns [`TexmError`] when `level` is outside the mip chain or scaled values
+/// cannot be represented as `i16`.
+pub fn scaled_page_rects(
+ document: &TexmDocument,
+ level: u32,
+ policy: PageScalePolicy,
+) -> Result<Vec<PageRect>, TexmError> {
+ let mip = document.mip_level(level)?;
+ document
+ .texture
+ .page_rects
+ .iter()
+ .copied()
+ .map(|rect| {
+ scale_page_rect(
+ document.width(),
+ document.height(),
+ mip.width,
+ mip.height,
+ rect,
+ policy,
+ )
+ })
+ .collect()
+}
+
+impl TexmDocument {
+ /// Width.
+ #[must_use]
+ pub fn width(&self) -> u32 {
+ self.texture.header.width
+ }
+
+ /// Height.
+ #[must_use]
+ pub fn height(&self) -> u32 {
+ self.texture.header.height
+ }
+
+ /// Pixel format.
+ #[must_use]
+ pub fn format(&self) -> PixelFormat {
+ map_format(self.texture.header.format)
+ }
+
+ /// Mip count.
+ #[must_use]
+ pub fn mip_count(&self) -> usize {
+ self.texture.mip_levels.len()
+ }
+
+ /// Returns a borrowed mip view.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`TexmError`] when `level` is outside the mip chain or the stored
+ /// range is outside the document.
+ pub fn mip_level(&self, level: u32) -> Result<MipLevelView<'_>, TexmError> {
+ let requested = usize::try_from(level).map_err(|_| TexmError::MipLevelOutOfRange {
+ requested: level,
+ mip_count: self.texture.mip_levels.len(),
+ })?;
+ let mip = self
+ .texture
+ .mip_levels
+ .get(requested)
+ .ok_or(TexmError::MipLevelOutOfRange {
+ requested: level,
+ mip_count: self.texture.mip_levels.len(),
+ })?;
+ let end = mip
+ .offset
+ .checked_add(mip.size)
+ .ok_or(TexmError::MipDataOutOfBounds {
+ offset: mip.offset,
+ size: mip.size,
+ document_size: self.bytes.len(),
+ })?;
+ let bytes = self
+ .bytes
+ .get(mip.offset..end)
+ .ok_or(TexmError::MipDataOutOfBounds {
+ offset: mip.offset,
+ size: mip.size,
+ document_size: self.bytes.len(),
+ })?;
+ Ok(MipLevelView {
+ level,
+ width: mip.width,
+ height: mip.height,
+ bytes,
+ })
+ }
+
+ /// Page rectangles.
+ #[must_use]
+ pub fn page_rects(&self) -> Vec<PageRect> {
+ self.texture
+ .page_rects
+ .iter()
+ .copied()
+ .map(map_page_rect)
+ .collect()
+ }
+}
+
+fn map_format(format: DiskPixelFormat) -> PixelFormat {
+ match format {
+ DiskPixelFormat::Indexed8 => PixelFormat::Indexed8,
+ DiskPixelFormat::Rgb565 => PixelFormat::Rgb565,
+ DiskPixelFormat::Rgb556 => PixelFormat::Rgb556,
+ DiskPixelFormat::Argb4444 => PixelFormat::Argb4444,
+ DiskPixelFormat::L8A8 => PixelFormat::L8A8,
+ DiskPixelFormat::Rgb888x => PixelFormat::Rgb888x,
+ DiskPixelFormat::Argb8888 => PixelFormat::Argb8888,
+ }
+}
+
+fn map_page_rect(rect: DiskPageRect) -> PageRect {
+ PageRect {
+ x: rect.x,
+ w: rect.w,
+ y: rect.y,
+ h: rect.h,
+ }
+}
+
+fn scale_page_rect(
+ source_width: u32,
+ source_height: u32,
+ target_width: u32,
+ target_height: u32,
+ rect: DiskPageRect,
+ policy: PageScalePolicy,
+) -> Result<PageRect, TexmError> {
+ match policy {
+ PageScalePolicy::FloorOriginCeilEnd => {
+ let x0 = scale_floor(rect.x, target_width, source_width)?;
+ let y0 = scale_floor(rect.y, target_height, source_height)?;
+ let x1 = scale_ceil(
+ rect.x
+ .checked_add(rect.w)
+ .ok_or_else(integer_overflow_error)?,
+ target_width,
+ source_width,
+ )?;
+ let y1 = scale_ceil(
+ rect.y
+ .checked_add(rect.h)
+ .ok_or_else(integer_overflow_error)?,
+ target_height,
+ source_height,
+ )?;
+ Ok(PageRect {
+ x: x0,
+ w: checked_i16(i32::from(x1) - i32::from(x0))?,
+ y: y0,
+ h: checked_i16(i32::from(y1) - i32::from(y0))?,
+ })
+ }
+ }
+}
+
+fn scale_floor(value: i16, numerator: u32, denominator: u32) -> Result<i16, TexmError> {
+ checked_i16(div_floor(
+ i64::from(value) * i64::from(numerator),
+ i64::from(denominator),
+ )?)
+}
+
+fn scale_ceil(value: i16, numerator: u32, denominator: u32) -> Result<i16, TexmError> {
+ checked_i16(div_ceil(
+ i64::from(value) * i64::from(numerator),
+ i64::from(denominator),
+ )?)
+}
+
+fn div_floor(value: i64, divisor: i64) -> Result<i32, TexmError> {
+ let result = if value >= 0 {
+ value / divisor
+ } else {
+ -((-value + divisor - 1) / divisor)
+ };
+ i32::try_from(result).map_err(|_| integer_overflow_error())
+}
+
+fn div_ceil(value: i64, divisor: i64) -> Result<i32, TexmError> {
+ let result = if value >= 0 {
+ (value + divisor - 1) / divisor
+ } else {
+ -((-value) / divisor)
+ };
+ i32::try_from(result).map_err(|_| integer_overflow_error())
+}
+
+fn checked_i16(value: i32) -> Result<i16, TexmError> {
+ i16::try_from(value)
+ .map_err(|_| TexmError::Format(format!("scaled Page rect value out of range: {value}")))
+}
+
+fn parse_texm(payload: &[u8]) -> Result<Texture, TexmError> {
+ if payload.len() < 32 {
+ return Err(TexmError::Format(format!(
+ "Texm payload too small for header: {}",
+ payload.len()
+ )));
+ }
+
+ let magic = read_u32(payload, 0)?;
+ if magic != TEXM_MAGIC {
+ return Err(TexmError::Format(format!(
+ "invalid Texm magic: 0x{magic:08X}"
+ )));
+ }
+
+ let width = read_u32(payload, 4)?;
+ let height = read_u32(payload, 8)?;
+ let mip_count = read_u32(payload, 12)?;
+ let format_raw = read_u32(payload, 28)?;
+
+ if width == 0 || height == 0 {
+ return Err(TexmError::Format(format!(
+ "invalid Texm dimensions: {width}x{height}"
+ )));
+ }
+ if mip_count == 0 {
+ return Err(TexmError::Format(format!(
+ "invalid Texm mip_count={mip_count}"
+ )));
+ }
+
+ let format = DiskPixelFormat::from_raw(format_raw)
+ .ok_or_else(|| TexmError::Format(format!("unknown Texm format={format_raw}")))?;
+ let bytes_per_pixel = format.bytes_per_pixel();
+
+ let mut offset = 32usize;
+ let palette = if format == DiskPixelFormat::Indexed8 {
+ let end = offset
+ .checked_add(1024)
+ .ok_or_else(integer_overflow_error)?;
+ if end > payload.len() {
+ return Err(TexmError::Format(format!(
+ "Texm core data out of bounds: 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(|_| integer_overflow_error())?);
+ let mut w = width;
+ let mut h = height;
+ for _ in 0..mip_count {
+ let pixel_count = u64::from(w)
+ .checked_mul(u64::from(h))
+ .ok_or_else(integer_overflow_error)?;
+ let level_size_u64 = pixel_count
+ .checked_mul(u64::try_from(bytes_per_pixel).map_err(|_| integer_overflow_error())?)
+ .ok_or_else(integer_overflow_error)?;
+ let level_size = usize::try_from(level_size_u64).map_err(|_| integer_overflow_error())?;
+ let level_offset = offset;
+ offset = offset
+ .checked_add(level_size)
+ .ok_or_else(integer_overflow_error)?;
+ if offset > payload.len() {
+ return Err(TexmError::Format(format!(
+ "Texm core data out of bounds: expected_end={offset}, actual_size={}",
+ payload.len()
+ )));
+ }
+ mip_levels.push(MipLevel {
+ width: w,
+ height: h,
+ offset: level_offset,
+ size: level_size,
+ });
+ w = (w >> 1).max(1);
+ h = (h >> 1).max(1);
+ }
+
+ let page_rects = parse_page_tail(payload, offset)?;
+
+ Ok(Texture {
+ header: Header {
+ width,
+ height,
+ format,
+ },
+ palette,
+ mip_levels,
+ page_rects,
+ })
+}
+
+fn decode_mip_rgba8_internal(
+ texture: &Texture,
+ payload: &[u8],
+ mip_index: usize,
+) -> Result<DecodedMip, TexmError> {
+ let Some(level) = texture.mip_levels.get(mip_index).copied() else {
+ return Err(TexmError::MipLevelOutOfRange {
+ requested: u32::try_from(mip_index).unwrap_or(u32::MAX),
+ mip_count: texture.mip_levels.len(),
+ });
+ };
+
+ let end = level
+ .offset
+ .checked_add(level.size)
+ .ok_or(TexmError::MipDataOutOfBounds {
+ offset: level.offset,
+ size: level.size,
+ document_size: payload.len(),
+ })?;
+ let Some(level_data) = payload.get(level.offset..end) else {
+ return Err(TexmError::MipDataOutOfBounds {
+ offset: level.offset,
+ size: level.size,
+ document_size: payload.len(),
+ });
+ };
+
+ let width = usize::try_from(level.width).map_err(|_| integer_overflow_error())?;
+ let height = usize::try_from(level.height).map_err(|_| integer_overflow_error())?;
+ let pixel_count = width
+ .checked_mul(height)
+ .ok_or_else(integer_overflow_error)?;
+ let mut rgba = vec![0u8; pixel_count.saturating_mul(4)];
+
+ match texture.header.format {
+ DiskPixelFormat::Indexed8 => {
+ let palette = texture
+ .palette
+ .as_ref()
+ .ok_or_else(|| TexmError::Format("indexed Texm has no palette".to_string()))?;
+ for (index, palette_index) in level_data.iter().copied().enumerate().take(pixel_count) {
+ let palette_offset = usize::from(palette_index).saturating_mul(4);
+ if palette_offset + 4 > palette.len() {
+ continue;
+ }
+ let out = index.saturating_mul(4);
+ rgba[out] = palette[palette_offset];
+ rgba[out + 1] = palette[palette_offset + 1];
+ rgba[out + 2] = palette[palette_offset + 2];
+ rgba[out + 3] = palette[palette_offset + 3];
+ }
+ }
+ DiskPixelFormat::Rgb565 => decode_words(level_data, pixel_count, &mut rgba, decode_rgb565),
+ DiskPixelFormat::Rgb556 => decode_words(level_data, pixel_count, &mut rgba, decode_rgb556),
+ DiskPixelFormat::Argb4444 => {
+ decode_words(level_data, pixel_count, &mut rgba, decode_argb4444);
+ }
+ DiskPixelFormat::L8A8 => {
+ decode_words(level_data, pixel_count, &mut rgba, decode_luminance_alpha88);
+ }
+ DiskPixelFormat::Rgb888x => {
+ decode_dwords(level_data, pixel_count, &mut rgba, decode_rgb888x);
+ }
+ DiskPixelFormat::Argb8888 => {
+ decode_dwords(level_data, pixel_count, &mut rgba, decode_argb8888);
+ }
+ }
+
+ Ok(DecodedMip {
+ width: level.width,
+ height: level.height,
+ rgba8: rgba,
+ })
+}
+
+fn parse_page_tail(payload: &[u8], core_end: usize) -> Result<Vec<DiskPageRect>, TexmError> {
+ if core_end == payload.len() {
+ return Ok(Vec::new());
+ }
+ if payload.len().saturating_sub(core_end) < 8 {
+ return Err(TexmError::Format(format!(
+ "invalid Page chunk size: expected=8, actual={}",
+ payload.len().saturating_sub(core_end)
+ )));
+ }
+ let magic = read_u32(payload, core_end)?;
+ if magic != PAGE_MAGIC {
+ return Err(TexmError::Format(
+ "Texm tail exists but Page magic is missing".to_string(),
+ ));
+ }
+ let rect_count = read_u32(payload, core_end + 4)?;
+ let rect_count_usize = usize::try_from(rect_count).map_err(|_| integer_overflow_error())?;
+ let expected_size = 8usize
+ .checked_add(
+ rect_count_usize
+ .checked_mul(8)
+ .ok_or_else(integer_overflow_error)?,
+ )
+ .ok_or_else(integer_overflow_error)?;
+ let actual = payload.len().saturating_sub(core_end);
+ if expected_size != actual {
+ return Err(TexmError::Format(format!(
+ "invalid Page chunk size: expected={expected_size}, actual={actual}"
+ )));
+ }
+
+ let mut rects = Vec::with_capacity(rect_count_usize);
+ for index in 0..rect_count_usize {
+ let offset = core_end
+ .checked_add(8)
+ .and_then(|value| value.checked_add(index * 8))
+ .ok_or_else(integer_overflow_error)?;
+ rects.push(DiskPageRect {
+ x: read_i16(payload, offset)?,
+ w: read_i16(payload, offset + 2)?,
+ y: read_i16(payload, offset + 4)?,
+ h: read_i16(payload, offset + 6)?,
+ });
+ }
+ Ok(rects)
+}
+
+fn read_u32(data: &[u8], offset: usize) -> Result<u32, TexmError> {
+ let bytes = data
+ .get(offset..offset + 4)
+ .ok_or_else(integer_overflow_error)?;
+ let arr: [u8; 4] = bytes.try_into().map_err(|_| integer_overflow_error())?;
+ Ok(u32::from_le_bytes(arr))
+}
+
+fn read_i16(data: &[u8], offset: usize) -> Result<i16, TexmError> {
+ let bytes = data
+ .get(offset..offset + 2)
+ .ok_or_else(integer_overflow_error)?;
+ let arr: [u8; 2] = bytes.try_into().map_err(|_| integer_overflow_error())?;
+ Ok(i16::from_le_bytes(arr))
+}
+
+fn decode_words(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u16) -> [u8; 4]) {
+ for index in 0..pixel_count {
+ let offset = index.saturating_mul(2);
+ let Some(bytes) = data.get(offset..offset + 2) else {
+ break;
+ };
+ let word = u16::from_le_bytes([bytes[0], bytes[1]]);
+ let pixel = decode(word);
+ let out = index.saturating_mul(4);
+ rgba[out..out + 4].copy_from_slice(&pixel);
+ }
+}
+
+fn decode_dwords(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u32) -> [u8; 4]) {
+ for index in 0..pixel_count {
+ let offset = index.saturating_mul(4);
+ let Some(bytes) = data.get(offset..offset + 4) else {
+ break;
+ };
+ let dword = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
+ let pixel = decode(dword);
+ let out = index.saturating_mul(4);
+ rgba[out..out + 4].copy_from_slice(&pixel);
+ }
+}
+
+fn expand5(value: u16) -> u8 {
+ u8::try_from((u32::from(value) * 255 + 15) / 31).unwrap_or(u8::MAX)
+}
+
+fn expand6(value: u16) -> u8 {
+ u8::try_from((u32::from(value) * 255 + 31) / 63).unwrap_or(u8::MAX)
+}
+
+fn expand4(value: u16) -> u8 {
+ u8::try_from(u32::from(value) * 17).unwrap_or(u8::MAX)
+}
+
+fn decode_rgb565(word: u16) -> [u8; 4] {
+ let red = expand5((word >> 11) & 0x1F);
+ let green = expand6((word >> 5) & 0x3F);
+ let blue = expand5(word & 0x1F);
+ [red, green, blue, 255]
+}
+
+fn decode_rgb556(word: u16) -> [u8; 4] {
+ let red = expand5((word >> 11) & 0x1F);
+ let green = expand5((word >> 6) & 0x1F);
+ let blue = expand6(word & 0x3F);
+ [red, green, blue, 255]
+}
+
+fn decode_argb4444(word: u16) -> [u8; 4] {
+ let alpha = expand4((word >> 12) & 0x0F);
+ let red = expand4((word >> 8) & 0x0F);
+ let green = expand4((word >> 4) & 0x0F);
+ let blue = expand4(word & 0x0F);
+ [red, green, blue, alpha]
+}
+
+fn decode_luminance_alpha88(word: u16) -> [u8; 4] {
+ let luminance = u8::try_from((word >> 8) & 0xFF).unwrap_or(u8::MAX);
+ let alpha = u8::try_from(word & 0xFF).unwrap_or(u8::MAX);
+ [luminance, luminance, luminance, alpha]
+}
+
+fn decode_rgb888x(dword: u32) -> [u8; 4] {
+ let red = u8::try_from(dword & 0xFF).unwrap_or(u8::MAX);
+ let green = u8::try_from((dword >> 8) & 0xFF).unwrap_or(u8::MAX);
+ let blue = u8::try_from((dword >> 16) & 0xFF).unwrap_or(u8::MAX);
+ [red, green, blue, 255]
+}
+
+fn decode_argb8888(dword: u32) -> [u8; 4] {
+ let alpha = u8::try_from(dword & 0xFF).unwrap_or(u8::MAX);
+ let red = u8::try_from((dword >> 8) & 0xFF).unwrap_or(u8::MAX);
+ let green = u8::try_from((dword >> 16) & 0xFF).unwrap_or(u8::MAX);
+ let blue = u8::try_from((dword >> 24) & 0xFF).unwrap_or(u8::MAX);
+ [red, green, blue, alpha]
+}
+
+fn integer_overflow_error() -> TexmError {
+ TexmError::Format("integer overflow".to_string())
+}
+
+/// Returns migration status.
+#[must_use]
+pub fn migration_facade_ready() -> bool {
+ true
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_nres::ReadProfile;
+ use std::path::{Path, PathBuf};
+
+ const TEXM_MAGIC: u32 = 0x6D78_6554;
+
+ #[test]
+ fn decodes_all_synthetic_formats() {
+ let cases = [
+ (0, PixelFormat::Indexed8, indexed_payload()),
+ (
+ 565,
+ PixelFormat::Rgb565,
+ payload(1, 1, 565, &[&0xFFE0_u16.to_le_bytes()]),
+ ),
+ (
+ 556,
+ PixelFormat::Rgb556,
+ payload(1, 1, 556, &[&0xF800_u16.to_le_bytes()]),
+ ),
+ (
+ 4444,
+ PixelFormat::Argb4444,
+ payload(1, 1, 4444, &[&0xF12E_u16.to_le_bytes()]),
+ ),
+ (
+ 88,
+ PixelFormat::L8A8,
+ payload(1, 1, 88, &[&0x7F40_u16.to_le_bytes()]),
+ ),
+ (
+ 888,
+ PixelFormat::Rgb888x,
+ payload(1, 1, 888, &[&[0x11, 0x22, 0x33, 0x99]]),
+ ),
+ (
+ 8888,
+ PixelFormat::Argb8888,
+ payload(1, 1, 8888, &[&[0x40, 0x11, 0x22, 0x33]]),
+ ),
+ ];
+
+ for (raw, expected, bytes) in cases {
+ let document = decode_texm(Arc::from(bytes.into_boxed_slice()))
+ .unwrap_or_else(|err| panic!("format {raw}: {err}"));
+ assert_eq!(document.format(), expected);
+ assert_eq!(document.mip_count(), 1);
+ let rgba =
+ decode_mip_rgba8(&document, 0).unwrap_or_else(|err| panic!("format {raw}: {err}"));
+ assert_eq!(rgba.width, 1);
+ assert_eq!(rgba.height, 1);
+ assert_eq!(rgba.rgba8.len(), 4);
+ }
+ }
+
+ #[test]
+ fn rejects_zero_dimensions() {
+ let err = decode_texm(Arc::from(
+ payload(0, 1, 8888, &[&[0, 0, 0, 0]]).into_boxed_slice(),
+ ))
+ .expect_err("zero width");
+ assert!(matches!(err, TexmError::Format(_)));
+ }
+
+ #[test]
+ fn non_power_of_two_mip_chain_clamps_each_dimension() {
+ let bytes = payload(3, 2, 8888, &[&[0; 3 * 2 * 4], &[1, 2, 3, 4], &[5, 6, 7, 8]]);
+ let document = decode_texm(Arc::from(bytes.into_boxed_slice())).expect("document");
+
+ assert_eq!(document.mip_level(0).expect("mip 0").width, 3);
+ assert_eq!(document.mip_level(0).expect("mip 0").height, 2);
+ assert_eq!(document.mip_level(1).expect("mip 1").width, 1);
+ assert_eq!(document.mip_level(1).expect("mip 1").height, 1);
+ assert_eq!(document.mip_level(2).expect("mip 2").width, 1);
+ assert_eq!(document.mip_level(2).expect("mip 2").height, 1);
+ }
+
+ #[test]
+ fn rejects_mip_size_arithmetic_overflow_or_oob() {
+ let err = decode_texm(Arc::from(
+ header(u32::MAX, u32::MAX, 1, 8888).into_boxed_slice(),
+ ))
+ .expect_err("huge mip");
+
+ assert!(matches!(err, TexmError::Format(_)));
+ }
+
+ #[test]
+ fn indexed_palette_requires_exact_1024_bytes() {
+ let mut bytes = indexed_payload();
+ bytes.remove(32 + 1023);
+
+ let err = decode_texm(Arc::from(bytes.into_boxed_slice())).expect_err("short palette");
+
+ assert!(matches!(err, TexmError::Format(_)));
+ }
+
+ #[test]
+ fn channel_expansion_boundary_values_are_stable() {
+ let document = decode_texm(Arc::from(
+ payload(2, 1, 565, &[&[0x00, 0x00, 0xFF, 0xFF]]).into_boxed_slice(),
+ ))
+ .expect("rgb565 document");
+ let rgba = decode_mip_rgba8(&document, 0).expect("rgba");
+
+ assert_eq!(rgba.rgba8, vec![0, 0, 0, 255, 255, 255, 255, 255]);
+ }
+
+ #[test]
+ fn rgb888x_preserves_fourth_disk_byte_but_outputs_opaque_alpha() {
+ let document = decode_texm(Arc::from(
+ payload(1, 1, 888, &[&[0x11, 0x22, 0x33, 0x99]]).into_boxed_slice(),
+ ))
+ .expect("rgb888x document");
+
+ assert_eq!(
+ document.mip_level(0).expect("mip").bytes,
+ &[0x11, 0x22, 0x33, 0x99]
+ );
+ assert_eq!(
+ decode_mip_rgba8(&document, 0).expect("rgba").rgba8,
+ vec![0x11, 0x22, 0x33, 0xFF]
+ );
+ }
+
+ #[test]
+ fn page_tail_absent_and_exact_rect_framing() {
+ let absent = decode_texm(Arc::from(
+ payload(1, 1, 8888, &[&[0, 0, 0, 0]]).into_boxed_slice(),
+ ))
+ .expect("page absent");
+ assert!(absent.page_rects().is_empty());
+
+ let mut bytes = payload(1, 1, 8888, &[&[0, 0, 0, 0]]);
+ push_page_tail(&mut bytes, &[(1, 2, 3, 4)]);
+ let document = decode_texm(Arc::from(bytes.into_boxed_slice())).expect("page rect");
+
+ assert_eq!(
+ document.page_rects(),
+ vec![PageRect {
+ x: 1,
+ w: 2,
+ y: 3,
+ h: 4,
+ }]
+ );
+ }
+
+ #[test]
+ fn invalid_page_magic_size_and_trailing_bytes_are_rejected() {
+ let mut missing_magic = payload(1, 1, 8888, &[&[0, 0, 0, 0]]);
+ missing_magic.extend_from_slice(b"tail");
+ assert!(decode_texm(Arc::from(missing_magic.into_boxed_slice())).is_err());
+
+ let mut wrong_size = payload(1, 1, 8888, &[&[0, 0, 0, 0]]);
+ wrong_size.extend_from_slice(&PAGE_MAGIC.to_le_bytes());
+ wrong_size.extend_from_slice(&2_u32.to_le_bytes());
+ wrong_size.extend_from_slice(&[0; 8]);
+ assert!(decode_texm(Arc::from(wrong_size.into_boxed_slice())).is_err());
+ }
+
+ #[test]
+ fn exposes_mip_views_and_upload_plan_without_mutating_document() {
+ let bytes = payload(2, 1, 8888, &[&[1, 2, 3, 4, 5, 6, 7, 8], &[9, 10, 11, 12]]);
+ let original = bytes.clone();
+ let document = decode_texm(Arc::from(bytes.into_boxed_slice())).expect("document");
+
+ let mip1 = document.mip_level(1).expect("mip 1");
+ assert_eq!(mip1.width, 1);
+ assert_eq!(mip1.height, 1);
+ assert_eq!(mip1.bytes, &[9, 10, 11, 12]);
+ let plan = plan_upload(&document, MipSkipPolicy { skip_top_levels: 1 }).expect("plan");
+ assert_eq!(plan.mips.len(), 1);
+ assert_eq!(plan.mips[0].level, 1);
+ assert_eq!(&document.bytes[..], &original[..]);
+ }
+
+ #[test]
+ fn page_scaling_uses_floor_origin_and_ceil_end_policy() {
+ let mut bytes = payload(5, 3, 8888, &[&[0; 5 * 3 * 4], &[0; 2 * 1 * 4]]);
+ push_page_tail(&mut bytes, &[(1, 3, 1, 2)]);
+ let document = decode_texm(Arc::from(bytes.into_boxed_slice())).expect("document");
+
+ assert_eq!(
+ scaled_page_rects(&document, 1, PageScalePolicy::FloorOriginCeilEnd).expect("scaled"),
+ vec![PageRect {
+ x: 0,
+ w: 2,
+ y: 0,
+ h: 1,
+ }]
+ );
+ assert_eq!(
+ plan_upload(&document, MipSkipPolicy { skip_top_levels: 1 })
+ .expect("plan")
+ .page_rects,
+ vec![PageRect {
+ x: 1,
+ w: 3,
+ y: 1,
+ h: 2,
+ }]
+ );
+ }
+
+ #[test]
+ fn arbitrary_texm_payloads_do_not_panic() {
+ for len in 0..128usize {
+ let mut bytes = vec![0xCC; len];
+ if len >= 4 {
+ bytes[0..4].copy_from_slice(&TEXM_MAGIC.to_le_bytes());
+ }
+ let result = std::panic::catch_unwind(|| {
+ let _ = decode_texm(Arc::from(bytes.into_boxed_slice()));
+ });
+ assert!(result.is_ok());
+ }
+ }
+
+ #[test]
+ fn licensed_corpus_texm_assets_validate_and_decode_mip0() {
+ for (corpus, expected) in [("IS", 518_usize), ("IS2", 631_usize)] {
+ let Some(root) = corpus_root(corpus) else {
+ continue;
+ };
+ let mut count = 0usize;
+ for path in files_under(&root) {
+ let Ok(bytes) = std::fs::read(&path) else {
+ continue;
+ };
+ let Ok(archive) = fparkan_nres::decode(
+ Arc::from(bytes.into_boxed_slice()),
+ ReadProfile::Compatible,
+ ) else {
+ continue;
+ };
+ for entry in archive
+ .entries()
+ .iter()
+ .filter(|entry| entry.meta().type_id == TEXM_MAGIC)
+ {
+ let payload = archive.payload(entry.id()).expect("payload");
+ let document = decode_texm(Arc::from(payload.to_vec().into_boxed_slice()))
+ .unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
+ });
+ decode_mip_rgba8(&document, 0).unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
+ });
+ count += 1;
+ }
+ }
+ assert_eq!(count, expected, "{corpus} Texm count");
+ }
+ }
+
+ fn indexed_payload() -> Vec<u8> {
+ let mut palette = [0_u8; 1024];
+ palette[4..8].copy_from_slice(&[10, 20, 30, 255]);
+ let mut out = header(1, 1, 1, 0);
+ out.extend_from_slice(&palette);
+ out.push(1);
+ out
+ }
+
+ fn payload(width: u32, height: u32, format: u32, mip_levels: &[&[u8]]) -> Vec<u8> {
+ let mut out = header(
+ width,
+ height,
+ u32::try_from(mip_levels.len()).expect("mip count"),
+ format,
+ );
+ for level in mip_levels {
+ out.extend_from_slice(level);
+ }
+ out
+ }
+
+ fn header(width: u32, height: u32, mip_count: u32, format: u32) -> Vec<u8> {
+ let mut out = Vec::new();
+ out.extend_from_slice(&TEXM_MAGIC.to_le_bytes());
+ out.extend_from_slice(&width.to_le_bytes());
+ out.extend_from_slice(&height.to_le_bytes());
+ out.extend_from_slice(&mip_count.to_le_bytes());
+ out.extend_from_slice(&0_u32.to_le_bytes());
+ out.extend_from_slice(&0_u32.to_le_bytes());
+ out.extend_from_slice(&0_u32.to_le_bytes());
+ out.extend_from_slice(&format.to_le_bytes());
+ out
+ }
+
+ fn push_page_tail(out: &mut Vec<u8>, rects: &[(i16, i16, i16, i16)]) {
+ out.extend_from_slice(&PAGE_MAGIC.to_le_bytes());
+ out.extend_from_slice(
+ &u32::try_from(rects.len())
+ .expect("rect count")
+ .to_le_bytes(),
+ );
+ for (x, w, y, h) in rects {
+ out.extend_from_slice(&x.to_le_bytes());
+ out.extend_from_slice(&w.to_le_bytes());
+ out.extend_from_slice(&y.to_le_bytes());
+ out.extend_from_slice(&h.to_le_bytes());
+ }
+ }
+
+ fn corpus_root(name: &str) -> Option<PathBuf> {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("testdata")
+ .join(name);
+ root.is_dir().then_some(root)
+ }
+
+ fn files_under(root: &Path) -> Vec<PathBuf> {
+ let mut out = Vec::new();
+ let mut stack = vec![root.to_path_buf()];
+ while let Some(path) = stack.pop() {
+ let Ok(read_dir) = std::fs::read_dir(path) else {
+ continue;
+ };
+ for entry in read_dir.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ stack.push(path);
+ } else {
+ out.push(path);
+ }
+ }
+ }
+ out.sort();
+ out
+ }
+}
diff --git a/crates/fparkan-vfs/Cargo.toml b/crates/fparkan-vfs/Cargo.toml
new file mode 100644
index 0000000..90239c2
--- /dev/null
+++ b/crates/fparkan-vfs/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "fparkan-vfs"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+fparkan-path = { path = "../fparkan-path" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-vfs/src/lib.rs b/crates/fparkan-vfs/src/lib.rs
new file mode 100644
index 0000000..dd71670
--- /dev/null
+++ b/crates/fparkan-vfs/src/lib.rs
@@ -0,0 +1,456 @@
+#![forbid(unsafe_code)]
+//! Virtual filesystem ports for resource loading.
+
+use fparkan_path::{join_under, NormalizedPath};
+use std::collections::BTreeMap;
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+/// VFS metadata.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct VfsMetadata {
+ /// Byte length.
+ pub len: u64,
+ /// Stable-enough source fingerprint for cache invalidation.
+ pub fingerprint: u64,
+}
+
+/// VFS entry.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct VfsEntry {
+ /// Path.
+ pub path: NormalizedPath,
+ /// Metadata.
+ pub metadata: VfsMetadata,
+}
+
+/// VFS error.
+#[derive(Debug)]
+pub enum VfsError {
+ /// Missing entry.
+ NotFound(String),
+ /// Ambiguous host path.
+ Ambiguous(String),
+ /// I/O error.
+ Io(std::io::Error),
+ /// Invalid path.
+ Path,
+}
+
+impl std::fmt::Display for VfsError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::NotFound(path) => write!(f, "not found: {path}"),
+ Self::Ambiguous(path) => write!(f, "ambiguous host path: {path}"),
+ Self::Io(err) => write!(f, "{err}"),
+ Self::Path => write!(f, "invalid path"),
+ }
+ }
+}
+
+impl std::error::Error for VfsError {}
+
+/// Resource VFS.
+pub trait Vfs: Send + Sync {
+ /// Reads metadata.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`VfsError`] when the path is invalid, missing, or cannot be
+ /// inspected by the backing store.
+ fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError>;
+ /// Reads bytes.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`VfsError`] when the path is invalid, missing, or cannot be
+ /// read by the backing store.
+ fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError>;
+ /// Lists entries below prefix.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`VfsError`] when the prefix is invalid, missing, or cannot be
+ /// traversed by the backing store.
+ fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError>;
+}
+
+/// Host directory VFS.
+#[derive(Clone, Debug)]
+pub struct DirectoryVfs {
+ root: PathBuf,
+}
+
+impl DirectoryVfs {
+ /// Creates a directory VFS.
+ #[must_use]
+ pub fn new(root: impl AsRef<Path>) -> Self {
+ Self {
+ root: root.as_ref().to_path_buf(),
+ }
+ }
+
+ fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> {
+ let exact = join_under(&self.root, path).map_err(|_| VfsError::Path)?;
+ if exact.exists() {
+ return Ok(exact);
+ }
+ resolve_casefolded(&self.root, path.as_str())
+ }
+}
+
+impl Vfs for DirectoryVfs {
+ fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
+ let meta = fs::metadata(self.host_path(path)?).map_err(VfsError::Io)?;
+ Ok(metadata_from_fs(&meta))
+ }
+
+ fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
+ let bytes = fs::read(self.host_path(path)?).map_err(VfsError::Io)?;
+ Ok(Arc::from(bytes.into_boxed_slice()))
+ }
+
+ fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
+ let base = self.host_path(prefix)?;
+ let mut entries = Vec::new();
+ if base.is_file() {
+ let metadata = fs::metadata(&base).map_err(VfsError::Io)?;
+ entries.push(VfsEntry {
+ path: prefix.clone(),
+ metadata: metadata_from_fs(&metadata),
+ });
+ return Ok(entries);
+ }
+ list_recursive(&self.root, &base, &mut entries)?;
+ entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
+ Ok(entries)
+ }
+}
+
+fn resolve_casefolded(root: &Path, normalized: &str) -> Result<PathBuf, VfsError> {
+ let mut current = root.to_path_buf();
+ for segment in normalized.split('/') {
+ let read_dir = fs::read_dir(&current).map_err(VfsError::Io)?;
+ let mut matches = Vec::new();
+ for entry in read_dir {
+ let entry = entry.map_err(VfsError::Io)?;
+ let name = entry.file_name();
+ let Some(name) = name.to_str() else {
+ continue;
+ };
+ if name.eq_ignore_ascii_case(segment) {
+ matches.push(entry.path());
+ }
+ }
+ current = select_casefolded_match(normalized, &current, segment, matches)?;
+ }
+ Ok(current)
+}
+
+fn select_casefolded_match(
+ normalized: &str,
+ current: &Path,
+ segment: &str,
+ mut matches: Vec<PathBuf>,
+) -> Result<PathBuf, VfsError> {
+ matches.sort();
+ match matches.len() {
+ 0 => Err(VfsError::NotFound(normalized.to_string())),
+ 1 => Ok(matches.remove(0)),
+ _ => Err(VfsError::Ambiguous(format!(
+ "{}/{}",
+ current.display(),
+ segment
+ ))),
+ }
+}
+
+fn list_recursive(root: &Path, dir: &Path, out: &mut Vec<VfsEntry>) -> Result<(), VfsError> {
+ let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?;
+ let mut children = Vec::new();
+ for entry in read_dir {
+ let entry = entry.map_err(VfsError::Io)?;
+ children.push(entry.path());
+ }
+ children.sort();
+ for child in children {
+ let metadata = fs::metadata(&child).map_err(VfsError::Io)?;
+ if metadata.is_dir() {
+ list_recursive(root, &child, out)?;
+ continue;
+ }
+ if !metadata.is_file() {
+ continue;
+ }
+ let rel = child.strip_prefix(root).map_err(|_| VfsError::Path)?;
+ let rel_text = rel.to_str().ok_or(VfsError::Path)?;
+ let path = fparkan_path::normalize_relative(
+ rel_text.as_bytes(),
+ fparkan_path::PathPolicy::HostCompatible,
+ )
+ .map_err(|_| VfsError::Path)?;
+ out.push(VfsEntry {
+ path,
+ metadata: metadata_from_fs(&metadata),
+ });
+ }
+ Ok(())
+}
+
+fn metadata_from_fs(metadata: &fs::Metadata) -> VfsMetadata {
+ let mut fingerprint = 0xcbf2_9ce4_8422_2325;
+ hash_u64(&mut fingerprint, metadata.len());
+ if let Ok(modified) = metadata.modified() {
+ if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
+ hash_u64(&mut fingerprint, duration.as_secs());
+ hash_u64(&mut fingerprint, u64::from(duration.subsec_nanos()));
+ }
+ }
+ VfsMetadata {
+ len: metadata.len(),
+ fingerprint,
+ }
+}
+
+/// In-memory VFS.
+#[derive(Clone, Debug, Default)]
+pub struct MemoryVfs {
+ files: BTreeMap<String, Arc<[u8]>>,
+}
+
+impl MemoryVfs {
+ /// Inserts a file.
+ #[allow(clippy::needless_pass_by_value)]
+ pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) {
+ self.files.insert(path.as_str().to_string(), bytes);
+ }
+}
+
+impl Vfs for MemoryVfs {
+ fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
+ let bytes = self
+ .files
+ .get(path.as_str())
+ .ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
+ Ok(VfsMetadata {
+ len: bytes.len() as u64,
+ fingerprint: stable_hash(bytes),
+ })
+ }
+
+ fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
+ self.files
+ .get(path.as_str())
+ .cloned()
+ .ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))
+ }
+
+ fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
+ let mut out = Vec::new();
+ for (path, bytes) in &self.files {
+ if path
+ .as_bytes()
+ .get(..prefix.as_str().len())
+ .is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_str().as_bytes()))
+ {
+ let normalized = fparkan_path::normalize_relative(
+ path.as_bytes(),
+ fparkan_path::PathPolicy::StrictLegacy,
+ )
+ .map_err(|_| VfsError::Path)?;
+ out.push(VfsEntry {
+ path: normalized,
+ metadata: VfsMetadata {
+ len: bytes.len() as u64,
+ fingerprint: stable_hash(bytes),
+ },
+ });
+ }
+ }
+ Ok(out)
+ }
+}
+
+fn stable_hash(bytes: &[u8]) -> u64 {
+ let mut state = 0xcbf2_9ce4_8422_2325;
+ for byte in bytes {
+ state ^= u64::from(*byte);
+ state = state.wrapping_mul(0x0000_0100_0000_01b3);
+ }
+ state
+}
+
+fn hash_u64(state: &mut u64, value: u64) {
+ for byte in value.to_le_bytes() {
+ *state ^= u64::from(byte);
+ *state = state.wrapping_mul(0x0000_0100_0000_01b3);
+ }
+}
+
+/// Layered VFS with deterministic first-layer precedence.
+#[derive(Clone, Default)]
+pub struct OverlayVfs {
+ layers: Vec<Arc<dyn Vfs>>,
+}
+
+impl std::fmt::Debug for OverlayVfs {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("OverlayVfs")
+ .field("layers", &self.layers.len())
+ .finish()
+ }
+}
+
+impl OverlayVfs {
+ /// Creates an empty overlay.
+ #[must_use]
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Creates an overlay from ordered layers.
+ #[must_use]
+ pub fn from_layers(layers: Vec<Arc<dyn Vfs>>) -> Self {
+ Self { layers }
+ }
+
+ /// Appends a lower-priority layer.
+ pub fn push_layer(&mut self, layer: Arc<dyn Vfs>) {
+ self.layers.push(layer);
+ }
+}
+
+impl Vfs for OverlayVfs {
+ fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
+ for layer in &self.layers {
+ match layer.metadata(path) {
+ Ok(metadata) => return Ok(metadata),
+ Err(VfsError::NotFound(_)) => {}
+ Err(err) => return Err(err),
+ }
+ }
+ Err(VfsError::NotFound(path.as_str().to_string()))
+ }
+
+ fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
+ for layer in &self.layers {
+ match layer.read(path) {
+ Ok(bytes) => return Ok(bytes),
+ Err(VfsError::NotFound(_)) => {}
+ Err(err) => return Err(err),
+ }
+ }
+ Err(VfsError::NotFound(path.as_str().to_string()))
+ }
+
+ fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
+ let mut by_key = BTreeMap::new();
+ for layer in &self.layers {
+ match layer.list(prefix) {
+ Ok(entries) => {
+ for entry in entries {
+ let key = entry.path.as_str().to_ascii_uppercase();
+ by_key.entry(key).or_insert(entry);
+ }
+ }
+ Err(VfsError::NotFound(_)) => {}
+ Err(err) => return Err(err),
+ }
+ }
+ let mut entries: Vec<_> = by_key.into_values().collect();
+ entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
+ Ok(entries)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_path::{normalize_relative, PathPolicy};
+
+ #[test]
+ fn directory_vfs_resolves_ascii_casefolded_segments() {
+ let root = unique_test_dir("casefold");
+ let dir = root.join("data").join("MAPS").join("Tut_1");
+ std::fs::create_dir_all(&dir).expect("mkdir");
+ std::fs::write(dir.join("Land.msh"), b"mesh").expect("write");
+
+ let vfs = DirectoryVfs::new(&root);
+ let path = normalize_relative(b"DATA/maps/tut_1/land.MSH", PathPolicy::StrictLegacy)
+ .expect("path");
+ assert_eq!(vfs.read(&path).expect("read").as_ref(), b"mesh");
+
+ std::fs::remove_dir_all(root).expect("cleanup");
+ }
+
+ #[test]
+ fn directory_vfs_lists_files_below_prefix() {
+ let root = unique_test_dir("list");
+ std::fs::create_dir_all(root.join("DATA").join("MAPS")).expect("mkdir");
+ std::fs::write(root.join("DATA").join("MAPS").join("Land.map"), b"map").expect("write");
+ std::fs::write(root.join("BuildDat.lst"), b"build").expect("write");
+
+ let vfs = DirectoryVfs::new(&root);
+ let prefix = normalize_relative(b"data", PathPolicy::StrictLegacy).expect("prefix");
+ let entries = vfs.list(&prefix).expect("list");
+ assert_eq!(entries.len(), 1);
+ assert!(entries[0]
+ .path
+ .as_str()
+ .eq_ignore_ascii_case("DATA/MAPS/Land.map"));
+
+ std::fs::remove_dir_all(root).expect("cleanup");
+ }
+
+ #[test]
+ fn casefold_selector_reports_ambiguous_segments() {
+ let err = select_casefolded_match(
+ "data/file.bin",
+ Path::new("/game"),
+ "data",
+ vec![PathBuf::from("/game/Data"), PathBuf::from("/game/DATA")],
+ )
+ .expect_err("ambiguous path");
+
+ assert!(matches!(err, VfsError::Ambiguous(_)));
+ }
+
+ #[test]
+ fn memory_vfs_uses_exact_lookup() {
+ let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path");
+ let mut vfs = MemoryVfs::default();
+ vfs.insert(path.clone(), Arc::from(b"payload".as_slice()));
+
+ assert_eq!(vfs.metadata(&path).expect("metadata").len, 7);
+ assert_eq!(vfs.read(&path).expect("read").as_ref(), b"payload");
+
+ let other_case =
+ normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("path");
+ assert!(matches!(vfs.read(&other_case), Err(VfsError::NotFound(_))));
+ }
+
+ #[test]
+ fn overlay_vfs_uses_first_matching_layer() {
+ let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path");
+ let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
+ let mut high = MemoryVfs::default();
+ let mut low = MemoryVfs::default();
+ high.insert(path.clone(), Arc::from(b"high".as_slice()));
+ low.insert(path.clone(), Arc::from(b"low".as_slice()));
+
+ let overlay = OverlayVfs::from_layers(vec![Arc::new(high), Arc::new(low)]);
+
+ assert_eq!(overlay.read(&path).expect("read").as_ref(), b"high");
+ let entries = overlay.list(&prefix).expect("list");
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].metadata.len, 4);
+ }
+
+ fn unique_test_dir(name: &str) -> PathBuf {
+ let mut path = std::env::temp_dir();
+ path.push(format!("fparkan-vfs-{name}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&path);
+ path
+ }
+}
diff --git a/crates/fparkan-world/Cargo.toml b/crates/fparkan-world/Cargo.toml
new file mode 100644
index 0000000..e336d07
--- /dev/null
+++ b/crates/fparkan-world/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "fparkan-world"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-world/src/lib.rs b/crates/fparkan-world/src/lib.rs
new file mode 100644
index 0000000..58412d9
--- /dev/null
+++ b/crates/fparkan-world/src/lib.rs
@@ -0,0 +1,840 @@
+#![forbid(unsafe_code)]
+//! Deterministic world identity, queue, lifecycle, and snapshots.
+
+use std::collections::VecDeque;
+
+/// Object handle with generation.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub struct ObjectHandle {
+ /// Generation.
+ pub generation: u32,
+ /// Slot.
+ pub slot: u32,
+}
+
+/// Original mission object id.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub struct OriginalObjectId(pub u32);
+
+/// Owner id.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub struct OwnerId(pub u16);
+
+/// Tick.
+#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
+pub struct Tick(pub u64);
+
+/// State hash.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct StateHash(pub [u8; 32]);
+
+/// World phase.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum WorldPhase {
+ /// Idle.
+ Idle,
+ /// Calculating.
+ Calculating,
+ /// Applying deferred operations.
+ ApplyingDeferred,
+ /// Publishing snapshot.
+ PublishingSnapshot,
+}
+
+/// Object draft.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct ObjectDraft {
+ /// Original id.
+ pub original_id: Option<OriginalObjectId>,
+}
+
+/// Distinct object identity metadata.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct IdentityMetadata {
+ /// Original mission object id.
+ pub original_id: Option<OriginalObjectId>,
+ /// Mirrored original id.
+ pub mirror_id: Option<OriginalObjectId>,
+ /// Local owner id.
+ pub owner_id: Option<OwnerId>,
+}
+
+/// World command.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct WorldCommand {
+ /// Sequence.
+ pub sequence: u64,
+ /// Target.
+ pub target: Option<ObjectHandle>,
+}
+
+/// World event.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct WorldEvent {
+ /// Sequence.
+ pub sequence: u64,
+ /// Target object, if any.
+ pub target: Option<ObjectHandle>,
+}
+
+/// Input snapshot.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct InputSnapshot;
+
+/// World snapshot.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct WorldSnapshot {
+ /// Tick.
+ pub tick: Tick,
+ /// Live object handles.
+ pub objects: Vec<ObjectHandle>,
+ /// Commands processed during this step.
+ pub events: Vec<WorldEvent>,
+ /// State hash.
+ pub hash: StateHash,
+}
+
+/// World configuration.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct WorldConfig;
+
+/// Fixed-step clock state.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct FixedStepClock {
+ accumulated_millis: u64,
+ tick: Tick,
+ paused: bool,
+ platform_event_collections: u64,
+}
+
+/// Fixed-step configuration.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct FixedStepConfig {
+ /// Milliseconds per simulation tick.
+ pub step_millis: u32,
+}
+
+impl Default for FixedStepConfig {
+ fn default() -> Self {
+ Self { step_millis: 16 }
+ }
+}
+
+/// Shutdown ordering report.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ShutdownReport {
+ /// Object handles released before managers.
+ pub released_objects: Vec<ObjectHandle>,
+ /// Whether managers were released after objects.
+ pub managers_released: bool,
+}
+
+#[derive(Clone, Debug)]
+struct Slot {
+ generation: u32,
+ live: bool,
+ registered: bool,
+ original_id: Option<OriginalObjectId>,
+ owner_id: Option<OwnerId>,
+ mirror_id: Option<OriginalObjectId>,
+ registration_sequence: Option<u64>,
+}
+
+/// World.
+#[derive(Clone, Debug)]
+pub struct World {
+ slots: Vec<Slot>,
+ queue: VecDeque<WorldCommand>,
+ deferred_delete: Vec<ObjectHandle>,
+ phase: WorldPhase,
+ tick: Tick,
+ next_sequence: u64,
+ next_registration_sequence: u64,
+}
+
+/// World error.
+#[derive(Debug, Eq, PartialEq)]
+pub enum WorldError {
+ /// Invalid handle.
+ InvalidHandle,
+ /// Stale handle.
+ StaleHandle,
+ /// Object already deleted.
+ Deleted,
+ /// Duplicate original object id.
+ DuplicateOriginalObjectId(OriginalObjectId),
+ /// Invalid fixed-step configuration.
+ InvalidFixedStep,
+}
+
+impl std::fmt::Display for WorldError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{self:?}")
+ }
+}
+
+impl std::error::Error for WorldError {}
+
+/// Creates a world.
+#[must_use]
+pub fn new(_config: WorldConfig) -> World {
+ World {
+ slots: Vec::new(),
+ queue: VecDeque::new(),
+ deferred_delete: Vec::new(),
+ phase: WorldPhase::Idle,
+ tick: Tick(0),
+ next_sequence: 0,
+ next_registration_sequence: 0,
+ }
+}
+
+/// Constructs an object without registering it.
+///
+/// # Errors
+///
+/// Returns [`WorldError::InvalidHandle`] if the slot index cannot be
+/// represented by an [`ObjectHandle`].
+pub fn construct_object(world: &mut World, draft: ObjectDraft) -> Result<ObjectHandle, WorldError> {
+ let slot = u32::try_from(world.slots.len()).map_err(|_| WorldError::InvalidHandle)?;
+ let handle = ObjectHandle {
+ generation: 1,
+ slot,
+ };
+ world.slots.push(Slot {
+ generation: 1,
+ live: true,
+ registered: false,
+ original_id: draft.original_id,
+ owner_id: None,
+ mirror_id: None,
+ registration_sequence: None,
+ });
+ Ok(handle)
+}
+
+/// Registers a constructed object.
+///
+/// # Errors
+///
+/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
+pub fn register_object(world: &mut World, handle: ObjectHandle) -> Result<(), WorldError> {
+ let original_id = checked_slot(world, handle)?.original_id;
+ if let Some(original_id) = original_id {
+ let duplicate = world.slots.iter().enumerate().any(|(idx, slot)| {
+ u32::try_from(idx).is_ok_and(|slot_index| slot_index != handle.slot)
+ && slot.live
+ && slot.registered
+ && slot.original_id == Some(original_id)
+ });
+ if duplicate {
+ return Err(WorldError::DuplicateOriginalObjectId(original_id));
+ }
+ }
+ let sequence = world.next_registration_sequence;
+ world.next_registration_sequence = world.next_registration_sequence.saturating_add(1);
+ let slot = checked_slot_mut(world, handle)?;
+ slot.registered = true;
+ slot.registration_sequence = Some(sequence);
+ Ok(())
+}
+
+/// Attaches local ownership metadata to an object.
+///
+/// # Errors
+///
+/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
+pub fn set_owner(
+ world: &mut World,
+ handle: ObjectHandle,
+ owner_id: Option<OwnerId>,
+) -> Result<(), WorldError> {
+ checked_slot_mut(world, handle)?.owner_id = owner_id;
+ Ok(())
+}
+
+/// Attaches mirror metadata to an object without changing its original id.
+///
+/// # Errors
+///
+/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
+pub fn set_mirror_original(
+ world: &mut World,
+ handle: ObjectHandle,
+ mirror_id: Option<OriginalObjectId>,
+) -> Result<(), WorldError> {
+ checked_slot_mut(world, handle)?.mirror_id = mirror_id;
+ Ok(())
+}
+
+/// Returns registration sequence for a live object.
+///
+/// # Errors
+///
+/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
+pub fn registration_sequence(
+ world: &World,
+ handle: ObjectHandle,
+) -> Result<Option<u64>, WorldError> {
+ Ok(checked_slot(world, handle)?.registration_sequence)
+}
+
+/// Returns object identity metadata.
+///
+/// # Errors
+///
+/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
+pub fn identity_metadata(
+ world: &World,
+ handle: ObjectHandle,
+) -> Result<IdentityMetadata, WorldError> {
+ let slot = checked_slot(world, handle)?;
+ Ok(IdentityMetadata {
+ original_id: slot.original_id,
+ mirror_id: slot.mirror_id,
+ owner_id: slot.owner_id,
+ })
+}
+
+/// Requests deletion.
+///
+/// # Errors
+///
+/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
+pub fn request_delete(world: &mut World, handle: ObjectHandle) -> Result<(), WorldError> {
+ checked_slot(world, handle)?;
+ if world.phase == WorldPhase::Calculating {
+ if !world.deferred_delete.contains(&handle) {
+ world.deferred_delete.push(handle);
+ }
+ Ok(())
+ } else {
+ delete_now(world, handle)
+ }
+}
+
+/// Enqueues a command.
+///
+/// # Errors
+///
+/// Returns [`WorldError`] when a targeted command references an invalid
+/// handle.
+pub fn enqueue(world: &mut World, mut command: WorldCommand) -> Result<(), WorldError> {
+ if let Some(handle) = command.target {
+ checked_slot(world, handle)?;
+ }
+ command.sequence = world.next_sequence;
+ world.next_sequence = world.next_sequence.saturating_add(1);
+ world.queue.push_back(command);
+ Ok(())
+}
+
+/// Advances one deterministic step.
+///
+/// # Errors
+///
+/// Returns [`WorldError`] if a queued command references a stale, deleted, or
+/// out-of-range handle.
+pub fn step(world: &mut World, input: &InputSnapshot) -> Result<WorldSnapshot, WorldError> {
+ step_with_handler(world, input, |_, _| Ok(()))
+}
+
+/// Advances one deterministic step with a command callback.
+///
+/// The callback runs while the world is in the calculating phase, which allows
+/// tests and adapters to exercise deferred deletion semantics without exposing
+/// mutable slot internals.
+///
+/// # Errors
+///
+/// Returns [`WorldError`] if a queued command references a stale, deleted, or
+/// out-of-range handle, or if the callback reports a world error.
+pub fn step_with_handler<F>(
+ world: &mut World,
+ _input: &InputSnapshot,
+ mut handler: F,
+) -> Result<WorldSnapshot, WorldError>
+where
+ F: FnMut(&mut World, &WorldCommand) -> Result<(), WorldError>,
+{
+ world.phase = WorldPhase::Calculating;
+ let mut events = Vec::new();
+ while let Some(command) = world.queue.pop_front() {
+ if let Some(handle) = command.target {
+ if world.deferred_delete.contains(&handle) {
+ continue;
+ }
+ checked_slot(world, handle)?;
+ }
+ handler(world, &command)?;
+ events.push(WorldEvent {
+ sequence: command.sequence,
+ target: command.target,
+ });
+ }
+ world.phase = WorldPhase::ApplyingDeferred;
+ let deletes = std::mem::take(&mut world.deferred_delete);
+ for handle in deletes {
+ let _ = delete_now(world, handle);
+ }
+ world.tick.0 = world.tick.0.saturating_add(1);
+ world.phase = WorldPhase::PublishingSnapshot;
+ let snapshot = WorldSnapshot {
+ tick: world.tick,
+ objects: live_registered(world),
+ events,
+ hash: canonical_state_hash(world),
+ };
+ world.phase = WorldPhase::Idle;
+ Ok(snapshot)
+}
+
+/// Computes canonical state hash.
+#[must_use]
+pub fn canonical_state_hash(world: &World) -> StateHash {
+ let mut state = 0xcbf2_9ce4_8422_2325_u64;
+ hash_u64(&mut state, world.tick.0);
+ for (idx, slot) in world.slots.iter().enumerate() {
+ hash_u64(&mut state, idx as u64);
+ hash_u64(&mut state, u64::from(slot.generation));
+ hash_u64(&mut state, u64::from(u8::from(slot.live)));
+ hash_u64(&mut state, u64::from(u8::from(slot.registered)));
+ hash_u64(&mut state, slot.original_id.map_or(0, |id| u64::from(id.0)));
+ hash_u64(&mut state, slot.mirror_id.map_or(0, |id| u64::from(id.0)));
+ hash_u64(&mut state, slot.owner_id.map_or(0, |id| u64::from(id.0)));
+ hash_u64(&mut state, slot.registration_sequence.unwrap_or(u64::MAX));
+ }
+ let mut out = [0; 32];
+ out[..8].copy_from_slice(&state.to_le_bytes());
+ out[8..16].copy_from_slice(&state.rotate_left(13).to_le_bytes());
+ out[16..24].copy_from_slice(&state.rotate_left(29).to_le_bytes());
+ out[24..32].copy_from_slice(&state.rotate_left(47).to_le_bytes());
+ StateHash(out)
+}
+
+/// Creates a fixed-step clock.
+///
+/// # Errors
+///
+/// Returns [`WorldError::InvalidFixedStep`] when the configured step is zero.
+pub fn fixed_step_clock(config: FixedStepConfig) -> Result<FixedStepClock, WorldError> {
+ if config.step_millis == 0 {
+ return Err(WorldError::InvalidFixedStep);
+ }
+ Ok(FixedStepClock {
+ accumulated_millis: 0,
+ tick: Tick(0),
+ paused: false,
+ platform_event_collections: 0,
+ })
+}
+
+/// Records platform event collection independently of game time.
+pub fn collect_platform_events(clock: &mut FixedStepClock) {
+ clock.platform_event_collections = clock.platform_event_collections.saturating_add(1);
+}
+
+/// Sets pause state.
+pub fn set_paused(clock: &mut FixedStepClock, paused: bool) {
+ clock.paused = paused;
+}
+
+/// Advances fixed-step game time.
+///
+/// Returns the number of simulation ticks that should be executed.
+///
+/// # Errors
+///
+/// Returns [`WorldError::InvalidFixedStep`] when the configured step is zero.
+pub fn advance_fixed_step(
+ clock: &mut FixedStepClock,
+ config: FixedStepConfig,
+ elapsed_millis: u64,
+) -> Result<u32, WorldError> {
+ if config.step_millis == 0 {
+ return Err(WorldError::InvalidFixedStep);
+ }
+ if clock.paused {
+ return Ok(0);
+ }
+ clock.accumulated_millis = clock.accumulated_millis.saturating_add(elapsed_millis);
+ let step = u64::from(config.step_millis);
+ let mut ticks = 0_u32;
+ while clock.accumulated_millis >= step {
+ clock.accumulated_millis -= step;
+ clock.tick.0 = clock.tick.0.saturating_add(1);
+ ticks = ticks.saturating_add(1);
+ }
+ Ok(ticks)
+}
+
+/// Returns fixed-step clock tick.
+#[must_use]
+pub fn fixed_step_tick(clock: &FixedStepClock) -> Tick {
+ clock.tick
+}
+
+/// Returns platform event collection count.
+#[must_use]
+pub fn platform_event_collections(clock: &FixedStepClock) -> u64 {
+ clock.platform_event_collections
+}
+
+/// Runs end-frame callbacks in stable sequence order.
+#[must_use]
+pub fn end_frame_callback_order(mut callbacks: Vec<WorldEvent>) -> Vec<u64> {
+ callbacks.sort_by_key(|event| event.sequence);
+ callbacks.into_iter().map(|event| event.sequence).collect()
+}
+
+/// Releases live objects before managers.
+#[must_use]
+pub fn shutdown(mut world: World) -> ShutdownReport {
+ let released_objects = live_registered(&world);
+ for slot in &mut world.slots {
+ slot.live = false;
+ slot.registered = false;
+ slot.generation = slot.generation.saturating_add(1);
+ }
+ ShutdownReport {
+ released_objects,
+ managers_released: true,
+ }
+}
+
+fn hash_u64(state: &mut u64, value: u64) {
+ for byte in value.to_le_bytes() {
+ *state ^= u64::from(byte);
+ *state = state.wrapping_mul(0x0000_0100_0000_01b3);
+ }
+}
+
+fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> {
+ let slot = world
+ .slots
+ .get(handle.slot as usize)
+ .ok_or(WorldError::InvalidHandle)?;
+ if slot.generation != handle.generation {
+ return Err(WorldError::StaleHandle);
+ }
+ if !slot.live {
+ return Err(WorldError::Deleted);
+ }
+ Ok(slot)
+}
+
+fn checked_slot_mut(world: &mut World, handle: ObjectHandle) -> Result<&mut Slot, WorldError> {
+ let slot = world
+ .slots
+ .get_mut(handle.slot as usize)
+ .ok_or(WorldError::InvalidHandle)?;
+ if slot.generation != handle.generation {
+ return Err(WorldError::StaleHandle);
+ }
+ if !slot.live {
+ return Err(WorldError::Deleted);
+ }
+ Ok(slot)
+}
+
+fn delete_now(world: &mut World, handle: ObjectHandle) -> Result<(), WorldError> {
+ let slot = checked_slot_mut(world, handle)?;
+ slot.live = false;
+ slot.generation = slot.generation.saturating_add(1);
+ Ok(())
+}
+
+fn live_registered(world: &World) -> Vec<ObjectHandle> {
+ world
+ .slots
+ .iter()
+ .enumerate()
+ .filter_map(|(idx, slot)| {
+ let slot_index = u32::try_from(idx).ok()?;
+ (slot.live && slot.registered).then_some(ObjectHandle {
+ generation: slot.generation,
+ slot: slot_index,
+ })
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn construct_register_and_hash_are_stable() {
+ let mut world = new(WorldConfig);
+ let handle = construct_object(&mut world, ObjectDraft { original_id: None }).expect("obj");
+ let before = step(&mut world, &InputSnapshot).expect("step");
+ assert!(before.objects.is_empty());
+ register_object(&mut world, handle).expect("register");
+ let after = step(&mut world, &InputSnapshot).expect("step");
+ assert_eq!(after.objects, vec![handle]);
+ }
+
+ #[test]
+ fn registration_sequence_stale_and_duplicate_original_contracts() {
+ let mut world = new(WorldConfig);
+ let first = construct_object(
+ &mut world,
+ ObjectDraft {
+ original_id: Some(OriginalObjectId(7)),
+ },
+ )
+ .expect("first");
+ let second = construct_object(
+ &mut world,
+ ObjectDraft {
+ original_id: Some(OriginalObjectId(8)),
+ },
+ )
+ .expect("second");
+ register_object(&mut world, first).expect("register first");
+ register_object(&mut world, second).expect("register second");
+ assert_eq!(registration_sequence(&world, first), Ok(Some(0)));
+ assert_eq!(registration_sequence(&world, second), Ok(Some(1)));
+
+ request_delete(&mut world, first).expect("delete");
+ assert_eq!(
+ register_object(&mut world, first),
+ Err(WorldError::StaleHandle)
+ );
+ let recycled = ObjectHandle {
+ generation: first.generation,
+ slot: first.slot,
+ };
+ assert_eq!(
+ register_object(&mut world, recycled),
+ Err(WorldError::StaleHandle)
+ );
+
+ let duplicate = construct_object(
+ &mut world,
+ ObjectDraft {
+ original_id: Some(OriginalObjectId(8)),
+ },
+ )
+ .expect("duplicate");
+ assert_eq!(
+ register_object(&mut world, duplicate),
+ Err(WorldError::DuplicateOriginalObjectId(OriginalObjectId(8)))
+ );
+ }
+
+ #[test]
+ fn identity_metadata_keeps_original_mirror_and_owner_distinct() {
+ let mut world = new(WorldConfig);
+ let handle = construct_object(
+ &mut world,
+ ObjectDraft {
+ original_id: Some(OriginalObjectId(10)),
+ },
+ )
+ .expect("object");
+ set_mirror_original(&mut world, handle, Some(OriginalObjectId(20))).expect("mirror");
+ set_owner(&mut world, handle, Some(OwnerId(3))).expect("owner");
+ assert_eq!(
+ identity_metadata(&world, handle),
+ Ok(IdentityMetadata {
+ original_id: Some(OriginalObjectId(10)),
+ mirror_id: Some(OriginalObjectId(20)),
+ owner_id: Some(OwnerId(3))
+ })
+ );
+ }
+
+ #[test]
+ fn command_fifo_and_deferred_delete_during_calculation() {
+ let mut world = new(WorldConfig);
+ let first = construct_object(&mut world, ObjectDraft { original_id: None }).expect("first");
+ let second =
+ construct_object(&mut world, ObjectDraft { original_id: None }).expect("second");
+ register_object(&mut world, first).expect("register first");
+ register_object(&mut world, second).expect("register second");
+ enqueue(
+ &mut world,
+ WorldCommand {
+ sequence: 99,
+ target: Some(first),
+ },
+ )
+ .expect("enqueue first");
+ enqueue(
+ &mut world,
+ WorldCommand {
+ sequence: 99,
+ target: Some(second),
+ },
+ )
+ .expect("enqueue second");
+ enqueue(
+ &mut world,
+ WorldCommand {
+ sequence: 99,
+ target: Some(first),
+ },
+ )
+ .expect("enqueue first again");
+
+ let snapshot = step_with_handler(&mut world, &InputSnapshot, |world, command| {
+ if command.target == Some(first) {
+ request_delete(world, first)?;
+ request_delete(world, first)?;
+ }
+ Ok(())
+ })
+ .expect("step");
+
+ assert_eq!(
+ snapshot.events,
+ vec![
+ WorldEvent {
+ sequence: 0,
+ target: Some(first)
+ },
+ WorldEvent {
+ sequence: 1,
+ target: Some(second)
+ }
+ ]
+ );
+ assert_eq!(
+ request_delete(&mut world, first),
+ Err(WorldError::StaleHandle)
+ );
+ assert_eq!(
+ step(&mut world, &InputSnapshot).expect("step").objects,
+ vec![second]
+ );
+ }
+
+ #[test]
+ fn snapshot_hash_determinism_and_immutability() {
+ let mut left = new(WorldConfig);
+ let mut right = new(WorldConfig);
+ for world in [&mut left, &mut right] {
+ let handle = construct_object(
+ world,
+ ObjectDraft {
+ original_id: Some(OriginalObjectId(1)),
+ },
+ )
+ .expect("object");
+ register_object(world, handle).expect("register");
+ }
+ let snapshot = step(&mut left, &InputSnapshot).expect("snapshot");
+ let clone = snapshot.clone();
+ let extra = construct_object(&mut left, ObjectDraft { original_id: None }).expect("extra");
+ register_object(&mut left, extra).expect("register extra");
+
+ assert_eq!(snapshot, clone);
+ assert_eq!(
+ clone.hash,
+ step(&mut right, &InputSnapshot).expect("right").hash
+ );
+ }
+
+ #[test]
+ fn fixed_step_pause_and_long_determinism_are_stable() {
+ let config = FixedStepConfig { step_millis: 20 };
+ let mut clock = fixed_step_clock(config).expect("clock");
+ collect_platform_events(&mut clock);
+ set_paused(&mut clock, true);
+ assert_eq!(advance_fixed_step(&mut clock, config, 100), Ok(0));
+ collect_platform_events(&mut clock);
+ assert_eq!(fixed_step_tick(&clock), Tick(0));
+ assert_eq!(platform_event_collections(&clock), 2);
+
+ set_paused(&mut clock, false);
+ assert_eq!(advance_fixed_step(&mut clock, config, 45), Ok(2));
+ assert_eq!(fixed_step_tick(&clock), Tick(2));
+
+ let mut first = new(WorldConfig);
+ let mut second = new(WorldConfig);
+ let mut first_hashes = Vec::new();
+ let mut second_hashes = Vec::new();
+ for _ in 0..10_000 {
+ first_hashes.push(step(&mut first, &InputSnapshot).expect("first").hash);
+ second_hashes.push(step(&mut second, &InputSnapshot).expect("second").hash);
+ }
+ assert_eq!(first_hashes, second_hashes);
+ }
+
+ #[test]
+ fn render_disabled_does_not_change_hash_end_callbacks_and_shutdown_order() {
+ let callbacks = vec![
+ WorldEvent {
+ sequence: 3,
+ target: None,
+ },
+ WorldEvent {
+ sequence: 1,
+ target: None,
+ },
+ WorldEvent {
+ sequence: 2,
+ target: None,
+ },
+ ];
+ assert_eq!(end_frame_callback_order(callbacks), vec![1, 2, 3]);
+
+ let mut rendered = new(WorldConfig);
+ let mut headless = rendered.clone();
+ assert_eq!(
+ step(&mut rendered, &InputSnapshot).expect("rendered").hash,
+ step(&mut headless, &InputSnapshot).expect("headless").hash
+ );
+
+ let handle =
+ construct_object(&mut rendered, ObjectDraft { original_id: None }).expect("object");
+ register_object(&mut rendered, handle).expect("register");
+ assert_eq!(
+ shutdown(rendered),
+ ShutdownReport {
+ released_objects: vec![handle],
+ managers_released: true
+ }
+ );
+ }
+
+ #[test]
+ fn generated_command_delete_sequences_preserve_registry_invariants() {
+ for seed in 0_u32..64 {
+ let mut world = new(WorldConfig);
+ let mut handles = Vec::new();
+ for index in 0..8 {
+ let handle = construct_object(
+ &mut world,
+ ObjectDraft {
+ original_id: Some(OriginalObjectId(seed * 100 + index)),
+ },
+ )
+ .expect("object");
+ register_object(&mut world, handle).expect("register");
+ handles.push(handle);
+ }
+ for (index, handle) in handles.iter().copied().enumerate() {
+ if (seed as usize + index) % 3 == 0 {
+ request_delete(&mut world, handle).expect("delete");
+ } else {
+ enqueue(
+ &mut world,
+ WorldCommand {
+ sequence: 0,
+ target: Some(handle),
+ },
+ )
+ .expect("enqueue");
+ }
+ }
+ let snapshot = step(&mut world, &InputSnapshot).expect("step");
+ for handle in snapshot.objects {
+ assert!(registration_sequence(&world, handle)
+ .expect("sequence")
+ .is_some());
+ }
+ }
+ }
+}
diff --git a/crates/msh-core/Cargo.toml b/crates/msh-core/Cargo.toml
deleted file mode 100644
index 86b0846..0000000
--- a/crates/msh-core/Cargo.toml
+++ /dev/null
@@ -1,12 +0,0 @@
-[package]
-name = "msh-core"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
-encoding_rs = "0.8"
-nres = { path = "../nres" }
-
-[dev-dependencies]
-common = { path = "../common" }
-proptest = "1"
diff --git a/crates/msh-core/README.md b/crates/msh-core/README.md
deleted file mode 100644
index 016df7a..0000000
--- a/crates/msh-core/README.md
+++ /dev/null
@@ -1,14 +0,0 @@
-# msh-core
-
-Парсер core-части формата `MSH`.
-
-Покрывает:
-
-- `Res1`, `Res2`, `Res3`, `Res6`, `Res13` (обязательные);
-- `Res4`, `Res5`, `Res10` (опциональные);
-- slot lookup по `node/lod/group`.
-
-Тесты:
-
-- прогон по всем `.msh` в `testdata`;
-- синтетическая минимальная модель.
diff --git a/crates/msh-core/src/error.rs b/crates/msh-core/src/error.rs
deleted file mode 100644
index d46c7b1..0000000
--- a/crates/msh-core/src/error.rs
+++ /dev/null
@@ -1,75 +0,0 @@
-use core::fmt;
-
-#[derive(Debug)]
-#[non_exhaustive]
-pub enum Error {
- Nres(nres::error::Error),
- MissingResource {
- kind: u32,
- label: &'static str,
- },
- InvalidResourceSize {
- label: &'static str,
- size: usize,
- stride: usize,
- },
- InvalidRes2Size {
- size: usize,
- },
- UnsupportedNodeStride {
- stride: usize,
- },
- IndexOutOfBounds {
- label: &'static str,
- index: usize,
- limit: usize,
- },
- IntegerOverflow,
-}
-
-impl From<nres::error::Error> for Error {
- fn from(value: nres::error::Error) -> Self {
- Self::Nres(value)
- }
-}
-
-impl fmt::Display for Error {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::Nres(err) => write!(f, "{err}"),
- Self::MissingResource { kind, label } => {
- write!(f, "missing required resource type={kind} ({label})")
- }
- Self::InvalidResourceSize {
- label,
- size,
- stride,
- } => {
- write!(
- f,
- "invalid {label} size={size}, expected multiple of stride={stride}"
- )
- }
- Self::InvalidRes2Size { size } => {
- write!(f, "invalid Res2 size={size}, expected >= 140")
- }
- Self::UnsupportedNodeStride { stride } => {
- write!(
- f,
- "unsupported Res1 node stride={stride}, expected 38 or 24"
- )
- }
- Self::IndexOutOfBounds {
- label,
- index,
- limit,
- } => write!(
- f,
- "{label} index out of bounds: index={index}, limit={limit}"
- ),
- Self::IntegerOverflow => write!(f, "integer overflow"),
- }
- }
-}
-
-impl std::error::Error for Error {}
diff --git a/crates/msh-core/src/lib.rs b/crates/msh-core/src/lib.rs
deleted file mode 100644
index bc51357..0000000
--- a/crates/msh-core/src/lib.rs
+++ /dev/null
@@ -1,434 +0,0 @@
-pub mod error;
-
-use crate::error::Error;
-use encoding_rs::WINDOWS_1251;
-use std::sync::Arc;
-
-pub type Result<T> = core::result::Result<T, Error>;
-
-pub const RES1_NODE_TABLE: u32 = 1;
-pub const RES2_SLOTS: u32 = 2;
-pub const RES3_POSITIONS: u32 = 3;
-pub const RES4_NORMALS: u32 = 4;
-pub const RES5_UV0: u32 = 5;
-pub const RES6_INDICES: u32 = 6;
-pub const RES10_NAMES: u32 = 10;
-pub const RES13_BATCHES: u32 = 13;
-
-#[derive(Clone, Debug)]
-pub struct Slot {
- pub tri_start: u16,
- pub tri_count: u16,
- pub batch_start: u16,
- pub batch_count: u16,
- pub aabb_min: [f32; 3],
- pub aabb_max: [f32; 3],
- pub sphere_center: [f32; 3],
- pub sphere_radius: f32,
- pub opaque: [u32; 5],
-}
-
-#[derive(Clone, Debug)]
-pub struct Batch {
- pub batch_flags: u16,
- pub material_index: u16,
- pub opaque4: u16,
- pub opaque6: u16,
- pub index_count: u16,
- pub index_start: u32,
- pub opaque14: u16,
- pub base_vertex: u32,
-}
-
-#[derive(Clone, Debug)]
-pub struct Model {
- pub node_stride: usize,
- pub node_count: usize,
- pub nodes_raw: Vec<u8>,
- pub slots: Vec<Slot>,
- pub positions: Vec<[f32; 3]>,
- pub normals: Option<Vec<[i8; 4]>>,
- pub uv0: Option<Vec<[i16; 2]>>,
- pub indices: Vec<u16>,
- pub batches: Vec<Batch>,
- pub node_names: Option<Vec<Option<String>>>,
-}
-
-impl Model {
- pub fn slot_index(&self, node_index: usize, lod: usize, group: usize) -> Option<usize> {
- if node_index >= self.node_count || lod >= 3 || group >= 5 {
- return None;
- }
- if self.node_stride != 38 {
- return None;
- }
- let node_off = node_index.checked_mul(self.node_stride)?;
- let matrix_off = node_off.checked_add(8)?;
- let word_off = matrix_off.checked_add((lod * 5 + group) * 2)?;
- let raw = read_u16(&self.nodes_raw, word_off).ok()?;
- if raw == u16::MAX {
- return None;
- }
- let idx = usize::from(raw);
- if idx >= self.slots.len() {
- return None;
- }
- Some(idx)
- }
-}
-
-pub fn parse_model_payload(payload: &[u8]) -> Result<Model> {
- let archive = nres::Archive::open_bytes(
- Arc::from(payload.to_vec().into_boxed_slice()),
- nres::OpenOptions::default(),
- )?;
-
- let res1 = read_required(&archive, RES1_NODE_TABLE, "Res1")?;
- let res2 = read_required(&archive, RES2_SLOTS, "Res2")?;
- let res3 = read_required(&archive, RES3_POSITIONS, "Res3")?;
- let res6 = read_required(&archive, RES6_INDICES, "Res6")?;
- let res13 = read_required(&archive, RES13_BATCHES, "Res13")?;
-
- let res4 = read_optional(&archive, RES4_NORMALS)?;
- let res5 = read_optional(&archive, RES5_UV0)?;
- let res10 = read_optional(&archive, RES10_NAMES)?;
-
- let node_stride = usize::try_from(res1.meta.attr3).map_err(|_| Error::IntegerOverflow)?;
- if node_stride != 38 && node_stride != 24 {
- return Err(Error::UnsupportedNodeStride {
- stride: node_stride,
- });
- }
- if res1.bytes.len() % node_stride != 0 {
- return Err(Error::InvalidResourceSize {
- label: "Res1",
- size: res1.bytes.len(),
- stride: node_stride,
- });
- }
- let node_count = res1.bytes.len() / node_stride;
-
- if res2.bytes.len() < 0x8C {
- return Err(Error::InvalidRes2Size {
- size: res2.bytes.len(),
- });
- }
- let slot_blob = res2
- .bytes
- .len()
- .checked_sub(0x8C)
- .ok_or(Error::IntegerOverflow)?;
- if slot_blob % 68 != 0 {
- return Err(Error::InvalidResourceSize {
- label: "Res2.slots",
- size: slot_blob,
- stride: 68,
- });
- }
- let slot_count = slot_blob / 68;
- let mut slots = Vec::with_capacity(slot_count);
- for i in 0..slot_count {
- let off = 0x8Cusize
- .checked_add(i.checked_mul(68).ok_or(Error::IntegerOverflow)?)
- .ok_or(Error::IntegerOverflow)?;
- slots.push(Slot {
- tri_start: read_u16(&res2.bytes, off)?,
- tri_count: read_u16(&res2.bytes, off + 2)?,
- batch_start: read_u16(&res2.bytes, off + 4)?,
- batch_count: read_u16(&res2.bytes, off + 6)?,
- aabb_min: [
- read_f32(&res2.bytes, off + 8)?,
- read_f32(&res2.bytes, off + 12)?,
- read_f32(&res2.bytes, off + 16)?,
- ],
- aabb_max: [
- read_f32(&res2.bytes, off + 20)?,
- read_f32(&res2.bytes, off + 24)?,
- read_f32(&res2.bytes, off + 28)?,
- ],
- sphere_center: [
- read_f32(&res2.bytes, off + 32)?,
- read_f32(&res2.bytes, off + 36)?,
- read_f32(&res2.bytes, off + 40)?,
- ],
- sphere_radius: read_f32(&res2.bytes, off + 44)?,
- opaque: [
- read_u32(&res2.bytes, off + 48)?,
- read_u32(&res2.bytes, off + 52)?,
- read_u32(&res2.bytes, off + 56)?,
- read_u32(&res2.bytes, off + 60)?,
- read_u32(&res2.bytes, off + 64)?,
- ],
- });
- }
-
- let positions = parse_positions(&res3.bytes)?;
- let indices = parse_u16_array(&res6.bytes, "Res6")?;
- let batches = parse_batches(&res13.bytes)?;
- validate_slot_batch_ranges(&slots, batches.len())?;
- validate_batch_index_ranges(&batches, indices.len())?;
-
- let normals = match res4 {
- Some(raw) => Some(parse_i8x4_array(&raw.bytes, "Res4")?),
- None => None,
- };
- let uv0 = match res5 {
- Some(raw) => Some(parse_i16x2_array(&raw.bytes, "Res5")?),
- None => None,
- };
- let node_names = match res10 {
- Some(raw) => Some(parse_res10_names(&raw.bytes, node_count)?),
- None => None,
- };
-
- Ok(Model {
- node_stride,
- node_count,
- nodes_raw: res1.bytes,
- slots,
- positions,
- normals,
- uv0,
- indices,
- batches,
- node_names,
- })
-}
-
-fn validate_slot_batch_ranges(slots: &[Slot], batch_count: usize) -> Result<()> {
- for slot in slots {
- let start = usize::from(slot.batch_start);
- let end = start
- .checked_add(usize::from(slot.batch_count))
- .ok_or(Error::IntegerOverflow)?;
- if end > batch_count {
- return Err(Error::IndexOutOfBounds {
- label: "Res2.batch_range",
- index: end,
- limit: batch_count,
- });
- }
- }
- Ok(())
-}
-
-fn validate_batch_index_ranges(batches: &[Batch], index_count: usize) -> Result<()> {
- for batch in batches {
- let start = usize::try_from(batch.index_start).map_err(|_| Error::IntegerOverflow)?;
- let end = start
- .checked_add(usize::from(batch.index_count))
- .ok_or(Error::IntegerOverflow)?;
- if end > index_count {
- return Err(Error::IndexOutOfBounds {
- label: "Res13.index_range",
- index: end,
- limit: index_count,
- });
- }
- }
- Ok(())
-}
-
-fn parse_positions(data: &[u8]) -> Result<Vec<[f32; 3]>> {
- if !data.len().is_multiple_of(12) {
- return Err(Error::InvalidResourceSize {
- label: "Res3",
- size: data.len(),
- stride: 12,
- });
- }
- let count = data.len() / 12;
- let mut out = Vec::with_capacity(count);
- for i in 0..count {
- let off = i * 12;
- out.push([
- read_f32(data, off)?,
- read_f32(data, off + 4)?,
- read_f32(data, off + 8)?,
- ]);
- }
- Ok(out)
-}
-
-fn parse_batches(data: &[u8]) -> Result<Vec<Batch>> {
- if !data.len().is_multiple_of(20) {
- return Err(Error::InvalidResourceSize {
- label: "Res13",
- size: data.len(),
- stride: 20,
- });
- }
- let count = data.len() / 20;
- let mut out = Vec::with_capacity(count);
- for i in 0..count {
- let off = i * 20;
- out.push(Batch {
- batch_flags: read_u16(data, off)?,
- material_index: read_u16(data, off + 2)?,
- opaque4: read_u16(data, off + 4)?,
- opaque6: read_u16(data, off + 6)?,
- index_count: read_u16(data, off + 8)?,
- index_start: read_u32(data, off + 10)?,
- opaque14: read_u16(data, off + 14)?,
- base_vertex: read_u32(data, off + 16)?,
- });
- }
- Ok(out)
-}
-
-fn parse_u16_array(data: &[u8], label: &'static str) -> Result<Vec<u16>> {
- if !data.len().is_multiple_of(2) {
- return Err(Error::InvalidResourceSize {
- label,
- size: data.len(),
- stride: 2,
- });
- }
- let mut out = Vec::with_capacity(data.len() / 2);
- for i in (0..data.len()).step_by(2) {
- out.push(read_u16(data, i)?);
- }
- Ok(out)
-}
-
-fn parse_i8x4_array(data: &[u8], label: &'static str) -> Result<Vec<[i8; 4]>> {
- if !data.len().is_multiple_of(4) {
- return Err(Error::InvalidResourceSize {
- label,
- size: data.len(),
- stride: 4,
- });
- }
- let mut out = Vec::with_capacity(data.len() / 4);
- for i in (0..data.len()).step_by(4) {
- out.push([
- read_i8(data, i)?,
- read_i8(data, i + 1)?,
- read_i8(data, i + 2)?,
- read_i8(data, i + 3)?,
- ]);
- }
- Ok(out)
-}
-
-fn parse_i16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[i16; 2]>> {
- if !data.len().is_multiple_of(4) {
- return Err(Error::InvalidResourceSize {
- label,
- size: data.len(),
- stride: 4,
- });
- }
- let mut out = Vec::with_capacity(data.len() / 4);
- for i in (0..data.len()).step_by(4) {
- out.push([read_i16(data, i)?, read_i16(data, i + 2)?]);
- }
- Ok(out)
-}
-
-fn parse_res10_names(data: &[u8], node_count: usize) -> Result<Vec<Option<String>>> {
- let mut out = Vec::with_capacity(node_count);
- let mut off = 0usize;
- for _ in 0..node_count {
- let len = usize::try_from(read_u32(data, off)?).map_err(|_| Error::IntegerOverflow)?;
- off = off.checked_add(4).ok_or(Error::IntegerOverflow)?;
- if len == 0 {
- out.push(None);
- continue;
- }
- let need = len.checked_add(1).ok_or(Error::IntegerOverflow)?;
- let end = off.checked_add(need).ok_or(Error::IntegerOverflow)?;
- let slice = data.get(off..end).ok_or(Error::InvalidResourceSize {
- label: "Res10",
- size: data.len(),
- stride: 1,
- })?;
- let text = if slice.last().copied() == Some(0) {
- &slice[..slice.len().saturating_sub(1)]
- } else {
- slice
- };
- let decoded = decode_cp1251(text);
- out.push(Some(decoded));
- off = end;
- }
- Ok(out)
-}
-
-fn decode_cp1251(bytes: &[u8]) -> String {
- let (decoded, _, _) = WINDOWS_1251.decode(bytes);
- decoded.into_owned()
-}
-
-struct RawResource {
- meta: nres::EntryMeta,
- bytes: Vec<u8>,
-}
-
-fn read_required(archive: &nres::Archive, kind: u32, label: &'static str) -> Result<RawResource> {
- let id = archive
- .entries()
- .find(|entry| entry.meta.kind == kind)
- .map(|entry| entry.id)
- .ok_or(Error::MissingResource { kind, label })?;
- let entry = archive.get(id).ok_or(Error::IndexOutOfBounds {
- label,
- index: usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?,
- limit: archive.entry_count(),
- })?;
- let data = archive.read(id)?.into_owned();
- Ok(RawResource {
- meta: entry.meta.clone(),
- bytes: data,
- })
-}
-
-fn read_optional(archive: &nres::Archive, kind: u32) -> Result<Option<RawResource>> {
- let Some(id) = archive
- .entries()
- .find(|entry| entry.meta.kind == kind)
- .map(|entry| entry.id)
- else {
- return Ok(None);
- };
- let entry = archive.get(id).ok_or(Error::IndexOutOfBounds {
- label: "optional",
- index: usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?,
- limit: archive.entry_count(),
- })?;
- let data = archive.read(id)?.into_owned();
- Ok(Some(RawResource {
- meta: entry.meta.clone(),
- bytes: data,
- }))
-}
-
-fn read_u16(data: &[u8], offset: usize) -> Result<u16> {
- let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?;
- let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
- Ok(u16::from_le_bytes(arr))
-}
-
-fn read_i16(data: &[u8], offset: usize) -> Result<i16> {
- let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?;
- let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
- Ok(i16::from_le_bytes(arr))
-}
-
-fn read_i8(data: &[u8], offset: usize) -> Result<i8> {
- let byte = data.get(offset).copied().ok_or(Error::IntegerOverflow)?;
- Ok(i8::from_le_bytes([byte]))
-}
-
-fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
- let bytes = data.get(offset..offset + 4).ok_or(Error::IntegerOverflow)?;
- let arr: [u8; 4] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
- Ok(u32::from_le_bytes(arr))
-}
-
-fn read_f32(data: &[u8], offset: usize) -> Result<f32> {
- Ok(f32::from_bits(read_u32(data, offset)?))
-}
-
-#[cfg(test)]
-mod tests;
diff --git a/crates/msh-core/src/tests.rs b/crates/msh-core/src/tests.rs
deleted file mode 100644
index 90a7fdc..0000000
--- a/crates/msh-core/src/tests.rs
+++ /dev/null
@@ -1,438 +0,0 @@
-use super::*;
-use common::collect_files_recursive;
-use nres::Archive;
-use proptest::prelude::*;
-use std::fs;
-use std::path::{Path, PathBuf};
-
-fn nres_test_files() -> Vec<PathBuf> {
- let root = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("..")
- .join("..")
- .join("testdata");
- let mut files = Vec::new();
- collect_files_recursive(&root, &mut files);
- files.sort();
- files
- .into_iter()
- .filter(|path| {
- fs::read(path)
- .map(|bytes| bytes.get(0..4) == Some(b"NRes"))
- .unwrap_or(false)
- })
- .collect()
-}
-
-fn is_msh_name(name: &str) -> bool {
- name.to_ascii_lowercase().ends_with(".msh")
-}
-
-#[derive(Clone)]
-struct SyntheticEntry {
- kind: u32,
- name: String,
- attr1: u32,
- attr2: u32,
- attr3: u32,
- data: Vec<u8>,
-}
-
-fn build_nested_nres(entries: &[SyntheticEntry]) -> Vec<u8> {
- let mut payload = Vec::new();
- payload.extend_from_slice(b"NRes");
- payload.extend_from_slice(&0x100u32.to_le_bytes());
- payload.extend_from_slice(
- &u32::try_from(entries.len())
- .expect("entry count overflow in test")
- .to_le_bytes(),
- );
- payload.extend_from_slice(&0u32.to_le_bytes()); // total_size placeholder
-
- let mut resource_offsets = Vec::with_capacity(entries.len());
- for entry in entries {
- resource_offsets.push(u32::try_from(payload.len()).expect("offset overflow in test"));
- payload.extend_from_slice(&entry.data);
- while !payload.len().is_multiple_of(8) {
- payload.push(0);
- }
- }
-
- for (index, entry) in entries.iter().enumerate() {
- payload.extend_from_slice(&entry.kind.to_le_bytes());
- payload.extend_from_slice(&entry.attr1.to_le_bytes());
- payload.extend_from_slice(&entry.attr2.to_le_bytes());
- payload.extend_from_slice(
- &u32::try_from(entry.data.len())
- .expect("size overflow in test")
- .to_le_bytes(),
- );
- payload.extend_from_slice(&entry.attr3.to_le_bytes());
-
- let mut name_raw = [0u8; 36];
- let name_bytes = entry.name.as_bytes();
- assert!(name_bytes.len() <= 35, "name too long for synthetic test");
- name_raw[..name_bytes.len()].copy_from_slice(name_bytes);
- payload.extend_from_slice(&name_raw);
-
- payload.extend_from_slice(&resource_offsets[index].to_le_bytes());
- payload.extend_from_slice(&(index as u32).to_le_bytes());
- }
-
- let total_size = u32::try_from(payload.len()).expect("size overflow in test");
- payload[12..16].copy_from_slice(&total_size.to_le_bytes());
- payload
-}
-
-fn synthetic_entry(kind: u32, name: &str, attr3: u32, data: Vec<u8>) -> SyntheticEntry {
- SyntheticEntry {
- kind,
- name: name.to_string(),
- attr1: 1,
- attr2: 0,
- attr3,
- data,
- }
-}
-
-fn res1_stride38_nodes(node_count: usize, node0_slot00: Option<u16>) -> Vec<u8> {
- let mut out = vec![0u8; node_count.saturating_mul(38)];
- for node in 0..node_count {
- let node_off = node * 38;
- for i in 0..15 {
- let off = node_off + 8 + i * 2;
- out[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes());
- }
- }
- if let Some(slot) = node0_slot00 {
- out[8..10].copy_from_slice(&slot.to_le_bytes());
- }
- out
-}
-
-fn res1_stride24_nodes(node_count: usize) -> Vec<u8> {
- vec![0u8; node_count.saturating_mul(24)]
-}
-
-fn res2_single_slot(batch_start: u16, batch_count: u16) -> Vec<u8> {
- let mut res2 = vec![0u8; 0x8C + 68];
- res2[0x8C..0x8C + 2].copy_from_slice(&0u16.to_le_bytes()); // tri_start
- res2[0x8C + 2..0x8C + 4].copy_from_slice(&0u16.to_le_bytes()); // tri_count
- res2[0x8C + 4..0x8C + 6].copy_from_slice(&batch_start.to_le_bytes()); // batch_start
- res2[0x8C + 6..0x8C + 8].copy_from_slice(&batch_count.to_le_bytes()); // batch_count
- res2
-}
-
-fn res3_triangle_positions() -> Vec<u8> {
- [0f32, 0f32, 0f32, 1f32, 0f32, 0f32, 0f32, 1f32, 0f32]
- .iter()
- .flat_map(|v| v.to_le_bytes())
- .collect()
-}
-
-fn res4_normals() -> Vec<u8> {
- vec![127u8, 0u8, 128u8, 0u8]
-}
-
-fn res5_uv0() -> Vec<u8> {
- [1024i16, -1024i16]
- .iter()
- .flat_map(|v| v.to_le_bytes())
- .collect()
-}
-
-fn res6_triangle_indices() -> Vec<u8> {
- [0u16, 1u16, 2u16]
- .iter()
- .flat_map(|v| v.to_le_bytes())
- .collect()
-}
-
-fn res13_single_batch(index_start: u32, index_count: u16) -> Vec<u8> {
- let mut batch = vec![0u8; 20];
- batch[0..2].copy_from_slice(&0u16.to_le_bytes());
- batch[2..4].copy_from_slice(&0u16.to_le_bytes());
- batch[8..10].copy_from_slice(&index_count.to_le_bytes());
- batch[10..14].copy_from_slice(&index_start.to_le_bytes());
- batch[16..20].copy_from_slice(&0u32.to_le_bytes());
- batch
-}
-
-fn res10_names_raw(names: &[Option<&[u8]>]) -> Vec<u8> {
- let mut out = Vec::new();
- for name in names {
- match name {
- Some(name) => {
- out.extend_from_slice(
- &u32::try_from(name.len())
- .expect("name size overflow in test")
- .to_le_bytes(),
- );
- out.extend_from_slice(name);
- out.push(0);
- }
- None => out.extend_from_slice(&0u32.to_le_bytes()),
- }
- }
- out
-}
-
-fn res10_names(names: &[Option<&str>]) -> Vec<u8> {
- let raw: Vec<Option<&[u8]>> = names.iter().map(|name| name.map(str::as_bytes)).collect();
- res10_names_raw(&raw)
-}
-
-fn base_synthetic_entries() -> Vec<SyntheticEntry> {
- vec![
- synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0))),
- synthetic_entry(RES2_SLOTS, "Res2", 68, res2_single_slot(0, 1)),
- synthetic_entry(RES3_POSITIONS, "Res3", 12, res3_triangle_positions()),
- synthetic_entry(RES6_INDICES, "Res6", 2, res6_triangle_indices()),
- synthetic_entry(RES13_BATCHES, "Res13", 20, res13_single_batch(0, 3)),
- ]
-}
-
-#[test]
-fn parse_all_game_msh_models() {
- let archives = nres_test_files();
- if archives.is_empty() {
- eprintln!("skipping parse_all_game_msh_models: no NRes files in testdata");
- return;
- }
-
- let mut model_count = 0usize;
- let mut renderable_count = 0usize;
- let mut legacy_stride24_count = 0usize;
-
- for archive_path in archives {
- let archive = Archive::open_path(&archive_path)
- .unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display()));
-
- for entry in archive.entries() {
- if !is_msh_name(&entry.meta.name) {
- continue;
- }
- model_count += 1;
- let payload = archive.read(entry.id).unwrap_or_else(|err| {
- panic!(
- "failed to read model '{}' in {}: {err}",
- entry.meta.name,
- archive_path.display()
- )
- });
- let model = parse_model_payload(payload.as_slice()).unwrap_or_else(|err| {
- panic!(
- "failed to parse model '{}' in {}: {err}",
- entry.meta.name,
- archive_path.display()
- )
- });
-
- if model.node_stride == 24 {
- legacy_stride24_count += 1;
- }
-
- for node_index in 0..model.node_count {
- for lod in 0..3 {
- for group in 0..5 {
- if let Some(slot_idx) = model.slot_index(node_index, lod, group) {
- assert!(
- slot_idx < model.slots.len(),
- "slot index out of bounds in '{}' ({})",
- entry.meta.name,
- archive_path.display()
- );
- }
- }
- }
- }
-
- let mut has_renderable_batch = false;
- for node_index in 0..model.node_count {
- let Some(slot_idx) = model.slot_index(node_index, 0, 0) else {
- continue;
- };
- let slot = &model.slots[slot_idx];
- let batch_end =
- usize::from(slot.batch_start).saturating_add(usize::from(slot.batch_count));
- if batch_end > model.batches.len() {
- continue;
- }
- for batch in &model.batches[usize::from(slot.batch_start)..batch_end] {
- let index_start = usize::try_from(batch.index_start).unwrap_or(usize::MAX);
- let index_count = usize::from(batch.index_count);
- let end = index_start.saturating_add(index_count);
- if end <= model.indices.len() && index_count >= 3 {
- has_renderable_batch = true;
- break;
- }
- }
- if has_renderable_batch {
- break;
- }
- }
- if has_renderable_batch {
- renderable_count += 1;
- }
- }
- }
-
- assert!(model_count > 0, "no .msh entries found");
- assert!(
- renderable_count > 0,
- "no renderable models (lod0/group0) were detected"
- );
- assert!(
- legacy_stride24_count <= model_count,
- "internal test accounting error"
- );
-}
-
-#[test]
-fn parse_minimal_synthetic_model() {
- let payload = build_nested_nres(&base_synthetic_entries());
- let model = parse_model_payload(&payload).expect("failed to parse synthetic model");
- assert_eq!(model.node_count, 1);
- assert_eq!(model.positions.len(), 3);
- assert_eq!(model.indices.len(), 3);
- assert_eq!(model.batches.len(), 1);
- assert_eq!(model.slot_index(0, 0, 0), Some(0));
-}
-
-#[test]
-fn parse_synthetic_stride24_variant() {
- let mut entries = base_synthetic_entries();
- entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 24, res1_stride24_nodes(1));
- let payload = build_nested_nres(&entries);
-
- let model = parse_model_payload(&payload).expect("failed to parse stride24 model");
- assert_eq!(model.node_stride, 24);
- assert_eq!(model.node_count, 1);
- assert_eq!(model.slot_index(0, 0, 0), None);
-}
-
-#[test]
-fn parse_synthetic_model_with_optional_res4_res5_res10() {
- let mut entries = base_synthetic_entries();
- entries.push(synthetic_entry(RES4_NORMALS, "Res4", 4, res4_normals()));
- entries.push(synthetic_entry(RES5_UV0, "Res5", 4, res5_uv0()));
- entries.push(synthetic_entry(
- RES10_NAMES,
- "Res10",
- 1,
- res10_names(&[Some("Hull"), None]),
- ));
- entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(2, Some(0)));
- let payload = build_nested_nres(&entries);
-
- let model = parse_model_payload(&payload).expect("failed to parse model with optional data");
- assert_eq!(model.node_count, 2);
- assert_eq!(model.normals.as_ref().map(Vec::len), Some(1));
- assert_eq!(model.uv0.as_ref().map(Vec::len), Some(1));
- assert_eq!(model.node_names, Some(vec![Some("Hull".to_string()), None]));
-}
-
-#[test]
-fn parse_res10_names_decodes_cp1251() {
- let mut entries = base_synthetic_entries();
- entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0)));
- entries.push(synthetic_entry(
- RES10_NAMES,
- "Res10",
- 1,
- res10_names_raw(&[Some(&[0xC0])]),
- ));
- let payload = build_nested_nres(&entries);
-
- let model = parse_model_payload(&payload).expect("failed to parse model with cp1251 name");
- assert_eq!(model.node_names, Some(vec![Some("А".to_string())]));
-}
-
-#[test]
-fn parse_fails_when_required_resource_missing() {
- let mut entries = base_synthetic_entries();
- entries.retain(|entry| entry.kind != RES13_BATCHES);
- let payload = build_nested_nres(&entries);
-
- assert!(matches!(
- parse_model_payload(&payload),
- Err(Error::MissingResource {
- kind: RES13_BATCHES,
- label: "Res13"
- })
- ));
-}
-
-#[test]
-fn parse_fails_for_invalid_res2_size() {
- let mut entries = base_synthetic_entries();
- entries[1] = synthetic_entry(RES2_SLOTS, "Res2", 68, vec![0u8; 0x8B]);
- let payload = build_nested_nres(&entries);
-
- assert!(matches!(
- parse_model_payload(&payload),
- Err(Error::InvalidRes2Size { .. })
- ));
-}
-
-#[test]
-fn parse_fails_for_unsupported_node_stride() {
- let mut entries = base_synthetic_entries();
- entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 30, vec![0u8; 30]);
- let payload = build_nested_nres(&entries);
-
- assert!(matches!(
- parse_model_payload(&payload),
- Err(Error::UnsupportedNodeStride { stride: 30 })
- ));
-}
-
-#[test]
-fn parse_fails_for_invalid_optional_resource_size() {
- let mut entries = base_synthetic_entries();
- entries.push(synthetic_entry(RES4_NORMALS, "Res4", 4, vec![1, 2, 3]));
- let payload = build_nested_nres(&entries);
-
- assert!(matches!(
- parse_model_payload(&payload),
- Err(Error::InvalidResourceSize { label: "Res4", .. })
- ));
-}
-
-#[test]
-fn parse_fails_for_slot_batch_range_out_of_bounds() {
- let mut entries = base_synthetic_entries();
- entries[1] = synthetic_entry(RES2_SLOTS, "Res2", 68, res2_single_slot(0, 2));
- let payload = build_nested_nres(&entries);
-
- assert!(matches!(
- parse_model_payload(&payload),
- Err(Error::IndexOutOfBounds {
- label: "Res2.batch_range",
- ..
- })
- ));
-}
-
-#[test]
-fn parse_fails_for_batch_index_range_out_of_bounds() {
- let mut entries = base_synthetic_entries();
- entries[4] = synthetic_entry(RES13_BATCHES, "Res13", 20, res13_single_batch(1, 3));
- let payload = build_nested_nres(&entries);
-
- assert!(matches!(
- parse_model_payload(&payload),
- Err(Error::IndexOutOfBounds {
- label: "Res13.index_range",
- ..
- })
- ));
-}
-
-proptest! {
- #![proptest_config(ProptestConfig::with_cases(64))]
-
- #[test]
- fn parse_model_payload_never_panics_on_random_bytes(data in proptest::collection::vec(any::<u8>(), 0..8192)) {
- let _ = parse_model_payload(&data);
- }
-}
diff --git a/crates/nres/Cargo.toml b/crates/nres/Cargo.toml
deleted file mode 100644
index 38b8822..0000000
--- a/crates/nres/Cargo.toml
+++ /dev/null
@@ -1,10 +0,0 @@
-[package]
-name = "nres"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
-common = { path = "../common" }
-
-[target.'cfg(windows)'.dependencies]
-windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
diff --git a/crates/nres/README.md b/crates/nres/README.md
deleted file mode 100644
index 8b9dfb5..0000000
--- a/crates/nres/README.md
+++ /dev/null
@@ -1,42 +0,0 @@
-# nres
-
-Rust-библиотека для работы с архивами формата **NRes**.
-
-## Что умеет
-
-- Открытие архива из файла (`open_path`) и из памяти (`open_bytes`).
-- Поддержка `raw_mode` (весь файл как единый ресурс).
-- Чтение метаданных и итерация по записям.
-- Поиск по имени без учёта регистра (`find`).
-- Чтение данных ресурса (`read`, `read_into`, `raw_slice`).
-- Редактирование архива через `Editor`:
-- `add`, `replace_data`, `remove`.
-- `commit` с пересчётом `sort_index`, выравниванием по 8 байт и атомарной записью файла.
-
-## Модель ошибок
-
-Библиотека возвращает типизированные ошибки (`InvalidMagic`, `UnsupportedVersion`, `TotalSizeMismatch`, `DirectoryOutOfBounds`, `EntryDataOutOfBounds`, и др.) без паник в production-коде.
-
-## Покрытие тестами
-
-### Реальные файлы
-
-- Рекурсивный прогон по `testdata/nres/**`.
-- Сейчас в наборе: **120 архивов**.
-- Для каждого архива проверяется:
-- чтение всех записей;
-- `read`/`read_into`/`raw_slice`;
-- `find`;
-- `unpack -> repack (Editor::commit)` с проверкой **byte-to-byte**.
-
-### Синтетические тесты
-
-- Проверка основных сценариев редактирования (`add/replace/remove/commit`).
-- Проверка валидации и ошибок:
-- `InvalidMagic`, `UnsupportedVersion`, `TotalSizeMismatch`, `InvalidEntryCount`, `DirectoryOutOfBounds`, `NameTooLong`, `EntryDataOutOfBounds`, `EntryIdOutOfRange`, `NameContainsNul`.
-
-## Быстрый запуск тестов
-
-```bash
-cargo test -p nres -- --nocapture
-```
diff --git a/crates/nres/src/error.rs b/crates/nres/src/error.rs
deleted file mode 100644
index 9a3c651..0000000
--- a/crates/nres/src/error.rs
+++ /dev/null
@@ -1,110 +0,0 @@
-use core::fmt;
-
-#[derive(Debug)]
-#[non_exhaustive]
-pub enum Error {
- Io(std::io::Error),
-
- InvalidMagic {
- got: [u8; 4],
- },
- UnsupportedVersion {
- got: u32,
- },
- TotalSizeMismatch {
- header: u32,
- actual: u64,
- },
-
- InvalidEntryCount {
- got: i32,
- },
- TooManyEntries {
- got: usize,
- },
- DirectoryOutOfBounds {
- directory_offset: u64,
- directory_len: u64,
- file_len: u64,
- },
-
- EntryIdOutOfRange {
- id: u32,
- entry_count: u32,
- },
- EntryDataOutOfBounds {
- id: u32,
- offset: u64,
- size: u32,
- directory_offset: u64,
- },
- NameTooLong {
- got: usize,
- max: usize,
- },
- NameContainsNul,
- BadNameEncoding,
-
- IntegerOverflow,
-
- RawModeDisallowsOperation(&'static str),
-}
-
-impl From<std::io::Error> for Error {
- fn from(value: std::io::Error) -> Self {
- Self::Io(value)
- }
-}
-
-impl fmt::Display for Error {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Error::Io(e) => write!(f, "I/O error: {e}"),
- Error::InvalidMagic { got } => write!(f, "invalid NRes magic: {got:02X?}"),
- Error::UnsupportedVersion { got } => {
- write!(f, "unsupported NRes version: {got:#x}")
- }
- Error::TotalSizeMismatch { header, actual } => {
- write!(f, "NRes total_size mismatch: header={header}, actual={actual}")
- }
- Error::InvalidEntryCount { got } => write!(f, "invalid entry_count: {got}"),
- Error::TooManyEntries { got } => write!(f, "too many entries: {got} exceeds u32::MAX"),
- Error::DirectoryOutOfBounds {
- directory_offset,
- directory_len,
- file_len,
- } => write!(
- f,
- "directory out of bounds: off={directory_offset}, len={directory_len}, file={file_len}"
- ),
- Error::EntryIdOutOfRange { id, entry_count } => {
- write!(f, "entry id out of range: id={id}, count={entry_count}")
- }
- Error::EntryDataOutOfBounds {
- id,
- offset,
- size,
- directory_offset,
- } => write!(
- f,
- "entry data out of bounds: id={id}, off={offset}, size={size}, dir_off={directory_offset}"
- ),
- Error::NameTooLong { got, max } => write!(f, "name too long: {got} > {max}"),
- Error::NameContainsNul => write!(f, "name contains NUL byte"),
- Error::BadNameEncoding => write!(f, "bad name encoding"),
- Error::IntegerOverflow => write!(f, "integer overflow"),
- Error::RawModeDisallowsOperation(op) => {
- write!(f, "operation not allowed in raw mode: {op}")
- }
- }
- }
-}
-
-impl std::error::Error for Error {
- fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
- match self {
- Self::Io(err) => Some(err),
- _ => None,
- }
- }
-}
diff --git a/crates/nres/src/lib.rs b/crates/nres/src/lib.rs
deleted file mode 100644
index 571b395..0000000
--- a/crates/nres/src/lib.rs
+++ /dev/null
@@ -1,772 +0,0 @@
-pub mod error;
-
-use crate::error::Error;
-use common::{OutputBuffer, ResourceData};
-use core::ops::Range;
-use std::cmp::Ordering;
-use std::fs::{self, OpenOptions as FsOpenOptions};
-use std::io::Write;
-use std::path::{Path, PathBuf};
-use std::sync::Arc;
-use std::time::{SystemTime, UNIX_EPOCH};
-
-pub type Result<T> = core::result::Result<T, Error>;
-
-#[derive(Clone, Debug, Default)]
-pub struct OpenOptions {
- pub raw_mode: bool,
- pub sequential_hint: bool,
- pub prefetch_pages: bool,
-}
-
-#[derive(Clone, Debug, Default)]
-pub enum OpenMode {
- #[default]
- ReadOnly,
- ReadWrite,
-}
-
-#[derive(Clone, Debug)]
-pub struct ArchiveHeader {
- pub magic: [u8; 4],
- pub version: u32,
- pub entry_count: u32,
- pub total_size: u32,
- pub directory_offset: u64,
- pub directory_size: u64,
-}
-
-#[derive(Clone, Debug)]
-pub struct ArchiveInfo {
- pub raw_mode: bool,
- pub file_size: u64,
- pub header: Option<ArchiveHeader>,
-}
-
-#[derive(Debug)]
-pub struct Archive {
- bytes: Arc<[u8]>,
- entries: Vec<EntryRecord>,
- info: ArchiveInfo,
- raw_mode: bool,
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
-pub struct EntryId(pub u32);
-
-#[derive(Clone, Debug)]
-pub struct EntryMeta {
- pub kind: u32,
- pub attr1: u32,
- pub attr2: u32,
- pub attr3: u32,
- pub name: String,
- pub data_offset: u64,
- pub data_size: u32,
- pub sort_index: u32,
-}
-
-#[derive(Copy, Clone, Debug)]
-pub struct EntryRef<'a> {
- pub id: EntryId,
- pub meta: &'a EntryMeta,
-}
-
-#[derive(Copy, Clone, Debug)]
-pub struct EntryInspect<'a> {
- pub id: EntryId,
- pub meta: &'a EntryMeta,
- pub name_raw: &'a [u8; 36],
-}
-
-#[derive(Clone, Debug)]
-struct EntryRecord {
- meta: EntryMeta,
- name_raw: [u8; 36],
-}
-
-impl Archive {
- pub fn open_path(path: impl AsRef<Path>) -> Result<Self> {
- Self::open_path_with(path, OpenMode::ReadOnly, OpenOptions::default())
- }
-
- pub fn open_path_with(
- path: impl AsRef<Path>,
- _mode: OpenMode,
- opts: OpenOptions,
- ) -> Result<Self> {
- let bytes = fs::read(path.as_ref())?;
- let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice());
- Self::open_bytes(arc, opts)
- }
-
- pub fn open_bytes(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Self> {
- let file_size = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
- let (entries, header) = parse_archive(&bytes, opts.raw_mode)?;
- if opts.prefetch_pages {
- prefetch_pages(&bytes);
- }
- Ok(Self {
- bytes,
- entries,
- info: ArchiveInfo {
- raw_mode: opts.raw_mode,
- file_size,
- header,
- },
- raw_mode: opts.raw_mode,
- })
- }
-
- pub fn info(&self) -> &ArchiveInfo {
- &self.info
- }
-
- pub fn entry_count(&self) -> usize {
- self.entries.len()
- }
-
- pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
- self.entries.iter().enumerate().filter_map(|(idx, entry)| {
- let id = u32::try_from(idx).ok()?;
- Some(EntryRef {
- id: EntryId(id),
- meta: &entry.meta,
- })
- })
- }
-
- pub fn entries_inspect(&self) -> impl Iterator<Item = EntryInspect<'_>> {
- self.entries.iter().enumerate().filter_map(|(idx, entry)| {
- let id = u32::try_from(idx).ok()?;
- Some(EntryInspect {
- id: EntryId(id),
- meta: &entry.meta,
- name_raw: &entry.name_raw,
- })
- })
- }
-
- pub fn find(&self, name: &str) -> Option<EntryId> {
- if self.entries.is_empty() {
- return None;
- }
-
- if !self.raw_mode {
- let mut low = 0usize;
- let mut high = self.entries.len();
- while low < high {
- let mid = low + (high - low) / 2;
- let Ok(target_idx) = usize::try_from(self.entries[mid].meta.sort_index) else {
- break;
- };
- if target_idx >= self.entries.len() {
- break;
- }
- let cmp = cmp_name_case_insensitive(
- name.as_bytes(),
- entry_name_bytes(&self.entries[target_idx].name_raw),
- );
- match cmp {
- Ordering::Less => high = mid,
- Ordering::Greater => low = mid + 1,
- Ordering::Equal => {
- let id = u32::try_from(target_idx).ok()?;
- return Some(EntryId(id));
- }
- }
- }
- }
-
- self.entries.iter().enumerate().find_map(|(idx, entry)| {
- if cmp_name_case_insensitive(name.as_bytes(), entry_name_bytes(&entry.name_raw))
- == Ordering::Equal
- {
- let id = u32::try_from(idx).ok()?;
- Some(EntryId(id))
- } else {
- None
- }
- })
- }
-
- pub fn get(&self, id: EntryId) -> Option<EntryRef<'_>> {
- let idx = usize::try_from(id.0).ok()?;
- let entry = self.entries.get(idx)?;
- Some(EntryRef {
- id,
- meta: &entry.meta,
- })
- }
-
- pub fn inspect(&self, id: EntryId) -> Option<EntryInspect<'_>> {
- let idx = usize::try_from(id.0).ok()?;
- let entry = self.entries.get(idx)?;
- Some(EntryInspect {
- id,
- meta: &entry.meta,
- name_raw: &entry.name_raw,
- })
- }
-
- pub fn read(&self, id: EntryId) -> Result<ResourceData<'_>> {
- let range = self.entry_range(id)?;
- Ok(ResourceData::Borrowed(&self.bytes[range]))
- }
-
- pub fn read_into(&self, id: EntryId, out: &mut dyn OutputBuffer) -> Result<usize> {
- let range = self.entry_range(id)?;
- out.write_exact(&self.bytes[range.clone()])?;
- Ok(range.len())
- }
-
- pub fn raw_slice(&self, id: EntryId) -> Result<Option<&[u8]>> {
- let range = self.entry_range(id)?;
- Ok(Some(&self.bytes[range]))
- }
-
- pub fn edit_path(path: impl AsRef<Path>) -> Result<Editor> {
- let path_buf = path.as_ref().to_path_buf();
- let bytes = fs::read(&path_buf)?;
- let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice());
- let (entries, _) = parse_archive(&arc, false)?;
- let mut editable = Vec::with_capacity(entries.len());
- for entry in &entries {
- let range = checked_range(entry.meta.data_offset, entry.meta.data_size, arc.len())?;
- editable.push(EditableEntry {
- meta: entry.meta.clone(),
- name_raw: entry.name_raw,
- data: EntryData::Borrowed(range), // Copy-on-write: only store range
- });
- }
- Ok(Editor {
- path: path_buf,
- source: arc,
- entries: editable,
- })
- }
-
- fn entry_range(&self, id: EntryId) -> Result<Range<usize>> {
- let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
- let Some(entry) = self.entries.get(idx) else {
- return Err(Error::EntryIdOutOfRange {
- id: id.0,
- entry_count: saturating_u32_len(self.entries.len()),
- });
- };
- checked_range(
- entry.meta.data_offset,
- entry.meta.data_size,
- self.bytes.len(),
- )
- }
-}
-
-pub struct Editor {
- path: PathBuf,
- source: Arc<[u8]>,
- entries: Vec<EditableEntry>,
-}
-
-#[derive(Clone, Debug)]
-enum EntryData {
- Borrowed(Range<usize>),
- Modified(Vec<u8>),
-}
-
-#[derive(Clone, Debug)]
-struct EditableEntry {
- meta: EntryMeta,
- name_raw: [u8; 36],
- data: EntryData,
-}
-
-impl EditableEntry {
- fn data_slice<'a>(&'a self, source: &'a Arc<[u8]>) -> &'a [u8] {
- match &self.data {
- EntryData::Borrowed(range) => &source[range.clone()],
- EntryData::Modified(vec) => vec.as_slice(),
- }
- }
-}
-
-#[derive(Clone, Debug)]
-pub struct NewEntry<'a> {
- pub kind: u32,
- pub attr1: u32,
- pub attr2: u32,
- pub attr3: u32,
- pub name: &'a str,
- pub data: &'a [u8],
-}
-
-impl Editor {
- pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
- self.entries.iter().enumerate().filter_map(|(idx, entry)| {
- let id = u32::try_from(idx).ok()?;
- Some(EntryRef {
- id: EntryId(id),
- meta: &entry.meta,
- })
- })
- }
-
- pub fn add(&mut self, entry: NewEntry<'_>) -> Result<EntryId> {
- let name_raw = encode_name_field(entry.name)?;
- let id_u32 = u32::try_from(self.entries.len()).map_err(|_| Error::IntegerOverflow)?;
- let data_size = u32::try_from(entry.data.len()).map_err(|_| Error::IntegerOverflow)?;
- self.entries.push(EditableEntry {
- meta: EntryMeta {
- kind: entry.kind,
- attr1: entry.attr1,
- attr2: entry.attr2,
- attr3: entry.attr3,
- name: decode_name(entry_name_bytes(&name_raw)),
- data_offset: 0,
- data_size,
- sort_index: 0,
- },
- name_raw,
- data: EntryData::Modified(entry.data.to_vec()),
- });
- Ok(EntryId(id_u32))
- }
-
- pub fn replace_data(&mut self, id: EntryId, data: &[u8]) -> Result<()> {
- let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
- let Some(entry) = self.entries.get_mut(idx) else {
- return Err(Error::EntryIdOutOfRange {
- id: id.0,
- entry_count: saturating_u32_len(self.entries.len()),
- });
- };
- entry.meta.data_size = u32::try_from(data.len()).map_err(|_| Error::IntegerOverflow)?;
- // Replace with new data (triggers copy-on-write if borrowed)
- entry.data = EntryData::Modified(data.to_vec());
- Ok(())
- }
-
- pub fn remove(&mut self, id: EntryId) -> Result<()> {
- let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
- if idx >= self.entries.len() {
- return Err(Error::EntryIdOutOfRange {
- id: id.0,
- entry_count: saturating_u32_len(self.entries.len()),
- });
- }
- self.entries.remove(idx);
- Ok(())
- }
-
- pub fn commit(mut self) -> Result<()> {
- let count_u32 = u32::try_from(self.entries.len()).map_err(|_| Error::IntegerOverflow)?;
-
- // Pre-calculate capacity to avoid reallocations
- let total_data_size: usize = self
- .entries
- .iter()
- .map(|e| e.data_slice(&self.source).len())
- .sum();
- let padding_estimate = self.entries.len() * 8; // Max 8 bytes padding per entry
- let directory_size = self.entries.len() * 64; // 64 bytes per entry
- let capacity = 16 + total_data_size + padding_estimate + directory_size;
-
- let mut out = Vec::with_capacity(capacity);
- out.resize(16, 0); // Header
-
- // Keep reference to source for copy-on-write
- let source = &self.source;
-
- for entry in &mut self.entries {
- entry.meta.data_offset =
- u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?;
-
- // Calculate size and get slice separately to avoid borrow conflicts
- let data_len = entry.data_slice(source).len();
- entry.meta.data_size = u32::try_from(data_len).map_err(|_| Error::IntegerOverflow)?;
-
- // Now get the slice again for writing
- let data_slice = entry.data_slice(source);
- out.extend_from_slice(data_slice);
-
- let padding = (8 - (out.len() % 8)) % 8;
- if padding > 0 {
- out.resize(out.len() + padding, 0);
- }
- }
-
- let mut sort_order: Vec<usize> = (0..self.entries.len()).collect();
- sort_order.sort_by(|a, b| {
- cmp_name_case_insensitive(
- entry_name_bytes(&self.entries[*a].name_raw),
- entry_name_bytes(&self.entries[*b].name_raw),
- )
- });
-
- for (idx, entry) in self.entries.iter_mut().enumerate() {
- // sort_index stores the original-entry index at sorted position `idx`.
- // This mirrors the format emitted by the retail assets and test fixtures.
- entry.meta.sort_index =
- u32::try_from(sort_order[idx]).map_err(|_| Error::IntegerOverflow)?;
- }
-
- for entry in &self.entries {
- let data_offset_u32 =
- u32::try_from(entry.meta.data_offset).map_err(|_| Error::IntegerOverflow)?;
- push_u32(&mut out, entry.meta.kind);
- push_u32(&mut out, entry.meta.attr1);
- push_u32(&mut out, entry.meta.attr2);
- push_u32(&mut out, entry.meta.data_size);
- push_u32(&mut out, entry.meta.attr3);
- out.extend_from_slice(&entry.name_raw);
- push_u32(&mut out, data_offset_u32);
- push_u32(&mut out, entry.meta.sort_index);
- }
-
- let total_size_u32 = u32::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?;
- out[0..4].copy_from_slice(b"NRes");
- out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
- out[8..12].copy_from_slice(&count_u32.to_le_bytes());
- out[12..16].copy_from_slice(&total_size_u32.to_le_bytes());
-
- write_atomic(&self.path, &out)
- }
-}
-
-fn parse_archive(
- bytes: &[u8],
- raw_mode: bool,
-) -> Result<(Vec<EntryRecord>, Option<ArchiveHeader>)> {
- if raw_mode {
- let data_size = u32::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
- let entry = EntryRecord {
- meta: EntryMeta {
- kind: 0,
- attr1: 0,
- attr2: 0,
- attr3: 0,
- name: String::from("RAW"),
- data_offset: 0,
- data_size,
- sort_index: 0,
- },
- name_raw: {
- let mut name = [0u8; 36];
- let bytes_name = b"RAW";
- name[..bytes_name.len()].copy_from_slice(bytes_name);
- name
- },
- };
- return Ok((vec![entry], None));
- }
-
- if bytes.len() < 16 {
- let mut got = [0u8; 4];
- let copy_len = bytes.len().min(4);
- got[..copy_len].copy_from_slice(&bytes[..copy_len]);
- return Err(Error::InvalidMagic { got });
- }
-
- let mut magic = [0u8; 4];
- magic.copy_from_slice(&bytes[0..4]);
- if &magic != b"NRes" {
- return Err(Error::InvalidMagic { got: magic });
- }
-
- let version = read_u32(bytes, 4)?;
- if version != 0x100 {
- return Err(Error::UnsupportedVersion { got: version });
- }
-
- let entry_count_i32 = i32::from_le_bytes(
- bytes[8..12]
- .try_into()
- .map_err(|_| Error::IntegerOverflow)?,
- );
- if entry_count_i32 < 0 {
- return Err(Error::InvalidEntryCount {
- got: entry_count_i32,
- });
- }
- let entry_count = usize::try_from(entry_count_i32).map_err(|_| Error::IntegerOverflow)?;
-
- // Validate entry_count fits in u32 (required for EntryId)
- if entry_count > u32::MAX as usize {
- return Err(Error::TooManyEntries { got: entry_count });
- }
-
- let total_size = read_u32(bytes, 12)?;
- let actual_size = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
- if u64::from(total_size) != actual_size {
- return Err(Error::TotalSizeMismatch {
- header: total_size,
- actual: actual_size,
- });
- }
-
- let directory_len = u64::try_from(entry_count)
- .map_err(|_| Error::IntegerOverflow)?
- .checked_mul(64)
- .ok_or(Error::IntegerOverflow)?;
- let directory_offset =
- u64::from(total_size)
- .checked_sub(directory_len)
- .ok_or(Error::DirectoryOutOfBounds {
- directory_offset: 0,
- directory_len,
- file_len: actual_size,
- })?;
-
- if directory_offset < 16 || directory_offset + directory_len > actual_size {
- return Err(Error::DirectoryOutOfBounds {
- directory_offset,
- directory_len,
- file_len: actual_size,
- });
- }
-
- let mut entries = Vec::with_capacity(entry_count);
- for index in 0..entry_count {
- let base = usize::try_from(directory_offset)
- .map_err(|_| Error::IntegerOverflow)?
- .checked_add(index.checked_mul(64).ok_or(Error::IntegerOverflow)?)
- .ok_or(Error::IntegerOverflow)?;
-
- let kind = read_u32(bytes, base)?;
- let attr1 = read_u32(bytes, base + 4)?;
- let attr2 = read_u32(bytes, base + 8)?;
- let data_size = read_u32(bytes, base + 12)?;
- let attr3 = read_u32(bytes, base + 16)?;
-
- let mut name_raw = [0u8; 36];
- let name_slice = bytes
- .get(base + 20..base + 56)
- .ok_or(Error::IntegerOverflow)?;
- name_raw.copy_from_slice(name_slice);
-
- let name_bytes = entry_name_bytes(&name_raw);
- if name_bytes.len() > 35 {
- return Err(Error::NameTooLong {
- got: name_bytes.len(),
- max: 35,
- });
- }
-
- let data_offset = u64::from(read_u32(bytes, base + 56)?);
- let sort_index = read_u32(bytes, base + 60)?;
-
- let end = data_offset
- .checked_add(u64::from(data_size))
- .ok_or(Error::IntegerOverflow)?;
- if data_offset < 16 || end > directory_offset {
- return Err(Error::EntryDataOutOfBounds {
- id: u32::try_from(index).map_err(|_| Error::IntegerOverflow)?,
- offset: data_offset,
- size: data_size,
- directory_offset,
- });
- }
-
- entries.push(EntryRecord {
- meta: EntryMeta {
- kind,
- attr1,
- attr2,
- attr3,
- name: decode_name(name_bytes),
- data_offset,
- data_size,
- sort_index,
- },
- name_raw,
- });
- }
-
- Ok((
- entries,
- Some(ArchiveHeader {
- magic: *b"NRes",
- version,
- entry_count: u32::try_from(entry_count).map_err(|_| Error::IntegerOverflow)?,
- total_size,
- directory_offset,
- directory_size: directory_len,
- }),
- ))
-}
-
-fn checked_range(offset: u64, size: u32, bytes_len: usize) -> Result<Range<usize>> {
- let start = usize::try_from(offset).map_err(|_| Error::IntegerOverflow)?;
- let len = usize::try_from(size).map_err(|_| Error::IntegerOverflow)?;
- let end = start.checked_add(len).ok_or(Error::IntegerOverflow)?;
- if end > bytes_len {
- return Err(Error::IntegerOverflow);
- }
- Ok(start..end)
-}
-
-fn read_u32(bytes: &[u8], offset: usize) -> Result<u32> {
- let data = bytes
- .get(offset..offset + 4)
- .ok_or(Error::IntegerOverflow)?;
- let arr: [u8; 4] = data.try_into().map_err(|_| Error::IntegerOverflow)?;
- Ok(u32::from_le_bytes(arr))
-}
-
-fn push_u32(out: &mut Vec<u8>, value: u32) {
- out.extend_from_slice(&value.to_le_bytes());
-}
-
-fn encode_name_field(name: &str) -> Result<[u8; 36]> {
- let bytes = name.as_bytes();
- if bytes.contains(&0) {
- return Err(Error::NameContainsNul);
- }
- if bytes.len() > 35 {
- return Err(Error::NameTooLong {
- got: bytes.len(),
- max: 35,
- });
- }
-
- let mut out = [0u8; 36];
- out[..bytes.len()].copy_from_slice(bytes);
- Ok(out)
-}
-
-fn entry_name_bytes(raw: &[u8; 36]) -> &[u8] {
- let len = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
- &raw[..len]
-}
-
-fn decode_name(name: &[u8]) -> String {
- name.iter().map(|b| char::from(*b)).collect()
-}
-
-fn cmp_name_case_insensitive(a: &[u8], b: &[u8]) -> Ordering {
- let mut idx = 0usize;
- let min_len = a.len().min(b.len());
- while idx < min_len {
- let left = ascii_lower(a[idx]);
- let right = ascii_lower(b[idx]);
- if left != right {
- return left.cmp(&right);
- }
- idx += 1;
- }
- a.len().cmp(&b.len())
-}
-
-fn ascii_lower(value: u8) -> u8 {
- if value.is_ascii_uppercase() {
- value + 32
- } else {
- value
- }
-}
-
-fn saturating_u32_len(len: usize) -> u32 {
- u32::try_from(len).unwrap_or(u32::MAX)
-}
-
-fn prefetch_pages(bytes: &[u8]) {
- use std::hint::black_box;
-
- let mut cursor = 0usize;
- let mut sink = 0u8;
- while cursor < bytes.len() {
- sink ^= bytes[cursor];
- cursor = cursor.saturating_add(4096);
- }
- black_box(sink);
-}
-
-fn write_atomic(path: &Path, content: &[u8]) -> Result<()> {
- let file_name = path
- .file_name()
- .and_then(|name| name.to_str())
- .unwrap_or("archive");
- let parent = path.parent().unwrap_or_else(|| Path::new("."));
-
- let mut temp_path = None;
- for attempt in 0..128u32 {
- let name = format!(
- ".{}.tmp.{}.{}.{}",
- file_name,
- std::process::id(),
- unix_time_nanos(),
- attempt
- );
- let candidate = parent.join(name);
- let opened = FsOpenOptions::new()
- .create_new(true)
- .write(true)
- .open(&candidate);
- if let Ok(mut file) = opened {
- file.write_all(content)?;
- file.sync_all()?;
- temp_path = Some((candidate, file));
- break;
- }
- }
-
- let Some((tmp_path, mut file)) = temp_path else {
- return Err(Error::Io(std::io::Error::new(
- std::io::ErrorKind::AlreadyExists,
- "failed to create temporary file for atomic write",
- )));
- };
-
- file.flush()?;
- drop(file);
-
- if let Err(err) = replace_file_atomically(&tmp_path, path) {
- let _ = fs::remove_file(&tmp_path);
- return Err(Error::Io(err));
- }
-
- Ok(())
-}
-
-#[cfg(not(windows))]
-fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> {
- fs::rename(src, dst)
-}
-
-#[cfg(windows)]
-fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> {
- use std::iter;
- use std::os::windows::ffi::OsStrExt;
- use windows_sys::Win32::Storage::FileSystem::{
- MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
- };
-
- let src_wide: Vec<u16> = src.as_os_str().encode_wide().chain(iter::once(0)).collect();
- let dst_wide: Vec<u16> = dst.as_os_str().encode_wide().chain(iter::once(0)).collect();
-
- // SAFETY: pointers reference NUL-terminated UTF-16 buffers that stay alive
- // for the duration of the call; flags and argument contract match WinAPI.
- let ok = unsafe {
- MoveFileExW(
- src_wide.as_ptr(),
- dst_wide.as_ptr(),
- MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
- )
- };
-
- if ok == 0 {
- Err(std::io::Error::last_os_error())
- } else {
- Ok(())
- }
-}
-
-fn unix_time_nanos() -> u128 {
- match SystemTime::now().duration_since(UNIX_EPOCH) {
- Ok(duration) => duration.as_nanos(),
- Err(_) => 0,
- }
-}
-
-#[cfg(test)]
-mod tests;
diff --git a/crates/nres/src/tests.rs b/crates/nres/src/tests.rs
deleted file mode 100644
index bfa75a8..0000000
--- a/crates/nres/src/tests.rs
+++ /dev/null
@@ -1,983 +0,0 @@
-use super::*;
-use common::collect_files_recursive;
-use std::any::Any;
-use std::fs;
-use std::panic::{catch_unwind, AssertUnwindSafe};
-
-#[derive(Clone)]
-struct SyntheticEntry<'a> {
- kind: u32,
- attr1: u32,
- attr2: u32,
- attr3: u32,
- name: &'a str,
- data: &'a [u8],
-}
-
-fn nres_test_files() -> Vec<PathBuf> {
- let root = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("..")
- .join("..")
- .join("testdata")
- .join("nres");
- let mut files = Vec::new();
- collect_files_recursive(&root, &mut files);
- files.sort();
- files
- .into_iter()
- .filter(|path| {
- fs::read(path)
- .map(|data| data.get(0..4) == Some(b"NRes"))
- .unwrap_or(false)
- })
- .collect()
-}
-
-fn make_temp_copy(original: &Path, bytes: &[u8]) -> PathBuf {
- let mut path = std::env::temp_dir();
- let file_name = original
- .file_name()
- .and_then(|v| v.to_str())
- .unwrap_or("archive");
- path.push(format!(
- "nres-test-{}-{}-{}",
- std::process::id(),
- unix_time_nanos(),
- file_name
- ));
- fs::write(&path, bytes).expect("failed to create temp file");
- path
-}
-
-fn panic_message(payload: Box<dyn Any + Send>) -> String {
- let any = payload.as_ref();
- if let Some(message) = any.downcast_ref::<String>() {
- return message.clone();
- }
- if let Some(message) = any.downcast_ref::<&str>() {
- return (*message).to_string();
- }
- String::from("panic without message")
-}
-
-fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
- let slice = bytes
- .get(offset..offset + 4)
- .expect("u32 read out of bounds in test");
- let arr: [u8; 4] = slice.try_into().expect("u32 conversion failed in test");
- u32::from_le_bytes(arr)
-}
-
-fn read_i32_le(bytes: &[u8], offset: usize) -> i32 {
- let slice = bytes
- .get(offset..offset + 4)
- .expect("i32 read out of bounds in test");
- let arr: [u8; 4] = slice.try_into().expect("i32 conversion failed in test");
- i32::from_le_bytes(arr)
-}
-
-fn name_field_bytes(raw: &[u8; 36]) -> Option<&[u8]> {
- let nul = raw.iter().position(|value| *value == 0)?;
- Some(&raw[..nul])
-}
-
-fn build_nres_bytes(entries: &[SyntheticEntry<'_>]) -> Vec<u8> {
- let mut out = vec![0u8; 16];
- let mut offsets = Vec::with_capacity(entries.len());
-
- for entry in entries {
- offsets.push(u32::try_from(out.len()).expect("offset overflow"));
- out.extend_from_slice(entry.data);
- let padding = (8 - (out.len() % 8)) % 8;
- if padding > 0 {
- out.resize(out.len() + padding, 0);
- }
- }
-
- let mut sort_order: Vec<usize> = (0..entries.len()).collect();
- sort_order.sort_by(|a, b| {
- cmp_name_case_insensitive(entries[*a].name.as_bytes(), entries[*b].name.as_bytes())
- });
-
- for (index, entry) in entries.iter().enumerate() {
- let mut name_raw = [0u8; 36];
- let name_bytes = entry.name.as_bytes();
- assert!(name_bytes.len() <= 35, "name too long in fixture");
- name_raw[..name_bytes.len()].copy_from_slice(name_bytes);
-
- push_u32(&mut out, entry.kind);
- push_u32(&mut out, entry.attr1);
- push_u32(&mut out, entry.attr2);
- push_u32(
- &mut out,
- u32::try_from(entry.data.len()).expect("data size overflow"),
- );
- push_u32(&mut out, entry.attr3);
- out.extend_from_slice(&name_raw);
- push_u32(&mut out, offsets[index]);
- push_u32(
- &mut out,
- u32::try_from(sort_order[index]).expect("sort index overflow"),
- );
- }
-
- out[0..4].copy_from_slice(b"NRes");
- out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
- out[8..12].copy_from_slice(
- &u32::try_from(entries.len())
- .expect("count overflow")
- .to_le_bytes(),
- );
- let total_size = u32::try_from(out.len()).expect("size overflow");
- out[12..16].copy_from_slice(&total_size.to_le_bytes());
- out
-}
-
-#[test]
-fn nres_docs_structural_invariants_all_files() {
- let files = nres_test_files();
- if files.is_empty() {
- eprintln!(
- "skipping nres_docs_structural_invariants_all_files: no NRes archives in testdata/nres"
- );
- return;
- }
-
- for path in files {
- let bytes = fs::read(&path).unwrap_or_else(|err| {
- panic!("failed to read {}: {err}", path.display());
- });
-
- assert!(
- bytes.len() >= 16,
- "NRes header too short in {}",
- path.display()
- );
- assert_eq!(&bytes[0..4], b"NRes", "bad magic in {}", path.display());
- assert_eq!(
- read_u32_le(&bytes, 4),
- 0x100,
- "bad version in {}",
- path.display()
- );
- assert_eq!(
- usize::try_from(read_u32_le(&bytes, 12)).expect("size overflow"),
- bytes.len(),
- "header.total_size mismatch in {}",
- path.display()
- );
-
- let entry_count_i32 = read_i32_le(&bytes, 8);
- assert!(
- entry_count_i32 >= 0,
- "negative entry_count={} in {}",
- entry_count_i32,
- path.display()
- );
- let entry_count = usize::try_from(entry_count_i32).expect("entry_count overflow");
- let directory_len = entry_count.checked_mul(64).expect("directory_len overflow");
- let directory_offset = bytes
- .len()
- .checked_sub(directory_len)
- .unwrap_or_else(|| panic!("directory underflow in {}", path.display()));
- assert!(
- directory_offset >= 16,
- "directory offset before data area in {}",
- path.display()
- );
- assert_eq!(
- directory_offset + directory_len,
- bytes.len(),
- "directory not at file end in {}",
- path.display()
- );
-
- let mut sort_indices = Vec::with_capacity(entry_count);
- let mut entries = Vec::with_capacity(entry_count);
- for index in 0..entry_count {
- let base = directory_offset + index * 64;
- let size = usize::try_from(read_u32_le(&bytes, base + 12)).expect("size overflow");
- let data_offset =
- usize::try_from(read_u32_le(&bytes, base + 56)).expect("offset overflow");
- let sort_index =
- usize::try_from(read_u32_le(&bytes, base + 60)).expect("sort_index overflow");
-
- let mut name_raw = [0u8; 36];
- name_raw.copy_from_slice(
- bytes
- .get(base + 20..base + 56)
- .expect("name field out of bounds in test"),
- );
- let name_bytes = name_field_bytes(&name_raw).unwrap_or_else(|| {
- panic!(
- "name field without NUL terminator in {} entry #{index}",
- path.display()
- )
- });
- assert!(
- name_bytes.len() <= 35,
- "name longer than 35 bytes in {} entry #{index}",
- path.display()
- );
-
- sort_indices.push(sort_index);
- entries.push((name_bytes.to_vec(), data_offset, size));
- }
-
- let mut expected_sort: Vec<usize> = (0..entry_count).collect();
- expected_sort.sort_by(|a, b| cmp_name_case_insensitive(&entries[*a].0, &entries[*b].0));
- assert_eq!(
- sort_indices,
- expected_sort,
- "sort_index table mismatch in {}",
- path.display()
- );
-
- let mut data_regions: Vec<(usize, usize)> =
- entries.iter().map(|(_, off, size)| (*off, *size)).collect();
- data_regions.sort_by_key(|(off, _)| *off);
-
- for (idx, (data_offset, size)) in data_regions.iter().enumerate() {
- assert_eq!(
- data_offset % 8,
- 0,
- "data offset is not 8-byte aligned in {} (region #{idx})",
- path.display()
- );
- assert!(
- *data_offset >= 16,
- "data offset before header end in {} (region #{idx})",
- path.display()
- );
- assert!(
- data_offset.checked_add(*size).unwrap_or(usize::MAX) <= directory_offset,
- "data region overlaps directory in {} (region #{idx})",
- path.display()
- );
- }
-
- for pair in data_regions.windows(2) {
- let (start, size) = pair[0];
- let (next_start, _) = pair[1];
- let end = start
- .checked_add(size)
- .unwrap_or_else(|| panic!("size overflow in {}", path.display()));
- assert!(
- end <= next_start,
- "overlapping data regions in {}: [{start}, {end}) and next at {next_start}",
- path.display()
- );
-
- for (offset, value) in bytes[end..next_start].iter().enumerate() {
- assert_eq!(
- *value,
- 0,
- "non-zero alignment padding in {} at offset {}",
- path.display(),
- end + offset
- );
- }
- }
- }
-}
-
-#[test]
-fn nres_read_and_roundtrip_all_files() {
- let files = nres_test_files();
- if files.is_empty() {
- eprintln!("skipping nres_read_and_roundtrip_all_files: no NRes archives in testdata/nres");
- return;
- }
-
- let checked = files.len();
- let mut success = 0usize;
- let mut failures = Vec::new();
-
- for path in files {
- let display_path = path.display().to_string();
- let result = catch_unwind(AssertUnwindSafe(|| {
- let original = fs::read(&path).expect("failed to read archive");
- let archive = Archive::open_path(&path)
- .unwrap_or_else(|err| panic!("failed to open {}: {err}", path.display()));
-
- let count = archive.entry_count();
- assert_eq!(
- count,
- archive.entries().count(),
- "entry count mismatch: {}",
- path.display()
- );
-
- for idx in 0..count {
- let id = EntryId(idx as u32);
- let entry = archive
- .get(id)
- .unwrap_or_else(|| panic!("missing entry #{idx} in {}", path.display()));
-
- let payload = archive.read(id).unwrap_or_else(|err| {
- panic!("read failed for {} entry #{idx}: {err}", path.display())
- });
-
- let mut out = Vec::new();
- let written = archive.read_into(id, &mut out).unwrap_or_else(|err| {
- panic!(
- "read_into failed for {} entry #{idx}: {err}",
- path.display()
- )
- });
- assert_eq!(
- written,
- payload.as_slice().len(),
- "size mismatch in {} entry #{idx}",
- path.display()
- );
- assert_eq!(
- out.as_slice(),
- payload.as_slice(),
- "payload mismatch in {} entry #{idx}",
- path.display()
- );
-
- let raw = archive
- .raw_slice(id)
- .unwrap_or_else(|err| {
- panic!(
- "raw_slice failed for {} entry #{idx}: {err}",
- path.display()
- )
- })
- .expect("raw_slice must return Some for file-backed archive");
- assert_eq!(
- raw,
- payload.as_slice(),
- "raw slice mismatch in {} entry #{idx}",
- path.display()
- );
-
- let found = archive.find(&entry.meta.name).unwrap_or_else(|| {
- panic!(
- "find failed for name '{}' in {}",
- entry.meta.name,
- path.display()
- )
- });
- let found_meta = archive.get(found).expect("find returned invalid id");
- assert!(
- found_meta.meta.name.eq_ignore_ascii_case(&entry.meta.name),
- "find returned unrelated entry in {}",
- path.display()
- );
- }
-
- let temp_copy = make_temp_copy(&path, &original);
- let mut editor = Archive::edit_path(&temp_copy)
- .unwrap_or_else(|err| panic!("edit_path failed for {}: {err}", path.display()));
-
- for idx in 0..count {
- let data = archive
- .read(EntryId(idx as u32))
- .unwrap_or_else(|err| {
- panic!(
- "read before replace failed for {} entry #{idx}: {err}",
- path.display()
- )
- })
- .into_owned();
- editor
- .replace_data(EntryId(idx as u32), &data)
- .unwrap_or_else(|err| {
- panic!(
- "replace_data failed for {} entry #{idx}: {err}",
- path.display()
- )
- });
- }
-
- editor
- .commit()
- .unwrap_or_else(|err| panic!("commit failed for {}: {err}", path.display()));
- let rebuilt = fs::read(&temp_copy).expect("failed to read rebuilt archive");
- let _ = fs::remove_file(&temp_copy);
-
- assert_eq!(
- original,
- rebuilt,
- "byte-to-byte roundtrip mismatch for {}",
- path.display()
- );
- }));
-
- match result {
- Ok(()) => success += 1,
- Err(payload) => {
- failures.push(format!("{}: {}", display_path, panic_message(payload)));
- }
- }
- }
-
- let failed = failures.len();
- eprintln!(
- "NRes summary: checked={}, success={}, failed={}",
- checked, success, failed
- );
- if !failures.is_empty() {
- panic!(
- "NRes validation failed.\nsummary: checked={}, success={}, failed={}\n{}",
- checked,
- success,
- failed,
- failures.join("\n")
- );
- }
-}
-
-#[test]
-fn nres_raw_mode_exposes_whole_file() {
- let files = nres_test_files();
- let Some(first) = files.first() else {
- eprintln!("skipping nres_raw_mode_exposes_whole_file: no NRes archives in testdata/nres");
- return;
- };
- let original = fs::read(first).expect("failed to read archive");
- let arc: Arc<[u8]> = Arc::from(original.clone().into_boxed_slice());
-
- let archive = Archive::open_bytes(
- arc,
- OpenOptions {
- raw_mode: true,
- sequential_hint: false,
- prefetch_pages: false,
- },
- )
- .expect("raw mode open failed");
-
- assert_eq!(archive.entry_count(), 1);
- let data = archive.read(EntryId(0)).expect("raw read failed");
- assert_eq!(data.as_slice(), original.as_slice());
-}
-
-#[test]
-fn nres_raw_mode_accepts_non_nres_bytes() {
- let payload = b"not-an-nres-archive".to_vec();
- let bytes: Arc<[u8]> = Arc::from(payload.clone().into_boxed_slice());
-
- match Archive::open_bytes(bytes.clone(), OpenOptions::default()) {
- Err(Error::InvalidMagic { .. }) => {}
- other => panic!("expected InvalidMagic without raw_mode, got {other:?}"),
- }
-
- let archive = Archive::open_bytes(
- bytes,
- OpenOptions {
- raw_mode: true,
- sequential_hint: false,
- prefetch_pages: false,
- },
- )
- .expect("raw_mode should accept any bytes");
-
- assert_eq!(archive.entry_count(), 1);
- assert_eq!(archive.find("raw"), Some(EntryId(0)));
- assert_eq!(
- archive
- .read(EntryId(0))
- .expect("raw read failed")
- .as_slice(),
- payload.as_slice()
- );
-}
-
-#[test]
-fn nres_open_options_hints_do_not_change_payload() {
- let payload: Vec<u8> = (0..70_000u32).map(|v| (v % 251) as u8).collect();
- let src = build_nres_bytes(&[SyntheticEntry {
- kind: 7,
- attr1: 70,
- attr2: 700,
- attr3: 7000,
- name: "big.bin",
- data: &payload,
- }]);
- let arc: Arc<[u8]> = Arc::from(src.into_boxed_slice());
-
- let baseline = Archive::open_bytes(arc.clone(), OpenOptions::default())
- .expect("baseline open should succeed");
- let hinted = Archive::open_bytes(
- arc,
- OpenOptions {
- raw_mode: false,
- sequential_hint: true,
- prefetch_pages: true,
- },
- )
- .expect("open with hints should succeed");
-
- assert_eq!(baseline.entry_count(), 1);
- assert_eq!(hinted.entry_count(), 1);
- assert_eq!(baseline.find("BIG.BIN"), Some(EntryId(0)));
- assert_eq!(hinted.find("big.bin"), Some(EntryId(0)));
- assert_eq!(
- baseline
- .read(EntryId(0))
- .expect("baseline read failed")
- .as_slice(),
- hinted
- .read(EntryId(0))
- .expect("hinted read failed")
- .as_slice()
- );
-}
-
-#[test]
-fn nres_commit_empty_archive_has_minimal_layout() {
- let mut path = std::env::temp_dir();
- path.push(format!(
- "nres-empty-commit-{}-{}.lib",
- std::process::id(),
- unix_time_nanos()
- ));
- fs::write(&path, build_nres_bytes(&[])).expect("write empty archive failed");
-
- Archive::edit_path(&path)
- .expect("edit_path failed for empty archive")
- .commit()
- .expect("commit failed for empty archive");
-
- let bytes = fs::read(&path).expect("failed to read committed archive");
- assert_eq!(bytes.len(), 16, "empty archive must contain only header");
- assert_eq!(&bytes[0..4], b"NRes");
- assert_eq!(read_u32_le(&bytes, 4), 0x100);
- assert_eq!(read_u32_le(&bytes, 8), 0);
- assert_eq!(read_u32_le(&bytes, 12), 16);
-
- let _ = fs::remove_file(&path);
-}
-
-#[test]
-fn nres_commit_recomputes_header_directory_and_sort_table() {
- let mut path = std::env::temp_dir();
- path.push(format!(
- "nres-commit-layout-{}-{}.lib",
- std::process::id(),
- unix_time_nanos()
- ));
- fs::write(&path, build_nres_bytes(&[])).expect("write empty archive failed");
-
- let mut editor = Archive::edit_path(&path).expect("edit_path failed");
- editor
- .add(NewEntry {
- kind: 10,
- attr1: 1,
- attr2: 2,
- attr3: 3,
- name: "Zulu",
- data: b"aaaaa",
- })
- .expect("add #0 failed");
- editor
- .add(NewEntry {
- kind: 11,
- attr1: 4,
- attr2: 5,
- attr3: 6,
- name: "alpha",
- data: b"bbbbbbbb",
- })
- .expect("add #1 failed");
- editor
- .add(NewEntry {
- kind: 12,
- attr1: 7,
- attr2: 8,
- attr3: 9,
- name: "Beta",
- data: b"cccc",
- })
- .expect("add #2 failed");
- editor.commit().expect("commit failed");
-
- let bytes = fs::read(&path).expect("failed to read committed archive");
- assert_eq!(&bytes[0..4], b"NRes");
- assert_eq!(read_u32_le(&bytes, 4), 0x100);
-
- let entry_count = usize::try_from(read_u32_le(&bytes, 8)).expect("entry_count overflow");
- let total_size = usize::try_from(read_u32_le(&bytes, 12)).expect("total_size overflow");
- assert_eq!(entry_count, 3);
- assert_eq!(total_size, bytes.len());
-
- let directory_offset = total_size
- .checked_sub(entry_count * 64)
- .expect("invalid directory offset");
- assert!(directory_offset >= 16);
-
- let mut sort_indices = Vec::new();
- let mut prev_data_end = 16usize;
- for idx in 0..entry_count {
- let base = directory_offset + idx * 64;
- let data_size = usize::try_from(read_u32_le(&bytes, base + 12)).expect("size overflow");
- let data_offset = usize::try_from(read_u32_le(&bytes, base + 56)).expect("offset overflow");
- let sort_index =
- usize::try_from(read_u32_le(&bytes, base + 60)).expect("sort index overflow");
-
- assert_eq!(
- data_offset % 8,
- 0,
- "entry #{idx} data offset must be 8-byte aligned"
- );
- assert!(
- data_offset >= prev_data_end,
- "entry #{idx} offset regressed"
- );
- assert!(
- data_offset + data_size <= directory_offset,
- "entry #{idx} overlaps directory"
- );
- prev_data_end = data_offset + data_size;
- sort_indices.push(sort_index);
- }
-
- let names = ["Zulu", "alpha", "Beta"];
- let mut expected_sort: Vec<usize> = (0..names.len()).collect();
- expected_sort
- .sort_by(|a, b| cmp_name_case_insensitive(names[*a].as_bytes(), names[*b].as_bytes()));
- assert_eq!(
- sort_indices, expected_sort,
- "sort table must contain original indexes in case-insensitive alphabetical order"
- );
-
- let archive = Archive::open_path(&path).expect("re-open failed");
- assert_eq!(archive.find("zulu"), Some(EntryId(0)));
- assert_eq!(archive.find("ALPHA"), Some(EntryId(1)));
- assert_eq!(archive.find("beta"), Some(EntryId(2)));
-
- let _ = fs::remove_file(&path);
-}
-
-#[test]
-fn nres_synthetic_read_find_and_edit() {
- let payload_a = b"alpha";
- let payload_b = b"B";
- let payload_c = b"";
- let src = build_nres_bytes(&[
- SyntheticEntry {
- kind: 1,
- attr1: 10,
- attr2: 20,
- attr3: 30,
- name: "Alpha.TXT",
- data: payload_a,
- },
- SyntheticEntry {
- kind: 2,
- attr1: 11,
- attr2: 21,
- attr3: 31,
- name: "beta.bin",
- data: payload_b,
- },
- SyntheticEntry {
- kind: 3,
- attr1: 12,
- attr2: 22,
- attr3: 32,
- name: "Gamma",
- data: payload_c,
- },
- ]);
-
- let archive = Archive::open_bytes(
- Arc::from(src.clone().into_boxed_slice()),
- OpenOptions::default(),
- )
- .expect("open synthetic nres failed");
-
- assert_eq!(archive.entry_count(), 3);
- assert_eq!(archive.find("alpha.txt"), Some(EntryId(0)));
- assert_eq!(archive.find("BETA.BIN"), Some(EntryId(1)));
- assert_eq!(archive.find("gAmMa"), Some(EntryId(2)));
- assert_eq!(archive.find("missing"), None);
-
- assert_eq!(
- archive.read(EntryId(0)).expect("read #0 failed").as_slice(),
- payload_a
- );
- assert_eq!(
- archive.read(EntryId(1)).expect("read #1 failed").as_slice(),
- payload_b
- );
- assert_eq!(
- archive.read(EntryId(2)).expect("read #2 failed").as_slice(),
- payload_c
- );
-
- let mut path = std::env::temp_dir();
- path.push(format!(
- "nres-synth-edit-{}-{}.lib",
- std::process::id(),
- unix_time_nanos()
- ));
- fs::write(&path, &src).expect("write temp synthetic archive failed");
-
- let mut editor = Archive::edit_path(&path).expect("edit_path on synthetic archive failed");
- editor
- .replace_data(EntryId(1), b"replaced")
- .expect("replace_data failed");
- let added = editor
- .add(NewEntry {
- kind: 4,
- attr1: 13,
- attr2: 23,
- attr3: 33,
- name: "delta",
- data: b"new payload",
- })
- .expect("add failed");
- assert_eq!(added, EntryId(3));
- editor.remove(EntryId(2)).expect("remove failed");
- editor.commit().expect("commit failed");
-
- let edited = Archive::open_path(&path).expect("re-open edited archive failed");
- assert_eq!(edited.entry_count(), 3);
- assert_eq!(
- edited
- .read(edited.find("beta.bin").expect("find beta.bin failed"))
- .expect("read beta.bin failed")
- .as_slice(),
- b"replaced"
- );
- assert_eq!(
- edited
- .read(edited.find("delta").expect("find delta failed"))
- .expect("read delta failed")
- .as_slice(),
- b"new payload"
- );
- assert_eq!(edited.find("gamma"), None);
-
- let _ = fs::remove_file(&path);
-}
-
-#[test]
-fn nres_max_name_length_roundtrip() {
- let max_name = "12345678901234567890123456789012345";
- assert_eq!(max_name.len(), 35);
-
- let src = build_nres_bytes(&[SyntheticEntry {
- kind: 9,
- attr1: 1,
- attr2: 2,
- attr3: 3,
- name: max_name,
- data: b"payload",
- }]);
-
- let archive = Archive::open_bytes(Arc::from(src.into_boxed_slice()), OpenOptions::default())
- .expect("open synthetic nres failed");
-
- assert_eq!(archive.entry_count(), 1);
- assert_eq!(archive.find(max_name), Some(EntryId(0)));
- assert_eq!(
- archive.find(&max_name.to_ascii_lowercase()),
- Some(EntryId(0))
- );
-
- let entry = archive.get(EntryId(0)).expect("missing entry 0");
- assert_eq!(entry.meta.name, max_name);
- assert_eq!(
- archive
- .read(EntryId(0))
- .expect("read payload failed")
- .as_slice(),
- b"payload"
- );
-}
-
-#[test]
-fn nres_find_falls_back_when_sort_index_is_out_of_range() {
- let mut bytes = build_nres_bytes(&[
- SyntheticEntry {
- kind: 1,
- attr1: 0,
- attr2: 0,
- attr3: 0,
- name: "Alpha",
- data: b"a",
- },
- SyntheticEntry {
- kind: 2,
- attr1: 0,
- attr2: 0,
- attr3: 0,
- name: "Beta",
- data: b"b",
- },
- SyntheticEntry {
- kind: 3,
- attr1: 0,
- attr2: 0,
- attr3: 0,
- name: "Gamma",
- data: b"c",
- },
- ]);
-
- let entry_count = 3usize;
- let directory_offset = bytes
- .len()
- .checked_sub(entry_count * 64)
- .expect("directory offset underflow");
- let mid_entry_sort_index = directory_offset + 64 + 60;
- bytes[mid_entry_sort_index..mid_entry_sort_index + 4].copy_from_slice(&u32::MAX.to_le_bytes());
-
- let archive = Archive::open_bytes(Arc::from(bytes.into_boxed_slice()), OpenOptions::default())
- .expect("open archive with corrupted sort index failed");
-
- assert_eq!(archive.find("alpha"), Some(EntryId(0)));
- assert_eq!(archive.find("BETA"), Some(EntryId(1)));
- assert_eq!(archive.find("gamma"), Some(EntryId(2)));
- assert_eq!(archive.find("missing"), None);
-}
-
-#[test]
-fn nres_validation_error_cases() {
- let valid = build_nres_bytes(&[SyntheticEntry {
- kind: 1,
- attr1: 2,
- attr2: 3,
- attr3: 4,
- name: "ok",
- data: b"1234",
- }]);
-
- let mut invalid_magic = valid.clone();
- invalid_magic[0..4].copy_from_slice(b"FAIL");
- match Archive::open_bytes(
- Arc::from(invalid_magic.into_boxed_slice()),
- OpenOptions::default(),
- ) {
- Err(Error::InvalidMagic { .. }) => {}
- other => panic!("expected InvalidMagic, got {other:?}"),
- }
-
- let mut invalid_version = valid.clone();
- invalid_version[4..8].copy_from_slice(&0x200_u32.to_le_bytes());
- match Archive::open_bytes(
- Arc::from(invalid_version.into_boxed_slice()),
- OpenOptions::default(),
- ) {
- Err(Error::UnsupportedVersion { got }) => assert_eq!(got, 0x200),
- other => panic!("expected UnsupportedVersion, got {other:?}"),
- }
-
- let mut bad_total = valid.clone();
- bad_total[12..16].copy_from_slice(&0_u32.to_le_bytes());
- match Archive::open_bytes(
- Arc::from(bad_total.into_boxed_slice()),
- OpenOptions::default(),
- ) {
- Err(Error::TotalSizeMismatch { .. }) => {}
- other => panic!("expected TotalSizeMismatch, got {other:?}"),
- }
-
- let mut bad_count = valid.clone();
- bad_count[8..12].copy_from_slice(&(-1_i32).to_le_bytes());
- match Archive::open_bytes(
- Arc::from(bad_count.into_boxed_slice()),
- OpenOptions::default(),
- ) {
- Err(Error::InvalidEntryCount { got }) => assert_eq!(got, -1),
- other => panic!("expected InvalidEntryCount, got {other:?}"),
- }
-
- let mut bad_dir = valid.clone();
- bad_dir[8..12].copy_from_slice(&1000_u32.to_le_bytes());
- match Archive::open_bytes(
- Arc::from(bad_dir.into_boxed_slice()),
- OpenOptions::default(),
- ) {
- Err(Error::DirectoryOutOfBounds { .. }) => {}
- other => panic!("expected DirectoryOutOfBounds, got {other:?}"),
- }
-
- let mut long_name = valid.clone();
- let entry_base = long_name.len() - 64;
- for b in &mut long_name[entry_base + 20..entry_base + 56] {
- *b = b'X';
- }
- match Archive::open_bytes(
- Arc::from(long_name.into_boxed_slice()),
- OpenOptions::default(),
- ) {
- Err(Error::NameTooLong { .. }) => {}
- other => panic!("expected NameTooLong, got {other:?}"),
- }
-
- let mut bad_data = valid.clone();
- bad_data[entry_base + 56..entry_base + 60].copy_from_slice(&12_u32.to_le_bytes());
- bad_data[entry_base + 12..entry_base + 16].copy_from_slice(&32_u32.to_le_bytes());
- match Archive::open_bytes(
- Arc::from(bad_data.into_boxed_slice()),
- OpenOptions::default(),
- ) {
- Err(Error::EntryDataOutOfBounds { .. }) => {}
- other => panic!("expected EntryDataOutOfBounds, got {other:?}"),
- }
-
- let archive = Archive::open_bytes(Arc::from(valid.into_boxed_slice()), OpenOptions::default())
- .expect("open valid archive failed");
- match archive.read(EntryId(99)) {
- Err(Error::EntryIdOutOfRange { .. }) => {}
- other => panic!("expected EntryIdOutOfRange, got {other:?}"),
- }
-}
-
-#[test]
-fn nres_editor_validation_error_cases() {
- let mut path = std::env::temp_dir();
- path.push(format!(
- "nres-editor-errors-{}-{}.lib",
- std::process::id(),
- unix_time_nanos()
- ));
- let src = build_nres_bytes(&[]);
- fs::write(&path, src).expect("write empty archive failed");
-
- let mut editor = Archive::edit_path(&path).expect("edit_path failed");
-
- let long_name = "X".repeat(36);
- match editor.add(NewEntry {
- kind: 0,
- attr1: 0,
- attr2: 0,
- attr3: 0,
- name: &long_name,
- data: b"",
- }) {
- Err(Error::NameTooLong { .. }) => {}
- other => panic!("expected NameTooLong, got {other:?}"),
- }
-
- match editor.add(NewEntry {
- kind: 0,
- attr1: 0,
- attr2: 0,
- attr3: 0,
- name: "bad\0name",
- data: b"",
- }) {
- Err(Error::NameContainsNul) => {}
- other => panic!("expected NameContainsNul, got {other:?}"),
- }
-
- match editor.replace_data(EntryId(0), b"x") {
- Err(Error::EntryIdOutOfRange { .. }) => {}
- other => panic!("expected EntryIdOutOfRange, got {other:?}"),
- }
-
- match editor.remove(EntryId(0)) {
- Err(Error::EntryIdOutOfRange { .. }) => {}
- other => panic!("expected EntryIdOutOfRange, got {other:?}"),
- }
-
- let _ = fs::remove_file(&path);
-}
diff --git a/crates/render-core/Cargo.toml b/crates/render-core/Cargo.toml
deleted file mode 100644
index c93d624..0000000
--- a/crates/render-core/Cargo.toml
+++ /dev/null
@@ -1,11 +0,0 @@
-[package]
-name = "render-core"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
-msh-core = { path = "../msh-core" }
-
-[dev-dependencies]
-common = { path = "../common" }
-nres = { path = "../nres" }
diff --git a/crates/render-core/README.md b/crates/render-core/README.md
deleted file mode 100644
index a58f64f..0000000
--- a/crates/render-core/README.md
+++ /dev/null
@@ -1,14 +0,0 @@
-# render-core
-
-CPU-подготовка draw-данных для моделей `MSH`.
-
-Покрывает:
-
-- обход `node -> slot -> batch`;
-- раскрытие индексов в triangle-list (`position + uv0`);
-- расчёт bounds по вершинам.
-
-Тесты:
-
-- построение рендер-сеток на реальных `.msh` из `testdata`;
-- unit-test bounds.
diff --git a/crates/render-core/src/lib.rs b/crates/render-core/src/lib.rs
deleted file mode 100644
index c7a69d6..0000000
--- a/crates/render-core/src/lib.rs
+++ /dev/null
@@ -1,146 +0,0 @@
-use msh_core::Model;
-use std::collections::HashMap;
-
-pub const DEFAULT_UV_SCALE: f32 = 1024.0;
-
-#[derive(Clone, Debug)]
-pub struct RenderVertex {
- pub position: [f32; 3],
- pub uv0: [f32; 2],
-}
-
-#[derive(Clone, Debug)]
-pub struct RenderMesh {
- pub vertices: Vec<RenderVertex>,
- pub indices: Vec<u16>,
- pub batch_count: usize,
- pub index_overflow: bool,
-}
-
-impl RenderMesh {
- pub fn triangle_count(&self) -> usize {
- self.indices.len() / 3
- }
-}
-
-/// Builds an indexed triangle mesh for a specific LOD/group pair.
-pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh {
- let mut vertices = Vec::new();
- let mut indices = Vec::new();
- let mut index_remap: HashMap<usize, u16> = HashMap::new();
- let mut batch_count = 0usize;
- let mut index_overflow = false;
- let uv0 = model.uv0.as_ref();
-
- for node_index in 0..model.node_count {
- let Some(slot_idx) = model.slot_index(node_index, lod, group) else {
- continue;
- };
- let Some(slot) = model.slots.get(slot_idx) else {
- continue;
- };
- let batch_start = usize::from(slot.batch_start);
- let batch_end = batch_start.saturating_add(usize::from(slot.batch_count));
- if batch_end > model.batches.len() {
- continue;
- }
-
- for batch in &model.batches[batch_start..batch_end] {
- let index_start = usize::try_from(batch.index_start).unwrap_or(usize::MAX);
- let index_count = usize::from(batch.index_count);
- let index_end = index_start.saturating_add(index_count);
- if index_end > model.indices.len() || index_count < 3 {
- continue;
- }
-
- let batch_out_start = indices.len();
- let mut batch_valid = true;
- for &idx in &model.indices[index_start..index_end] {
- let final_idx_u64 = u64::from(batch.base_vertex).saturating_add(u64::from(idx));
- let Ok(final_idx) = usize::try_from(final_idx_u64) else {
- batch_valid = false;
- break;
- };
- let Some(pos) = model.positions.get(final_idx) else {
- batch_valid = false;
- break;
- };
-
- let local_index = if let Some(&mapped) = index_remap.get(&final_idx) {
- mapped
- } else {
- let Ok(mapped) = u16::try_from(vertices.len()) else {
- index_overflow = true;
- batch_valid = false;
- break;
- };
- let uv = uv0
- .and_then(|uvs| uvs.get(final_idx))
- .copied()
- .map(|packed| {
- [
- packed[0] as f32 / DEFAULT_UV_SCALE,
- packed[1] as f32 / DEFAULT_UV_SCALE,
- ]
- })
- .unwrap_or([0.0, 0.0]);
- vertices.push(RenderVertex {
- position: *pos,
- uv0: uv,
- });
- index_remap.insert(final_idx, mapped);
- mapped
- };
-
- indices.push(local_index);
- }
-
- if !batch_valid {
- indices.truncate(batch_out_start);
- continue;
- }
-
- batch_count += 1;
- }
- }
-
- RenderMesh {
- vertices,
- indices,
- batch_count,
- index_overflow,
- }
-}
-
-pub fn compute_bounds(vertices: &[[f32; 3]]) -> Option<([f32; 3], [f32; 3])> {
- compute_bounds_impl(vertices.iter().copied())
-}
-
-pub fn compute_bounds_for_mesh(vertices: &[RenderVertex]) -> Option<([f32; 3], [f32; 3])> {
- compute_bounds_impl(vertices.iter().map(|v| v.position))
-}
-
-fn compute_bounds_impl<I>(mut positions: I) -> Option<([f32; 3], [f32; 3])>
-where
- I: Iterator<Item = [f32; 3]>,
-{
- let first = positions.next()?;
- let mut min_v = first;
- let mut max_v = first;
-
- for pos in positions {
- for i in 0..3 {
- if pos[i] < min_v[i] {
- min_v[i] = pos[i];
- }
- if pos[i] > max_v[i] {
- max_v[i] = pos[i];
- }
- }
- }
-
- Some((min_v, max_v))
-}
-
-#[cfg(test)]
-mod tests;
diff --git a/crates/render-core/src/tests.rs b/crates/render-core/src/tests.rs
deleted file mode 100644
index 1c5285e..0000000
--- a/crates/render-core/src/tests.rs
+++ /dev/null
@@ -1,256 +0,0 @@
-use super::*;
-use common::collect_files_recursive;
-use msh_core::parse_model_payload;
-use nres::Archive;
-use std::fs;
-use std::path::{Path, PathBuf};
-
-fn nres_test_files() -> Vec<PathBuf> {
- let root = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("..")
- .join("..")
- .join("testdata");
- let mut files = Vec::new();
- collect_files_recursive(&root, &mut files);
- files.sort();
- files
- .into_iter()
- .filter(|path| {
- fs::read(path)
- .map(|bytes| bytes.get(0..4) == Some(b"NRes"))
- .unwrap_or(false)
- })
- .collect()
-}
-
-#[test]
-fn build_render_mesh_for_real_models() {
- let archives = nres_test_files();
- if archives.is_empty() {
- eprintln!("skipping build_render_mesh_for_real_models: no NRes files in testdata");
- return;
- }
-
- let mut models_checked = 0usize;
- let mut meshes_non_empty = 0usize;
- let mut bounds_non_empty = 0usize;
-
- for archive_path in archives {
- let archive = Archive::open_path(&archive_path)
- .unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display()));
- for entry in archive.entries() {
- if !entry.meta.name.to_ascii_lowercase().ends_with(".msh") {
- continue;
- }
- models_checked += 1;
- let payload = archive.read(entry.id).unwrap_or_else(|err| {
- panic!(
- "failed to read model '{}' from {}: {err}",
- entry.meta.name,
- archive_path.display()
- )
- });
- let model = parse_model_payload(payload.as_slice()).unwrap_or_else(|err| {
- panic!(
- "failed to parse model '{}' from {}: {err}",
- entry.meta.name,
- archive_path.display()
- )
- });
- let mesh = build_render_mesh(&model, 0, 0);
- if !mesh.indices.is_empty() {
- meshes_non_empty += 1;
- }
- if compute_bounds_for_mesh(&mesh.vertices).is_some() {
- bounds_non_empty += 1;
- }
- for &index in &mesh.indices {
- assert!(
- usize::from(index) < mesh.vertices.len(),
- "index out of bounds for '{}' in {}",
- entry.meta.name,
- archive_path.display()
- );
- }
- for vertex in &mesh.vertices {
- assert!(
- vertex.uv0[0].is_finite() && vertex.uv0[1].is_finite(),
- "UV must be finite for '{}' in {}",
- entry.meta.name,
- archive_path.display()
- );
- }
- }
- }
-
- assert!(models_checked > 0, "no MSH models found");
- assert!(
- meshes_non_empty > 0,
- "all generated render meshes are empty"
- );
- assert_eq!(
- meshes_non_empty, bounds_non_empty,
- "bounds must be available for every non-empty mesh"
- );
-}
-
-#[test]
-fn compute_bounds_handles_empty_and_non_empty() {
- assert!(compute_bounds(&[]).is_none());
- let bounds = compute_bounds(&[[1.0, 2.0, 3.0], [-2.0, 5.0, 0.5], [0.0, -1.0, 9.0]])
- .expect("bounds expected");
- assert_eq!(bounds.0, [-2.0, -1.0, 0.5]);
- assert_eq!(bounds.1, [1.0, 5.0, 9.0]);
-}
-
-#[test]
-fn compute_bounds_for_mesh_handles_empty_and_non_empty() {
- assert!(compute_bounds_for_mesh(&[]).is_none());
- let bounds = compute_bounds_for_mesh(&[
- RenderVertex {
- position: [1.0, 2.0, 3.0],
- uv0: [0.0, 0.0],
- },
- RenderVertex {
- position: [-2.0, 5.0, 0.5],
- uv0: [0.2, 0.3],
- },
- RenderVertex {
- position: [0.0, -1.0, 9.0],
- uv0: [1.0, 1.0],
- },
- ])
- .expect("bounds expected");
- assert_eq!(bounds.0, [-2.0, -1.0, 0.5]);
- assert_eq!(bounds.1, [1.0, 5.0, 9.0]);
-}
-
-fn nodes_with_slot_refs(slot_ids: &[Option<u16>]) -> Vec<u8> {
- let mut out = vec![0u8; slot_ids.len().saturating_mul(38)];
- for (node_index, slot_id) in slot_ids.iter().copied().enumerate() {
- let node_off = node_index * 38;
- for i in 0..15 {
- let off = node_off + 8 + i * 2;
- out[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes());
- }
- if let Some(slot_id) = slot_id {
- out[node_off + 8..node_off + 10].copy_from_slice(&slot_id.to_le_bytes());
- }
- }
- out
-}
-
-fn slot(batch_start: u16, batch_count: u16) -> msh_core::Slot {
- msh_core::Slot {
- tri_start: 0,
- tri_count: 0,
- batch_start,
- batch_count,
- aabb_min: [0.0; 3],
- aabb_max: [0.0; 3],
- sphere_center: [0.0; 3],
- sphere_radius: 0.0,
- opaque: [0; 5],
- }
-}
-
-fn batch(index_start: u32, index_count: u16, base_vertex: u32) -> msh_core::Batch {
- msh_core::Batch {
- batch_flags: 0,
- material_index: 0,
- opaque4: 0,
- opaque6: 0,
- index_count,
- index_start,
- opaque14: 0,
- base_vertex,
- }
-}
-
-#[test]
-fn build_render_mesh_handles_empty_slot_model() {
- let model = msh_core::Model {
- node_stride: 38,
- node_count: 1,
- nodes_raw: nodes_with_slot_refs(&[None]),
- slots: Vec::new(),
- positions: vec![[0.0, 0.0, 0.0]],
- normals: None,
- uv0: None,
- indices: Vec::new(),
- batches: Vec::new(),
- node_names: None,
- };
-
- let mesh = build_render_mesh(&model, 0, 0);
- assert!(mesh.vertices.is_empty());
- assert!(mesh.indices.is_empty());
- assert_eq!(mesh.batch_count, 0);
- assert_eq!(mesh.triangle_count(), 0);
-}
-
-#[test]
-fn build_render_mesh_supports_multi_node_and_uv_scaling() {
- let model = msh_core::Model {
- node_stride: 38,
- node_count: 2,
- nodes_raw: nodes_with_slot_refs(&[Some(0), Some(1)]),
- slots: vec![slot(0, 1), slot(1, 1)],
- positions: vec![
- [0.0, 0.0, 0.0],
- [1.0, 0.0, 0.0],
- [0.0, 1.0, 0.0],
- [2.0, 0.0, 0.0],
- [3.0, 0.0, 0.0],
- [2.0, 1.0, 0.0],
- ],
- normals: None,
- uv0: Some(vec![
- [1024, -1024],
- [512, 256],
- [0, 0],
- [1024, 1024],
- [2048, 1024],
- [1024, 0],
- ]),
- indices: vec![0, 1, 2, 0, 1, 2],
- batches: vec![batch(0, 3, 0), batch(3, 3, 3)],
- node_names: None,
- };
-
- let mesh = build_render_mesh(&model, 0, 0);
- assert_eq!(mesh.batch_count, 2);
- assert_eq!(mesh.vertices.len(), 6);
- assert_eq!(mesh.indices, vec![0, 1, 2, 3, 4, 5]);
- assert_eq!(mesh.triangle_count(), 2);
- assert_eq!(mesh.vertices[0].uv0, [1.0, -1.0]);
- assert_eq!(mesh.vertices[1].uv0, [0.5, 0.25]);
- assert_eq!(mesh.vertices[2].uv0, [0.0, 0.0]);
- assert_eq!(mesh.vertices[3].uv0, [1.0, 1.0]);
-}
-
-#[test]
-fn build_render_mesh_deduplicates_shared_vertices() {
- let model = msh_core::Model {
- node_stride: 38,
- node_count: 1,
- nodes_raw: nodes_with_slot_refs(&[Some(0)]),
- slots: vec![slot(0, 1)],
- positions: vec![
- [0.0, 0.0, 0.0],
- [1.0, 0.0, 0.0],
- [0.0, 1.0, 0.0],
- [1.0, 1.0, 0.0],
- ],
- normals: None,
- uv0: None,
- indices: vec![0, 1, 2, 2, 1, 3],
- batches: vec![batch(0, 6, 0)],
- node_names: None,
- };
-
- let mesh = build_render_mesh(&model, 0, 0);
- assert_eq!(mesh.vertices.len(), 4);
- assert_eq!(mesh.indices, vec![0, 1, 2, 2, 1, 3]);
- assert_eq!(mesh.triangle_count(), 2);
-}
diff --git a/crates/render-demo/Cargo.toml b/crates/render-demo/Cargo.toml
deleted file mode 100644
index a2161bb..0000000
--- a/crates/render-demo/Cargo.toml
+++ /dev/null
@@ -1,31 +0,0 @@
-[package]
-name = "render-demo"
-version = "0.1.0"
-edition = "2021"
-
-[features]
-default = []
-demo = ["dep:sdl2", "dep:glow", "dep:image"]
-
-[dependencies]
-encoding_rs = "0.8"
-msh-core = { path = "../msh-core" }
-nres = { path = "../nres" }
-render-core = { path = "../render-core" }
-texm = { path = "../texm" }
-glow = { version = "0.17", optional = true }
-image = { version = "0.25", optional = true, default-features = false, features = ["png"] }
-
-[dev-dependencies]
-common = { path = "../common" }
-
-[target.'cfg(target_os = "macos")'.dependencies]
-sdl2 = { version = "0.38", optional = true, default-features = false, features = ["use-pkgconfig"] }
-
-[target.'cfg(not(target_os = "macos"))'.dependencies]
-sdl2 = { version = "0.38", optional = true, default-features = false, features = ["bundled", "static-link"] }
-
-[[bin]]
-name = "parkan-render-demo"
-path = "src/main.rs"
-required-features = ["demo"]
diff --git a/crates/render-demo/README.md b/crates/render-demo/README.md
deleted file mode 100644
index e9d5950..0000000
--- a/crates/render-demo/README.md
+++ /dev/null
@@ -1,84 +0,0 @@
-# render-demo
-
-Тестовый рендерер Parkan-моделей на Rust (`SDL2 + OpenGL`: GLES2 с fallback на Core 3.3).
-
-## Назначение
-
-- Проверить, что `nres + msh-core + render-core` дают рабочий draw-path на реальных ассетах.
-- Проверить текстурный path `WEAR -> MAT0 -> Texm` на реальных ассетах.
-- Служить минимальным reference-приложением.
-
-## Запуск
-
-```bash
-cargo run -p render-demo --features demo -- \
- --archive "testdata/Parkan - Iron Strategy/animals.rlb" \
- --model "A_L_01.msh" \
- --lod 0 \
- --group 0
-```
-
-### macOS prerequisites
-
-Для macOS `render-demo` ожидает системный SDL2 через `pkg-config`:
-
-```bash
-brew install sdl2 pkg-config
-```
-
-После этого запускайте той же командой `cargo run ... --features demo`.
-
-Параметры:
-
-- `--archive` (обязательный): NRes-архив с `.msh` entry.
-- `--model` (опционально): имя модели; если не задано, берётся первая `.msh`.
-- `--lod` (опционально, default `0`).
-- `--group` (опционально, default `0`).
-- `--width`, `--height` (опционально, default `1280x720`).
-- `--angle` (опционально): фиксированный угол поворота вокруг Y (в радианах).
-- `--spin-rate` (опционально, default `0.35`): скорость вращения в интерактивном режиме.
-- В интерактивном режиме FPS выводится в заголовок окна и в stdout (обновление примерно каждые 0.5 сек).
-- `--texture <name>`: явное имя `Texm` (override авто-резолва).
-- `--texture-archive <path>`: путь к архиву текстур (по умолчанию `textures.lib` рядом с `--archive`).
-- `--material-archive <path>`: путь к `material.lib` (по умолчанию соседний `material.lib`).
-- `--wear <name.wea>`: имя wear-entry внутри модельного архива (по умолчанию `<model_stem>.wea`).
-- `--no-texture`: отключить текстуры и рендерить однотонным цветом.
-
-## Авто-резолв текстуры
-
-Если не передан `--texture`, демо пытается взять текстуру из игровых данных:
-
-1. `model.msh -> model.wea` (первый wear-материал),
-2. `material.lib` (`MAT0`) по имени материала с fallback `DEFAULT`,
-3. первая непустая `textureName` фаза материала,
-4. загрузка `Texm` из `textures.lib` (или `lightmap.lib` как fallback).
-
-## Детерминированный снимок кадра
-
-Для parity-проверок используется headless-сценарий с фиксированными параметрами:
-
-```bash
-cargo run -p render-demo --features demo -- \
- --archive "testdata/Parkan - Iron Strategy/animals.rlb" \
- --model "A_L_01.msh" \
- --lod 0 \
- --group 0 \
- --width 1280 \
- --height 720 \
- --angle 0.0 \
- --capture "target/render-parity/current/animals_a_l_01.png"
-```
-
-Явный выбор текстуры:
-
-```bash
-cargo run -p render-demo --features demo -- \
- --archive "testdata/Parkan - Iron Strategy/animals.rlb" \
- --model "A_L_01.msh" \
- --texture "PG09.0"
-```
-
-## Ограничения
-
-- Используется только базовая texture-фаза (без полной material/fx анимации).
-- Вывод через `glDrawElements(GL_TRIANGLES)` с index-buffer (позиции+UV).
diff --git a/crates/render-demo/build.rs b/crates/render-demo/build.rs
deleted file mode 100644
index 126d1d7..0000000
--- a/crates/render-demo/build.rs
+++ /dev/null
@@ -1,4 +0,0 @@
-fn main() {
- #[cfg(windows)]
- println!("cargo:rustc-link-lib=advapi32");
-}
diff --git a/crates/render-demo/src/lib.rs b/crates/render-demo/src/lib.rs
deleted file mode 100644
index 9555151..0000000
--- a/crates/render-demo/src/lib.rs
+++ /dev/null
@@ -1,591 +0,0 @@
-use encoding_rs::WINDOWS_1251;
-use msh_core::{parse_model_payload, Model};
-use nres::{Archive, EntryRef};
-use std::fmt;
-use std::path::{Path, PathBuf};
-use texm::{decode_mip_rgba8, parse_texm};
-
-const WEAR_KIND: u32 = 0x5241_4557;
-const MAT0_KIND: u32 = 0x3054_414D;
-
-#[derive(Debug)]
-pub enum Error {
- Nres(nres::error::Error),
- Msh(msh_core::error::Error),
- Texm(texm::error::Error),
- Io(std::io::Error),
- NoMshEntries,
- ModelNotFound(String),
- NoTexmEntries,
- TextureNotFound(String),
- MaterialNotFound(String),
- WearNotFound(String),
- InvalidWear(String),
- InvalidMaterial(String),
-}
-
-impl fmt::Display for Error {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::Nres(err) => write!(f, "{err}"),
- Self::Msh(err) => write!(f, "{err}"),
- Self::Texm(err) => write!(f, "{err}"),
- Self::Io(err) => write!(f, "{err}"),
- Self::NoMshEntries => write!(f, "archive does not contain .msh entries"),
- Self::ModelNotFound(name) => write!(f, "model not found: {name}"),
- Self::NoTexmEntries => write!(f, "archive does not contain Texm entries"),
- Self::TextureNotFound(name) => write!(f, "texture not found: {name}"),
- Self::MaterialNotFound(name) => write!(f, "material not found: {name}"),
- Self::WearNotFound(name) => write!(f, "wear entry not found: {name}"),
- Self::InvalidWear(reason) => write!(f, "invalid WEAR payload: {reason}"),
- Self::InvalidMaterial(reason) => write!(f, "invalid MAT0 payload: {reason}"),
- }
- }
-}
-
-impl std::error::Error for Error {
- fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
- match self {
- Self::Nres(err) => Some(err),
- Self::Msh(err) => Some(err),
- Self::Texm(err) => Some(err),
- Self::Io(err) => Some(err),
- _ => None,
- }
- }
-}
-
-impl From<nres::error::Error> for Error {
- fn from(value: nres::error::Error) -> Self {
- Self::Nres(value)
- }
-}
-
-impl From<msh_core::error::Error> for Error {
- fn from(value: msh_core::error::Error) -> Self {
- Self::Msh(value)
- }
-}
-
-impl From<texm::error::Error> for Error {
- fn from(value: texm::error::Error) -> Self {
- Self::Texm(value)
- }
-}
-
-impl From<std::io::Error> for Error {
- fn from(value: std::io::Error) -> Self {
- Self::Io(value)
- }
-}
-
-pub type Result<T> = core::result::Result<T, Error>;
-
-#[derive(Clone, Debug)]
-pub struct LoadedModel {
- pub name: String,
- pub model: Model,
-}
-
-#[derive(Clone, Debug)]
-pub struct LoadedTexture {
- pub name: String,
- pub width: u32,
- pub height: u32,
- pub rgba8: Vec<u8>,
-}
-
-pub fn load_model_with_name_from_archive(
- path: &Path,
- model_name: Option<&str>,
-) -> Result<LoadedModel> {
- let archive = Archive::open_path(path)?;
- let mut msh_entries = Vec::new();
- for entry in archive.entries() {
- if entry.meta.name.to_ascii_lowercase().ends_with(".msh") {
- msh_entries.push((entry.id, entry.meta.name.clone()));
- }
- }
- if msh_entries.is_empty() {
- return Err(Error::NoMshEntries);
- }
-
- let target_id = if let Some(name) = model_name {
- msh_entries
- .iter()
- .find(|(_, n)| n.eq_ignore_ascii_case(name))
- .map(|(id, _)| *id)
- .ok_or_else(|| Error::ModelNotFound(name.to_string()))?
- } else {
- msh_entries[0].0
- };
-
- let target_name = archive
- .get(target_id)
- .map(|entry| entry.meta.name.clone())
- .unwrap_or_else(|| String::from("<unknown>"));
- let payload = archive.read(target_id)?;
- Ok(LoadedModel {
- name: target_name,
- model: parse_model_payload(payload.as_slice())?,
- })
-}
-
-pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result<Model> {
- Ok(load_model_with_name_from_archive(path, model_name)?.model)
-}
-
-pub fn load_texture_from_archive(path: &Path, texture_name: Option<&str>) -> Result<LoadedTexture> {
- let archive = Archive::open_path(path)?;
- if let Some(name) = texture_name {
- return load_texture_from_archive_by_name(&archive, name);
- }
-
- let mut texm_entries = archive
- .entries()
- .filter(|entry| entry.meta.kind == texm::TEXM_MAGIC)
- .collect::<Vec<_>>();
- if texm_entries.is_empty() {
- return Err(Error::NoTexmEntries);
- }
- texm_entries.sort_by(|a, b| {
- a.meta
- .name
- .to_ascii_lowercase()
- .cmp(&b.meta.name.to_ascii_lowercase())
- });
- let first = texm_entries[0];
- decode_texture_entry(&archive, first)
-}
-
-pub fn resolve_texture_for_model(
- model_archive_path: &Path,
- model_entry_name: &str,
- texture_name_override: Option<&str>,
- textures_archive_override: Option<&Path>,
- material_archive_override: Option<&Path>,
- wear_entry_override: Option<&str>,
-) -> Result<Option<LoadedTexture>> {
- if let Some(name) = texture_name_override {
- return load_texture_by_name_from_candidate_archives(
- name,
- candidate_texture_archives(model_archive_path, textures_archive_override),
- )
- .map(Some);
- }
-
- let wear_entry_name = if let Some(name) = wear_entry_override {
- name.to_string()
- } else {
- derive_wear_entry_name(model_entry_name).ok_or_else(|| {
- Error::WearNotFound(format!(
- "cannot derive WEAR name from model '{model_entry_name}'"
- ))
- })?
- };
-
- let model_archive = Archive::open_path(model_archive_path)?;
- let wear_materials = parse_wear_material_names(
- read_entry_by_name_kind(&model_archive, &wear_entry_name, WEAR_KIND)?
- .0
- .as_slice(),
- )?;
- let Some(primary_material) = wear_materials.first() else {
- return Ok(None);
- };
-
- let material_path = if let Some(path) = material_archive_override {
- path.to_path_buf()
- } else {
- sibling_archive_path(model_archive_path, "material.lib")
- .ok_or_else(|| Error::MaterialNotFound(String::from("material.lib")))?
- };
- let material_archive = Archive::open_path(&material_path)?;
- let material_entry = find_material_entry_with_fallback(&material_archive, primary_material)?;
- let material_payload = material_archive.read(material_entry.id)?.into_owned();
- let texture_name =
- parse_primary_texture_name_from_mat0(&material_payload, material_entry.meta.attr2)?;
- let Some(texture_name) = texture_name else {
- return Ok(None);
- };
-
- let texture = load_texture_by_name_from_candidate_archives(
- &texture_name,
- candidate_texture_archives(model_archive_path, textures_archive_override),
- )?;
- Ok(Some(texture))
-}
-
-fn load_texture_by_name_from_candidate_archives(
- texture_name: &str,
- archives: Vec<PathBuf>,
-) -> Result<LoadedTexture> {
- let mut last_not_found = None;
- for archive_path in archives {
- if !archive_path.is_file() {
- continue;
- }
- let archive = Archive::open_path(&archive_path)?;
- match load_texture_from_archive_by_name(&archive, texture_name) {
- Ok(texture) => return Ok(texture),
- Err(Error::TextureNotFound(name)) => {
- last_not_found = Some(name);
- }
- Err(other) => return Err(other),
- }
- }
-
- Err(Error::TextureNotFound(
- last_not_found.unwrap_or_else(|| texture_name.to_string()),
- ))
-}
-
-fn candidate_texture_archives(
- model_archive_path: &Path,
- textures_archive_override: Option<&Path>,
-) -> Vec<PathBuf> {
- if let Some(path) = textures_archive_override {
- return vec![path.to_path_buf()];
- }
-
- let mut out = Vec::new();
- if let Some(path) = sibling_archive_path(model_archive_path, "textures.lib") {
- out.push(path);
- }
- if let Some(path) = sibling_archive_path(model_archive_path, "lightmap.lib") {
- out.push(path);
- }
- out
-}
-
-fn sibling_archive_path(model_archive_path: &Path, name: &str) -> Option<PathBuf> {
- let parent = model_archive_path.parent()?;
- Some(parent.join(name))
-}
-
-fn derive_wear_entry_name(model_entry_name: &str) -> Option<String> {
- let stem = model_entry_name.rsplit_once('.').map(|(left, _)| left)?;
- Some(format!("{stem}.wea"))
-}
-
-fn read_entry_by_name_kind(
- archive: &Archive,
- name: &str,
- expected_kind: u32,
-) -> Result<(Vec<u8>, String)> {
- let Some(id) = archive.find(name) else {
- return Err(Error::WearNotFound(name.to_string()));
- };
- let Some(entry) = archive.get(id) else {
- return Err(Error::WearNotFound(name.to_string()));
- };
- if entry.meta.kind != expected_kind {
- return Err(Error::WearNotFound(name.to_string()));
- }
- let payload = archive.read(id)?.into_owned();
- Ok((payload, entry.meta.name.clone()))
-}
-
-fn find_material_entry_with_fallback<'a>(
- archive: &'a Archive,
- requested_name: &str,
-) -> Result<EntryRef<'a>> {
- if let Some(id) = archive.find(requested_name) {
- if let Some(entry) = archive.get(id) {
- if entry.meta.kind == MAT0_KIND {
- return Ok(entry);
- }
- }
- }
-
- if let Some(id) = archive.find("DEFAULT") {
- if let Some(entry) = archive.get(id) {
- if entry.meta.kind == MAT0_KIND {
- return Ok(entry);
- }
- }
- }
-
- let Some(entry) = archive.entries().find(|entry| entry.meta.kind == MAT0_KIND) else {
- return Err(Error::MaterialNotFound(requested_name.to_string()));
- };
- Ok(entry)
-}
-
-fn parse_wear_material_names(payload: &[u8]) -> Result<Vec<String>> {
- let text = decode_cp1251(payload).replace('\r', "");
- let mut lines = text.lines();
- let Some(first) = lines.next() else {
- return Err(Error::InvalidWear(String::from("WEAR payload is empty")));
- };
- let count = first
- .trim()
- .parse::<usize>()
- .map_err(|_| Error::InvalidWear(format!("invalid wearCount line: '{first}'")))?;
- if count == 0 {
- return Err(Error::InvalidWear(String::from("wearCount must be > 0")));
- }
-
- let mut materials = Vec::with_capacity(count);
- for idx in 0..count {
- let Some(line) = lines.next() else {
- return Err(Error::InvalidWear(format!(
- "missing material line {idx} of {count}"
- )));
- };
- let mut parts = line.split_whitespace();
- let _legacy = parts
- .next()
- .ok_or_else(|| Error::InvalidWear(format!("invalid material line {idx}: '{line}'")))?;
- let name = parts
- .next()
- .ok_or_else(|| Error::InvalidWear(format!("invalid material line {idx}: '{line}'")))?;
- materials.push(name.to_string());
- }
-
- Ok(materials)
-}
-
-fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Option<String>> {
- if payload.len() < 4 {
- return Err(Error::InvalidMaterial(String::from(
- "MAT0 payload is too small for header",
- )));
- }
- let phase_count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
- if phase_count == 0 {
- return Ok(None);
- }
-
- let mut offset = 4usize;
- if attr2 >= 2 {
- offset = offset
- .checked_add(2)
- .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?;
- }
- if attr2 >= 3 {
- offset = offset
- .checked_add(4)
- .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?;
- }
- if attr2 >= 4 {
- offset = offset
- .checked_add(4)
- .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?;
- }
-
- for phase in 0..phase_count {
- let phase_off = offset
- .checked_add(phase.checked_mul(34).ok_or_else(|| {
- Error::InvalidMaterial(String::from("MAT0 phase offset overflow"))
- })?)
- .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 phase offset overflow")))?;
- let phase_end = phase_off
- .checked_add(34)
- .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 phase offset overflow")))?;
- let Some(rec) = payload.get(phase_off..phase_end) else {
- return Err(Error::InvalidMaterial(format!(
- "MAT0 phase {phase} is out of bounds"
- )));
- };
- let name_raw = &rec[18..34];
- let name_end = name_raw
- .iter()
- .position(|&b| b == 0)
- .unwrap_or(name_raw.len());
- let name = decode_cp1251(&name_raw[..name_end]).trim().to_string();
- if !name.is_empty() {
- return Ok(Some(name));
- }
- }
-
- Ok(None)
-}
-
-fn decode_cp1251(bytes: &[u8]) -> String {
- let (decoded, _, _) = WINDOWS_1251.decode(bytes);
- decoded.into_owned()
-}
-
-fn load_texture_from_archive_by_name(archive: &Archive, name: &str) -> Result<LoadedTexture> {
- let Some(id) = archive.find(name) else {
- return Err(Error::TextureNotFound(name.to_string()));
- };
- let Some(entry) = archive.get(id) else {
- return Err(Error::TextureNotFound(name.to_string()));
- };
- if entry.meta.kind != texm::TEXM_MAGIC {
- return Err(Error::TextureNotFound(name.to_string()));
- }
- decode_texture_entry(archive, entry)
-}
-
-fn decode_texture_entry(archive: &Archive, entry: EntryRef<'_>) -> Result<LoadedTexture> {
- let payload = archive.read(entry.id)?.into_owned();
- let parsed = parse_texm(&payload)?;
- let decoded = decode_mip_rgba8(&parsed, &payload, 0)?;
- Ok(LoadedTexture {
- name: entry.meta.name.clone(),
- width: decoded.width,
- height: decoded.height,
- rgba8: decoded.rgba8,
- })
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use common::collect_files_recursive;
- use std::fs;
- use std::path::{Path, PathBuf};
-
- fn archive_with_msh() -> Option<PathBuf> {
- let root = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("..")
- .join("..")
- .join("testdata");
- let mut files = Vec::new();
- collect_files_recursive(&root, &mut files);
- files.sort();
- for path in files {
- let Ok(bytes) = fs::read(&path) else {
- continue;
- };
- if bytes.get(0..4) != Some(b"NRes") {
- continue;
- }
- let Ok(archive) = Archive::open_path(&path) else {
- continue;
- };
- if archive
- .entries()
- .any(|entry| entry.meta.name.to_ascii_lowercase().ends_with(".msh"))
- {
- return Some(path);
- }
- }
- None
- }
-
- fn game_root() -> Option<PathBuf> {
- let path = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("..")
- .join("..")
- .join("testdata")
- .join("Parkan - Iron Strategy");
- if path.is_dir() {
- Some(path)
- } else {
- None
- }
- }
-
- #[test]
- fn load_model_from_real_archive() {
- let Some(path) = archive_with_msh() else {
- eprintln!("skipping load_model_from_real_archive: no .msh archives in testdata");
- return;
- };
- let model = load_model_from_archive(&path, None)
- .unwrap_or_else(|err| panic!("failed to load model from {}: {err:?}", path.display()));
- assert!(model.node_count > 0);
- assert!(!model.positions.is_empty());
- assert!(!model.indices.is_empty());
- }
-
- #[test]
- fn resolve_texture_for_real_model_via_wear_and_material() {
- let Some(root) = game_root() else {
- eprintln!(
- "skipping resolve_texture_for_real_model_via_wear_and_material: no game root"
- );
- return;
- };
- let archive = root.join("animals.rlb");
- if !archive.is_file() {
- eprintln!("skipping resolve_texture_for_real_model_via_wear_and_material: missing animals.rlb");
- return;
- }
-
- let loaded = load_model_with_name_from_archive(&archive, Some("A_L_01.msh"))
- .unwrap_or_else(|err| {
- panic!(
- "failed to load model A_L_01.msh from {}: {err:?}",
- archive.display()
- )
- });
- let texture = resolve_texture_for_model(&archive, &loaded.name, None, None, None, None)
- .unwrap_or_else(|err| panic!("failed to resolve texture for {}: {err:?}", loaded.name))
- .expect("texture must be resolved for A_L_01.msh");
- assert!(texture.width > 0 && texture.height > 0);
- assert_eq!(
- texture.rgba8.len(),
- usize::try_from(texture.width)
- .ok()
- .and_then(|w| usize::try_from(texture.height).ok().map(|h| w * h * 4))
- .unwrap_or(0)
- );
- }
-
- #[test]
- fn load_first_texture_from_real_archive() {
- let Some(root) = game_root() else {
- eprintln!("skipping load_first_texture_from_real_archive: no game root");
- return;
- };
- let archive = root.join("textures.lib");
- if !archive.is_file() {
- eprintln!("skipping load_first_texture_from_real_archive: missing textures.lib");
- return;
- }
- let texture = load_texture_from_archive(&archive, None).unwrap_or_else(|err| {
- panic!(
- "failed to load first texture from {}: {err:?}",
- archive.display()
- )
- });
- assert!(texture.width > 0 && texture.height > 0);
- assert!(!texture.rgba8.is_empty());
- }
-
- #[test]
- fn parse_wear_material_names_parses_counted_lines() {
- let payload = b"2\r\n0 MAT_A\r\n1 MAT_B\r\n";
- let materials =
- parse_wear_material_names(payload).expect("failed to parse valid WEAR payload");
- assert_eq!(materials, vec!["MAT_A".to_string(), "MAT_B".to_string()]);
- }
-
- #[test]
- fn parse_wear_material_names_rejects_invalid_payload() {
- let payload = b"2\n0 ONLY_ONE\n";
- assert!(matches!(
- parse_wear_material_names(payload),
- Err(Error::InvalidWear(_))
- ));
- }
-
- #[test]
- fn parse_primary_texture_name_from_mat0_respects_attr2_layout() {
- let mut payload = vec![0u8; 4 + 10 + 34];
- payload[0..2].copy_from_slice(&1u16.to_le_bytes()); // phase_count
- // attr2=4 adds 10 bytes before phase table
- let name = b"TEX_MAIN";
- payload[4 + 10 + 18..4 + 10 + 18 + name.len()].copy_from_slice(name);
-
- let parsed = parse_primary_texture_name_from_mat0(&payload, 4)
- .expect("failed to parse MAT0 payload with attr2=4");
- assert_eq!(parsed, Some("TEX_MAIN".to_string()));
- }
-
- #[test]
- fn parse_primary_texture_name_from_mat0_decodes_cp1251_bytes() {
- let mut payload = vec![0u8; 4 + 34];
- payload[0..2].copy_from_slice(&1u16.to_le_bytes()); // phase_count
- payload[4 + 18] = 0xC0; // 'А' in CP1251
-
- let parsed =
- parse_primary_texture_name_from_mat0(&payload, 0).expect("failed to parse MAT0");
- assert_eq!(parsed, Some("А".to_string()));
- }
-}
diff --git a/crates/render-demo/src/main.rs b/crates/render-demo/src/main.rs
deleted file mode 100644
index 61f6bfa..0000000
--- a/crates/render-demo/src/main.rs
+++ /dev/null
@@ -1,997 +0,0 @@
-use glow::HasContext as _;
-use render_core::{build_render_mesh, compute_bounds_for_mesh};
-use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture};
-use std::io::Write as _;
-use std::path::{Path, PathBuf};
-use std::time::{Duration, Instant};
-
-struct Args {
- archive: PathBuf,
- model: Option<String>,
- lod: usize,
- group: usize,
- width: u32,
- height: u32,
- fov_deg: f32,
- capture: Option<PathBuf>,
- angle: Option<f32>,
- spin_rate: f32,
- texture: Option<String>,
- texture_archive: Option<PathBuf>,
- material_archive: Option<PathBuf>,
- wear: Option<String>,
- no_texture: bool,
-}
-
-struct GpuTexture {
- handle: glow::NativeTexture,
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq)]
-enum GlBackend {
- Gles2,
- Core33,
-}
-
-fn parse_args() -> Result<Args, String> {
- let mut archive = None;
- let mut model = None;
- let mut lod = 0usize;
- let mut group = 0usize;
- let mut width = 1280u32;
- let mut height = 720u32;
- let mut fov_deg = 60.0f32;
- let mut capture = None;
- let mut angle = None;
- let mut spin_rate = 0.35f32;
- let mut texture = None;
- let mut texture_archive = None;
- let mut material_archive = None;
- let mut wear = None;
- let mut no_texture = false;
-
- let mut it = std::env::args().skip(1);
- while let Some(arg) = it.next() {
- match arg.as_str() {
- "--archive" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --archive"))?;
- archive = Some(PathBuf::from(value));
- }
- "--model" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --model"))?;
- model = Some(value);
- }
- "--lod" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --lod"))?;
- lod = value
- .parse::<usize>()
- .map_err(|_| String::from("invalid --lod value"))?;
- }
- "--group" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --group"))?;
- group = value
- .parse::<usize>()
- .map_err(|_| String::from("invalid --group value"))?;
- }
- "--width" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --width"))?;
- width = value
- .parse::<u32>()
- .map_err(|_| String::from("invalid --width value"))?;
- if width == 0 {
- return Err(String::from("--width must be > 0"));
- }
- }
- "--height" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --height"))?;
- height = value
- .parse::<u32>()
- .map_err(|_| String::from("invalid --height value"))?;
- if height == 0 {
- return Err(String::from("--height must be > 0"));
- }
- }
- "--fov" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --fov"))?;
- fov_deg = value
- .parse::<f32>()
- .map_err(|_| String::from("invalid --fov value"))?;
- if !(1.0..=179.0).contains(&fov_deg) {
- return Err(String::from("--fov must be in range [1, 179]"));
- }
- }
- "--capture" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --capture"))?;
- capture = Some(PathBuf::from(value));
- }
- "--angle" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --angle"))?;
- angle = Some(
- value
- .parse::<f32>()
- .map_err(|_| String::from("invalid --angle value"))?,
- );
- }
- "--spin-rate" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --spin-rate"))?;
- spin_rate = value
- .parse::<f32>()
- .map_err(|_| String::from("invalid --spin-rate value"))?;
- }
- "--texture" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --texture"))?;
- texture = Some(value);
- }
- "--texture-archive" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --texture-archive"))?;
- texture_archive = Some(PathBuf::from(value));
- }
- "--material-archive" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --material-archive"))?;
- material_archive = Some(PathBuf::from(value));
- }
- "--wear" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --wear"))?;
- wear = Some(value);
- }
- "--no-texture" => {
- no_texture = true;
- }
- "--help" | "-h" => {
- print_help();
- std::process::exit(0);
- }
- other => {
- return Err(format!("unknown argument: {other}"));
- }
- }
- }
-
- let archive = archive.ok_or_else(|| String::from("missing required --archive"))?;
- Ok(Args {
- archive,
- model,
- lod,
- group,
- width,
- height,
- fov_deg,
- capture,
- angle,
- spin_rate,
- texture,
- texture_archive,
- material_archive,
- wear,
- no_texture,
- })
-}
-
-fn print_help() {
- eprintln!(
- "parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N] [--width W] [--height H] [--fov DEG]"
- );
- eprintln!(" [--capture <out.png>] [--angle RAD] [--spin-rate RAD_PER_SEC]");
- eprintln!(" [--texture <name>] [--texture-archive <path>] [--material-archive <path>] [--wear <name.wea>] [--no-texture]");
-}
-
-fn main() {
- let args = match parse_args() {
- Ok(v) => v,
- Err(err) => {
- eprintln!("{err}");
- print_help();
- std::process::exit(2);
- }
- };
-
- if let Err(err) = run(args) {
- eprintln!("{err}");
- std::process::exit(1);
- }
-}
-
-fn run(args: Args) -> Result<(), String> {
- let loaded_model = load_model_with_name_from_archive(&args.archive, args.model.as_deref())
- .map_err(|err| {
- format!(
- "failed to load model from archive {}: {err}",
- args.archive.display()
- )
- })?;
- let mesh = build_render_mesh(&loaded_model.model, args.lod, args.group);
- if mesh.indices.is_empty() {
- return Err(format!(
- "model has no renderable triangles for lod={} group={}",
- args.lod, args.group
- ));
- }
- if mesh.index_overflow {
- eprintln!(
- "warning: mesh exceeds u16 index space and may be partially rendered on GLES2 targets"
- );
- }
- let Some((bounds_min, bounds_max)) = compute_bounds_for_mesh(&mesh.vertices) else {
- return Err(String::from("failed to compute mesh bounds"));
- };
-
- let resolved_texture = resolve_texture(&args, &loaded_model.name)?;
- if let Some(tex) = resolved_texture.as_ref() {
- println!(
- "resolved texture '{}' ({}x{})",
- tex.name, tex.width, tex.height
- );
- } else {
- println!("texture path disabled or unresolved; rendering with fallback color");
- }
-
- let center = [
- 0.5 * (bounds_min[0] + bounds_max[0]),
- 0.5 * (bounds_min[1] + bounds_max[1]),
- 0.5 * (bounds_min[2] + bounds_max[2]),
- ];
- let extent = [
- bounds_max[0] - bounds_min[0],
- bounds_max[1] - bounds_min[1],
- bounds_max[2] - bounds_min[2],
- ];
- let radius =
- (extent[0] * extent[0] + extent[1] * extent[1] + extent[2] * extent[2]).sqrt() * 0.5;
- let camera_distance = (radius * 2.5).max(2.0);
-
- let sdl = sdl2::init().map_err(|err| format!("failed to init SDL2: {err}"))?;
- let video = sdl
- .video()
- .map_err(|err| format!("failed to init SDL2 video: {err}"))?;
-
- let (mut window, _gl_ctx, gl_backend) = create_window_and_context(&video, &args)?;
- let _ = if args.capture.is_some() {
- video.gl_set_swap_interval(0)
- } else {
- video.gl_set_swap_interval(1)
- };
-
- let mut vertex_data = Vec::with_capacity(mesh.vertices.len() * 5);
- for vertex in &mesh.vertices {
- vertex_data.push(vertex.position[0]);
- vertex_data.push(vertex.position[1]);
- vertex_data.push(vertex.position[2]);
- vertex_data.push(vertex.uv0[0]);
- vertex_data.push(vertex.uv0[1]);
- }
- let vertex_bytes = f32_slice_to_ne_bytes(&vertex_data);
- let index_bytes = u16_slice_to_ne_bytes(&mesh.indices);
-
- let gl = unsafe {
- glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _)
- };
-
- let program = unsafe { create_program(&gl, gl_backend)? };
- let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") };
- let u_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") };
- let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") };
- let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }
- .ok_or_else(|| String::from("shader attribute a_pos is missing"))?;
- let a_uv = unsafe { gl.get_attrib_location(program, "a_uv") }
- .ok_or_else(|| String::from("shader attribute a_uv is missing"))?;
-
- let vbo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? };
- let ebo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? };
- unsafe {
- gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
- gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW);
- gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
- gl.buffer_data_u8_slice(glow::ELEMENT_ARRAY_BUFFER, &index_bytes, glow::STATIC_DRAW);
- gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
- gl.bind_buffer(glow::ARRAY_BUFFER, None);
- }
- let vao = unsafe { create_vertex_layout_if_needed(&gl, gl_backend, vbo, ebo, a_pos, a_uv)? };
-
- let gpu_texture = if let Some(texture) = resolved_texture.as_ref() {
- Some(unsafe { create_texture(&gl, texture)? })
- } else {
- None
- };
-
- let result = if let Some(capture_path) = args.capture.as_ref() {
- run_capture(
- &gl,
- program,
- u_mvp.as_ref(),
- u_use_tex.as_ref(),
- u_tex.as_ref(),
- a_pos,
- a_uv,
- vbo,
- ebo,
- vao,
- gpu_texture.as_ref(),
- mesh.indices.len(),
- &args,
- center,
- camera_distance,
- capture_path,
- )
- } else {
- run_interactive(
- &sdl,
- &mut window,
- &gl,
- program,
- u_mvp.as_ref(),
- u_use_tex.as_ref(),
- u_tex.as_ref(),
- a_pos,
- a_uv,
- vbo,
- ebo,
- vao,
- gpu_texture.as_ref(),
- mesh.indices.len(),
- &args,
- center,
- camera_distance,
- )
- };
-
- unsafe {
- if let Some(texture) = gpu_texture {
- gl.delete_texture(texture.handle);
- }
- if let Some(vao) = vao {
- gl.delete_vertex_array(vao);
- }
- gl.delete_buffer(ebo);
- gl.delete_buffer(vbo);
- gl.delete_program(program);
- }
-
- result
-}
-
-fn create_window_and_context(
- video: &sdl2::VideoSubsystem,
- args: &Args,
-) -> Result<(sdl2::video::Window, sdl2::video::GLContext, GlBackend), String> {
- let candidates = [
- (GlBackend::Gles2, sdl2::video::GLProfile::GLES, 2, 0),
- (GlBackend::Core33, sdl2::video::GLProfile::Core, 3, 3),
- ];
- let mut errors = Vec::new();
-
- for (backend, profile, major, minor) in candidates {
- {
- let gl_attr = video.gl_attr();
- gl_attr.set_context_profile(profile);
- gl_attr.set_context_version(major, minor);
- gl_attr.set_depth_size(24);
- gl_attr.set_double_buffer(true);
- }
-
- let mut window_builder = video.window(
- "Parkan Render Demo (SDL2 + OpenGL)",
- args.width,
- args.height,
- );
- window_builder.opengl();
- if args.capture.is_some() {
- window_builder.hidden();
- } else {
- window_builder.resizable();
- }
-
- let window = match window_builder.build() {
- Ok(window) => window,
- Err(err) => {
- errors.push(format!(
- "{profile:?} {major}.{minor}: window build failed ({err})"
- ));
- continue;
- }
- };
-
- let gl_ctx = match window.gl_create_context() {
- Ok(ctx) => ctx,
- Err(err) => {
- errors.push(format!(
- "{profile:?} {major}.{minor}: context create failed ({err})"
- ));
- continue;
- }
- };
-
- if let Err(err) = window.gl_make_current(&gl_ctx) {
- errors.push(format!(
- "{profile:?} {major}.{minor}: make current failed ({err})"
- ));
- continue;
- }
-
- return Ok((window, gl_ctx, backend));
- }
-
- Err(format!(
- "failed to create OpenGL context. Attempts: {}",
- errors.join(" | ")
- ))
-}
-
-unsafe fn create_vertex_layout_if_needed(
- gl: &glow::Context,
- backend: GlBackend,
- vbo: glow::NativeBuffer,
- ebo: glow::NativeBuffer,
- a_pos: u32,
- a_uv: u32,
-) -> Result<Option<glow::NativeVertexArray>, String> {
- if backend != GlBackend::Core33 {
- return Ok(None);
- }
-
- let vao = gl.create_vertex_array().map_err(|e| e.to_string())?;
- gl.bind_vertex_array(Some(vao));
- gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
- gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
- gl.enable_vertex_attrib_array(a_pos);
- gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
- gl.enable_vertex_attrib_array(a_uv);
- gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
- gl.bind_vertex_array(None);
- Ok(Some(vao))
-}
-
-fn resolve_texture(args: &Args, model_name: &str) -> Result<Option<LoadedTexture>, String> {
- if args.no_texture {
- return Ok(None);
- }
-
- match resolve_texture_for_model(
- &args.archive,
- model_name,
- args.texture.as_deref(),
- args.texture_archive.as_deref(),
- args.material_archive.as_deref(),
- args.wear.as_deref(),
- ) {
- Ok(texture) => Ok(texture),
- Err(err) => {
- if args.texture.is_some()
- || args.texture_archive.is_some()
- || args.material_archive.is_some()
- || args.wear.is_some()
- {
- Err(format!("failed to resolve texture: {err}"))
- } else {
- eprintln!("warning: auto texture resolve failed ({err}), fallback to solid color");
- Ok(None)
- }
- }
- }
-}
-
-unsafe fn create_texture(
- gl: &glow::Context,
- texture: &LoadedTexture,
-) -> Result<GpuTexture, String> {
- let handle = gl.create_texture().map_err(|e| e.to_string())?;
- gl.bind_texture(glow::TEXTURE_2D, Some(handle));
- gl.tex_parameter_i32(
- glow::TEXTURE_2D,
- glow::TEXTURE_MIN_FILTER,
- glow::LINEAR as i32,
- );
- gl.tex_parameter_i32(
- glow::TEXTURE_2D,
- glow::TEXTURE_MAG_FILTER,
- glow::LINEAR as i32,
- );
- gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
- gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
- gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 1);
- gl.tex_image_2d(
- glow::TEXTURE_2D,
- 0,
- glow::RGBA as i32,
- texture.width.min(i32::MAX as u32) as i32,
- texture.height.min(i32::MAX as u32) as i32,
- 0,
- glow::RGBA,
- glow::UNSIGNED_BYTE,
- glow::PixelUnpackData::Slice(Some(texture.rgba8.as_slice())),
- );
- gl.bind_texture(glow::TEXTURE_2D, None);
- Ok(GpuTexture { handle })
-}
-
-#[allow(clippy::too_many_arguments)]
-fn run_capture(
- gl: &glow::Context,
- program: glow::NativeProgram,
- u_mvp: Option<&glow::NativeUniformLocation>,
- u_use_tex: Option<&glow::NativeUniformLocation>,
- u_tex: Option<&glow::NativeUniformLocation>,
- a_pos: u32,
- a_uv: u32,
- vbo: glow::NativeBuffer,
- ebo: glow::NativeBuffer,
- vao: Option<glow::NativeVertexArray>,
- texture: Option<&GpuTexture>,
- index_count: usize,
- args: &Args,
- center: [f32; 3],
- camera_distance: f32,
- capture_path: &Path,
-) -> Result<(), String> {
- let angle = args.angle.unwrap_or(0.0);
- let mvp = compute_mvp(
- args.width,
- args.height,
- args.fov_deg,
- center,
- camera_distance,
- angle,
- );
- unsafe {
- draw_frame(
- gl,
- program,
- u_mvp,
- u_use_tex,
- u_tex,
- a_pos,
- a_uv,
- vbo,
- ebo,
- vao,
- texture,
- index_count,
- args.width,
- args.height,
- &mvp,
- );
- }
- let mut rgba = unsafe { read_pixels_rgba(gl, args.width, args.height)? };
- flip_image_y_rgba(&mut rgba, args.width as usize, args.height as usize);
- save_png(capture_path, args.width, args.height, rgba)?;
- println!("captured frame to {}", capture_path.display());
- Ok(())
-}
-
-#[allow(clippy::too_many_arguments)]
-fn run_interactive(
- sdl: &sdl2::Sdl,
- window: &mut sdl2::video::Window,
- gl: &glow::Context,
- program: glow::NativeProgram,
- u_mvp: Option<&glow::NativeUniformLocation>,
- u_use_tex: Option<&glow::NativeUniformLocation>,
- u_tex: Option<&glow::NativeUniformLocation>,
- a_pos: u32,
- a_uv: u32,
- vbo: glow::NativeBuffer,
- ebo: glow::NativeBuffer,
- vao: Option<glow::NativeVertexArray>,
- texture: Option<&GpuTexture>,
- index_count: usize,
- args: &Args,
- center: [f32; 3],
- camera_distance: f32,
-) -> Result<(), String> {
- let mut events = sdl
- .event_pump()
- .map_err(|err| format!("failed to get SDL event pump: {err}"))?;
- let start = Instant::now();
- let mut fps_window_start = Instant::now();
- let mut fps_frames: u32 = 0;
- let mut fps_printed = false;
- let base_title = "Parkan Render Demo (SDL2 + OpenGL)";
-
- 'main_loop: loop {
- for event in events.poll_iter() {
- match event {
- sdl2::event::Event::Quit { .. } => break 'main_loop,
- sdl2::event::Event::KeyDown {
- keycode: Some(sdl2::keyboard::Keycode::Escape),
- ..
- } => break 'main_loop,
- _ => {}
- }
- }
-
- let (w, h) = window.size();
- let angle = args
- .angle
- .unwrap_or(start.elapsed().as_secs_f32() * args.spin_rate);
- let mvp = compute_mvp(w, h, args.fov_deg, center, camera_distance, angle);
-
- unsafe {
- draw_frame(
- gl,
- program,
- u_mvp,
- u_use_tex,
- u_tex,
- a_pos,
- a_uv,
- vbo,
- ebo,
- vao,
- texture,
- index_count,
- w,
- h,
- &mvp,
- );
- }
- window.gl_swap_window();
-
- fps_frames = fps_frames.saturating_add(1);
- let elapsed = fps_window_start.elapsed();
- if elapsed >= Duration::from_millis(500) {
- let fps = fps_frames as f32 / elapsed.as_secs_f32().max(0.000_1);
- let frame_time_ms = 1000.0 / fps.max(0.000_1);
- let _ = window.set_title(&format!(
- "{base_title} | FPS: {fps:.1} ({frame_time_ms:.2} ms)"
- ));
- print!("\rFPS: {fps:.1} ({frame_time_ms:.2} ms)");
- let _ = std::io::stdout().flush();
- fps_printed = true;
- fps_frames = 0;
- fps_window_start = Instant::now();
- }
- }
-
- if fps_printed {
- println!();
- }
-
- Ok(())
-}
-
-fn compute_mvp(
- width: u32,
- height: u32,
- fov_deg: f32,
- center: [f32; 3],
- camera_distance: f32,
- angle_rad: f32,
-) -> [f32; 16] {
- let aspect = (width as f32 / (height.max(1) as f32)).max(0.01);
- let proj = mat4_perspective(fov_deg.to_radians(), aspect, 0.01, camera_distance * 10.0);
- let view = mat4_translation(0.0, 0.0, -camera_distance);
- let center_shift = mat4_translation(-center[0], -center[1], -center[2]);
- let rot = mat4_rotation_y(angle_rad);
- let model_m = mat4_mul(&rot, &center_shift);
- let vp = mat4_mul(&view, &model_m);
- mat4_mul(&proj, &vp)
-}
-
-#[allow(clippy::too_many_arguments)]
-unsafe fn draw_frame(
- gl: &glow::Context,
- program: glow::NativeProgram,
- u_mvp: Option<&glow::NativeUniformLocation>,
- u_use_tex: Option<&glow::NativeUniformLocation>,
- u_tex: Option<&glow::NativeUniformLocation>,
- a_pos: u32,
- a_uv: u32,
- vbo: glow::NativeBuffer,
- ebo: glow::NativeBuffer,
- vao: Option<glow::NativeVertexArray>,
- texture: Option<&GpuTexture>,
- index_count: usize,
- width: u32,
- height: u32,
- mvp: &[f32; 16],
-) {
- gl.viewport(
- 0,
- 0,
- width.min(i32::MAX as u32) as i32,
- height.min(i32::MAX as u32) as i32,
- );
- gl.enable(glow::DEPTH_TEST);
- gl.clear_color(0.06, 0.08, 0.12, 1.0);
- gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
-
- gl.use_program(Some(program));
- gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp);
-
- let texture_enabled = texture.is_some();
- gl.uniform_1_f32(u_use_tex, if texture_enabled { 1.0 } else { 0.0 });
- if let Some(tex) = texture {
- gl.active_texture(glow::TEXTURE0);
- gl.bind_texture(glow::TEXTURE_2D, Some(tex.handle));
- gl.uniform_1_i32(u_tex, 0);
- } else {
- gl.bind_texture(glow::TEXTURE_2D, None);
- }
-
- if let Some(vao) = vao {
- gl.bind_vertex_array(Some(vao));
- gl.draw_elements(
- glow::TRIANGLES,
- index_count.min(i32::MAX as usize) as i32,
- glow::UNSIGNED_SHORT,
- 0,
- );
- gl.bind_vertex_array(None);
- } else {
- gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
- gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
- gl.enable_vertex_attrib_array(a_pos);
- gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
- gl.enable_vertex_attrib_array(a_uv);
- gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
- gl.draw_elements(
- glow::TRIANGLES,
- index_count.min(i32::MAX as usize) as i32,
- glow::UNSIGNED_SHORT,
- 0,
- );
- gl.disable_vertex_attrib_array(a_uv);
- gl.disable_vertex_attrib_array(a_pos);
- gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
- gl.bind_buffer(glow::ARRAY_BUFFER, None);
- }
- gl.bind_texture(glow::TEXTURE_2D, None);
- gl.use_program(None);
-}
-
-unsafe fn read_pixels_rgba(gl: &glow::Context, width: u32, height: u32) -> Result<Vec<u8>, String> {
- let pixel_count = usize::try_from(width)
- .ok()
- .and_then(|w| usize::try_from(height).ok().map(|h| w.saturating_mul(h)))
- .ok_or_else(|| String::from("frame dimensions are too large"))?;
- let mut pixels = vec![0u8; pixel_count.saturating_mul(4)];
- gl.read_pixels(
- 0,
- 0,
- width.min(i32::MAX as u32) as i32,
- height.min(i32::MAX as u32) as i32,
- glow::RGBA,
- glow::UNSIGNED_BYTE,
- glow::PixelPackData::Slice(Some(pixels.as_mut_slice())),
- );
- Ok(pixels)
-}
-
-fn flip_image_y_rgba(rgba: &mut [u8], width: usize, height: usize) {
- let stride = width.saturating_mul(4);
- if stride == 0 {
- return;
- }
- for y in 0..(height / 2) {
- let top = y * stride;
- let bottom = (height - 1 - y) * stride;
- for i in 0..stride {
- rgba.swap(top + i, bottom + i);
- }
- }
-}
-
-fn save_png(path: &Path, width: u32, height: u32, rgba: Vec<u8>) -> Result<(), String> {
- if let Some(parent) = path.parent() {
- if !parent.as_os_str().is_empty() {
- std::fs::create_dir_all(parent).map_err(|err| {
- format!(
- "failed to create output directory {}: {err}",
- parent.display()
- )
- })?;
- }
- }
- let image = image::RgbaImage::from_raw(width, height, rgba)
- .ok_or_else(|| String::from("failed to build image from framebuffer bytes"))?;
- image
- .save(path)
- .map_err(|err| format!("failed to save PNG {}: {err}", path.display()))
-}
-
-unsafe fn create_program(
- gl: &glow::Context,
- backend: GlBackend,
-) -> Result<glow::NativeProgram, String> {
- let (vs_src, fs_src) = match backend {
- GlBackend::Gles2 => (
- r#"
-attribute vec3 a_pos;
-attribute vec2 a_uv;
-uniform mat4 u_mvp;
-varying vec2 v_uv;
-void main() {
- v_uv = a_uv;
- gl_Position = u_mvp * vec4(a_pos, 1.0);
-}
-"#,
- r#"
-precision mediump float;
-uniform sampler2D u_tex;
-uniform float u_use_tex;
-varying vec2 v_uv;
-void main() {
- vec4 base = vec4(0.85, 0.90, 1.00, 1.0);
- vec4 texColor = texture2D(u_tex, v_uv);
- gl_FragColor = mix(base, texColor, u_use_tex);
-}
-"#,
- ),
- GlBackend::Core33 => (
- r#"#version 330 core
-in vec3 a_pos;
-in vec2 a_uv;
-uniform mat4 u_mvp;
-out vec2 v_uv;
-void main() {
- v_uv = a_uv;
- gl_Position = u_mvp * vec4(a_pos, 1.0);
-}
-"#,
- r#"#version 330 core
-uniform sampler2D u_tex;
-uniform float u_use_tex;
-in vec2 v_uv;
-out vec4 fragColor;
-void main() {
- vec4 base = vec4(0.85, 0.90, 1.00, 1.0);
- vec4 texColor = texture(u_tex, v_uv);
- fragColor = mix(base, texColor, u_use_tex);
-}
-"#,
- ),
- };
-
- let program = gl.create_program().map_err(|e| e.to_string())?;
- let vs = gl
- .create_shader(glow::VERTEX_SHADER)
- .map_err(|e| e.to_string())?;
- let fs = gl
- .create_shader(glow::FRAGMENT_SHADER)
- .map_err(|e| e.to_string())?;
-
- gl.shader_source(vs, vs_src);
- gl.compile_shader(vs);
- if !gl.get_shader_compile_status(vs) {
- let log = gl.get_shader_info_log(vs);
- gl.delete_shader(vs);
- gl.delete_shader(fs);
- gl.delete_program(program);
- return Err(format!("vertex shader compile failed: {log}"));
- }
-
- gl.shader_source(fs, fs_src);
- gl.compile_shader(fs);
- if !gl.get_shader_compile_status(fs) {
- let log = gl.get_shader_info_log(fs);
- gl.delete_shader(vs);
- gl.delete_shader(fs);
- gl.delete_program(program);
- return Err(format!("fragment shader compile failed: {log}"));
- }
-
- gl.attach_shader(program, vs);
- gl.attach_shader(program, fs);
- gl.link_program(program);
-
- gl.detach_shader(program, vs);
- gl.detach_shader(program, fs);
- gl.delete_shader(vs);
- gl.delete_shader(fs);
-
- if !gl.get_program_link_status(program) {
- let log = gl.get_program_info_log(program);
- gl.delete_program(program);
- return Err(format!("program link failed: {log}"));
- }
-
- Ok(program)
-}
-
-fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec<u8> {
- let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<f32>()));
- for &value in slice {
- out.extend_from_slice(&value.to_ne_bytes());
- }
- out
-}
-
-fn u16_slice_to_ne_bytes(slice: &[u16]) -> Vec<u8> {
- let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<u16>()));
- for &value in slice {
- out.extend_from_slice(&value.to_ne_bytes());
- }
- out
-}
-
-fn mat4_identity() -> [f32; 16] {
- [
- 1.0, 0.0, 0.0, 0.0, //
- 0.0, 1.0, 0.0, 0.0, //
- 0.0, 0.0, 1.0, 0.0, //
- 0.0, 0.0, 0.0, 1.0, //
- ]
-}
-
-fn mat4_translation(x: f32, y: f32, z: f32) -> [f32; 16] {
- let mut m = mat4_identity();
- m[12] = x;
- m[13] = y;
- m[14] = z;
- m
-}
-
-fn mat4_rotation_y(rad: f32) -> [f32; 16] {
- let c = rad.cos();
- let s = rad.sin();
- [
- c, 0.0, -s, 0.0, //
- 0.0, 1.0, 0.0, 0.0, //
- s, 0.0, c, 0.0, //
- 0.0, 0.0, 0.0, 1.0, //
- ]
-}
-
-fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] {
- let f = 1.0 / (0.5 * fovy).tan();
- let nf = 1.0 / (near - far);
- [
- f / aspect,
- 0.0,
- 0.0,
- 0.0,
- 0.0,
- f,
- 0.0,
- 0.0,
- 0.0,
- 0.0,
- (far + near) * nf,
- -1.0,
- 0.0,
- 0.0,
- (2.0 * far * near) * nf,
- 0.0,
- ]
-}
-
-fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
- let mut out = [0.0f32; 16];
- for c in 0..4 {
- for r in 0..4 {
- let mut acc = 0.0f32;
- for k in 0..4 {
- acc += a[k * 4 + r] * b[c * 4 + k];
- }
- out[c * 4 + r] = acc;
- }
- }
- out
-}
diff --git a/crates/render-mission-demo/Cargo.toml b/crates/render-mission-demo/Cargo.toml
deleted file mode 100644
index d658212..0000000
--- a/crates/render-mission-demo/Cargo.toml
+++ /dev/null
@@ -1,33 +0,0 @@
-[package]
-name = "render-mission-demo"
-version = "0.1.0"
-edition = "2021"
-
-[features]
-default = []
-demo = ["dep:sdl2", "dep:glow"]
-
-[dependencies]
-encoding_rs = "0.8"
-glow = { version = "0.16", optional = true }
-nres = { path = "../nres" }
-render-core = { path = "../render-core" }
-render-demo = { path = "../render-demo" }
-tma = { path = "../tma" }
-terrain-core = { path = "../terrain-core" }
-texm = { path = "../texm" }
-unitdat = { path = "../unitdat" }
-
-[dev-dependencies]
-common = { path = "../common" }
-
-[target.'cfg(target_os = "macos")'.dependencies]
-sdl2 = { version = "0.37", optional = true, default-features = false, features = ["use-pkgconfig"] }
-
-[target.'cfg(not(target_os = "macos"))'.dependencies]
-sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] }
-
-[[bin]]
-name = "parkan-render-mission-demo"
-path = "src/main.rs"
-required-features = ["demo"]
diff --git a/crates/render-mission-demo/src/lib.rs b/crates/render-mission-demo/src/lib.rs
deleted file mode 100644
index 9732f39..0000000
--- a/crates/render-mission-demo/src/lib.rs
+++ /dev/null
@@ -1,881 +0,0 @@
-use encoding_rs::WINDOWS_1251;
-use nres::Archive;
-use render_core::{build_render_mesh, RenderMesh};
-use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture};
-use std::collections::HashMap;
-use std::fmt;
-use std::fs;
-use std::path::{Path, PathBuf};
-use terrain_core::TerrainMesh;
-use tma::MissionFile;
-
-const MAT0_KIND: u32 = 0x3054_414D;
-const MESH_KIND: u32 = 0x4853_454D;
-const OBJECT_REF_STRIDE: usize = 64;
-const OBJECT_REF_ARCHIVE_BYTES: usize = 32;
-
-pub type Result<T> = core::result::Result<T, Error>;
-
-#[derive(Debug)]
-pub enum Error {
- Io(std::io::Error),
- Mission(tma::Error),
- Terrain(terrain_core::Error),
- UnitDat(unitdat::Error),
- RenderDemo(render_demo::Error),
- Nres(nres::error::Error),
- Texm(texm::error::Error),
- InvalidMapPath(String),
- GameRootNotFound(PathBuf),
-}
-
-impl fmt::Display for Error {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::Io(err) => write!(f, "{err}"),
- Self::Mission(err) => write!(f, "{err}"),
- Self::Terrain(err) => write!(f, "{err}"),
- Self::UnitDat(err) => write!(f, "{err}"),
- Self::RenderDemo(err) => write!(f, "{err}"),
- Self::Nres(err) => write!(f, "{err}"),
- Self::Texm(err) => write!(f, "{err}"),
- Self::InvalidMapPath(path) => write!(f, "invalid mission map path: {path}"),
- Self::GameRootNotFound(path) => {
- write!(
- f,
- "failed to detect game root from mission path {}",
- path.display()
- )
- }
- }
- }
-}
-
-impl std::error::Error for Error {
- fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
- match self {
- Self::Io(err) => Some(err),
- Self::Mission(err) => Some(err),
- Self::Terrain(err) => Some(err),
- Self::UnitDat(err) => Some(err),
- Self::RenderDemo(err) => Some(err),
- Self::Nres(err) => Some(err),
- Self::Texm(err) => Some(err),
- Self::InvalidMapPath(_) | Self::GameRootNotFound(_) => None,
- }
- }
-}
-
-impl From<std::io::Error> for Error {
- fn from(value: std::io::Error) -> Self {
- Self::Io(value)
- }
-}
-
-impl From<tma::Error> for Error {
- fn from(value: tma::Error) -> Self {
- Self::Mission(value)
- }
-}
-
-impl From<terrain_core::Error> for Error {
- fn from(value: terrain_core::Error) -> Self {
- Self::Terrain(value)
- }
-}
-
-impl From<unitdat::Error> for Error {
- fn from(value: unitdat::Error) -> Self {
- Self::UnitDat(value)
- }
-}
-
-impl From<render_demo::Error> for Error {
- fn from(value: render_demo::Error) -> Self {
- Self::RenderDemo(value)
- }
-}
-
-impl From<nres::error::Error> for Error {
- fn from(value: nres::error::Error) -> Self {
- Self::Nres(value)
- }
-}
-
-impl From<texm::error::Error> for Error {
- fn from(value: texm::error::Error) -> Self {
- Self::Texm(value)
- }
-}
-
-#[derive(Copy, Clone, Debug)]
-pub struct LoadOptions {
- pub load_model_textures: bool,
- pub load_terrain_texture: bool,
-}
-
-impl Default for LoadOptions {
- fn default() -> Self {
- Self {
- load_model_textures: true,
- load_terrain_texture: true,
- }
- }
-}
-
-#[derive(Clone, Debug)]
-pub struct MissionScene {
- pub game_root: PathBuf,
- pub mission_path: PathBuf,
- pub mission: MissionFile,
- pub map_folder_rel: PathBuf,
- pub land_msh_path: PathBuf,
- pub terrain: TerrainMesh,
- pub terrain_texture: Option<LoadedTexture>,
- pub models: Vec<SceneModel>,
- pub skipped_objects: usize,
-}
-
-#[derive(Clone, Debug)]
-pub struct SceneModel {
- pub archive_path: PathBuf,
- pub model_name: String,
- pub mesh: RenderMesh,
- pub texture: Option<LoadedTexture>,
- pub instances: Vec<ModelInstance>,
-}
-
-#[derive(Copy, Clone, Debug)]
-pub struct ModelInstance {
- pub position: [f32; 3],
- pub yaw_rad: f32,
- pub scale: [f32; 3],
-}
-
-#[derive(Clone, Debug)]
-struct ObjectPrototype {
- archive_path: PathBuf,
- model_name: String,
-}
-
-#[derive(Clone, Debug)]
-struct ObjectRef {
- archive_name: String,
- resource_name: String,
-}
-
-#[derive(Clone, Debug, Hash, PartialEq, Eq)]
-struct ModelKey {
- archive_path: PathBuf,
- model_name: String,
-}
-
-pub fn detect_game_root_from_mission_path(mission_path: &Path) -> Option<PathBuf> {
- let mut cursor = mission_path.parent();
- while let Some(dir) = cursor {
- if dir.join("DATA").is_dir() && dir.join("objects.rlb").is_file() {
- return Some(dir.to_path_buf());
- }
- cursor = dir.parent();
- }
- None
-}
-
-pub fn load_scene(
- game_root: impl AsRef<Path>,
- mission_path: impl AsRef<Path>,
-) -> Result<MissionScene> {
- load_scene_with_options(game_root, mission_path, LoadOptions::default())
-}
-
-pub fn load_scene_with_options(
- game_root: impl AsRef<Path>,
- mission_path: impl AsRef<Path>,
- options: LoadOptions,
-) -> Result<MissionScene> {
- let game_root = game_root.as_ref().to_path_buf();
- let mission_path = mission_path.as_ref().to_path_buf();
-
- let mission = tma::parse_path(&mission_path)?;
- let map_folder_rel = map_folder_from_footer(&mission.footer.map_path)?;
- let land_msh_path = game_root.join(&map_folder_rel).join("Land.msh");
- let terrain = terrain_core::load_land_mesh(&land_msh_path)?;
- let terrain_texture = if options.load_terrain_texture {
- resolve_terrain_texture(&game_root, &map_folder_rel)?
- } else {
- None
- };
-
- let mut grouped_instances: HashMap<ModelKey, Vec<ModelInstance>> = HashMap::new();
- let mut prototype_cache: HashMap<String, Option<ObjectPrototype>> = HashMap::new();
- let mut skipped = 0usize;
-
- for object in &mission.objects {
- let cache_key = object.resource_name.to_ascii_lowercase();
- let proto = if let Some(cached) = prototype_cache.get(&cache_key) {
- cached.clone()
- } else {
- let resolved = resolve_object_prototype(&game_root, object)?;
- prototype_cache.insert(cache_key, resolved.clone());
- resolved
- };
-
- let Some(proto) = proto else {
- skipped += 1;
- continue;
- };
-
- let instance = ModelInstance {
- position: object.position,
- yaw_rad: object.orientation[2],
- scale: normalize_scale(object.scale),
- };
-
- grouped_instances
- .entry(ModelKey {
- archive_path: proto.archive_path,
- model_name: proto.model_name,
- })
- .or_default()
- .push(instance);
- }
-
- let mut models = Vec::new();
- for (key, instances) in grouped_instances {
- let loaded =
- match load_model_with_name_from_archive(&key.archive_path, Some(&key.model_name)) {
- Ok(v) => v,
- Err(_) => {
- skipped += instances.len();
- continue;
- }
- };
-
- let mesh = build_render_mesh(&loaded.model, 0, 0);
- if mesh.indices.is_empty() {
- skipped += instances.len();
- continue;
- }
-
- let texture = if options.load_model_textures {
- resolve_texture_for_model(&key.archive_path, &loaded.name, None, None, None, None)
- .ok()
- .flatten()
- } else {
- None
- };
-
- models.push(SceneModel {
- archive_path: key.archive_path,
- model_name: loaded.name,
- mesh,
- texture,
- instances,
- });
- }
-
- models.sort_by(|a, b| a.model_name.cmp(&b.model_name));
-
- Ok(MissionScene {
- game_root,
- mission_path,
- mission,
- map_folder_rel,
- land_msh_path,
- terrain,
- terrain_texture,
- models,
- skipped_objects: skipped,
- })
-}
-
-pub fn compute_scene_bounds(scene: &MissionScene) -> Option<([f32; 3], [f32; 3])> {
- let mut min_v = [f32::INFINITY; 3];
- let mut max_v = [f32::NEG_INFINITY; 3];
- let mut any = false;
-
- for pos in &scene.terrain.positions {
- merge_bounds(&mut min_v, &mut max_v, *pos);
- any = true;
- }
-
- for model in &scene.models {
- for instance in &model.instances {
- merge_bounds(&mut min_v, &mut max_v, instance.position);
- any = true;
- }
- }
-
- any.then_some((min_v, max_v))
-}
-
-fn merge_bounds(min_v: &mut [f32; 3], max_v: &mut [f32; 3], p: [f32; 3]) {
- for i in 0..3 {
- if p[i] < min_v[i] {
- min_v[i] = p[i];
- }
- if p[i] > max_v[i] {
- max_v[i] = p[i];
- }
- }
-}
-
-fn normalize_scale(scale: [f32; 3]) -> [f32; 3] {
- let mut out = scale;
- for item in &mut out {
- if !item.is_finite() || item.abs() < 0.000_1 {
- *item = 1.0;
- }
- }
- out
-}
-
-fn map_folder_from_footer(map_path: &str) -> Result<PathBuf> {
- let mut parts = split_relative_path(map_path);
- if parts.len() < 2 {
- return Err(Error::InvalidMapPath(map_path.to_string()));
- }
- parts.pop(); // remove 'land'
-
- let mut out = PathBuf::new();
- for part in parts {
- out.push(part);
- }
- Ok(out)
-}
-
-fn resolve_object_prototype(
- game_root: &Path,
- object: &tma::MissionObject,
-) -> Result<Option<ObjectPrototype>> {
- if object.resource_name.to_ascii_lowercase().ends_with(".dat") {
- let dat_path = game_root.join(pathbuf_from_rel(&object.resource_name));
- if !dat_path.is_file() {
- return Ok(None);
- }
-
- let parsed = unitdat::parse_path(&dat_path)?;
- let archive_path = game_root.join(pathbuf_from_rel(&parsed.archive_name));
- if !archive_path.is_file() {
- return Ok(None);
- }
- return resolve_archive_model(game_root, &archive_path, &parsed.model_key);
- }
-
- let archive_path = game_root.join("objects.rlb");
- if !archive_path.is_file() {
- return Ok(None);
- }
- resolve_archive_model(game_root, &archive_path, &object.resource_name)
-}
-
-fn resolve_archive_model(
- game_root: &Path,
- archive_path: &Path,
- model_key: &str,
-) -> Result<Option<ObjectPrototype>> {
- if !archive_path.is_file() {
- return Ok(None);
- }
-
- if is_objects_registry_archive(archive_path) {
- if let Some(proto) = resolve_objects_registry_model(game_root, archive_path, model_key)? {
- return Ok(Some(proto));
- }
- }
-
- let model_name = ensure_msh_suffix(model_key);
- if !archive_has_mesh_entry(archive_path, &model_name)? {
- return Ok(None);
- }
-
- Ok(Some(ObjectPrototype {
- archive_path: archive_path.to_path_buf(),
- model_name: model_name.to_ascii_lowercase(),
- }))
-}
-
-fn is_objects_registry_archive(archive_path: &Path) -> bool {
- archive_path
- .file_name()
- .and_then(|name| name.to_str())
- .is_some_and(|name| name.eq_ignore_ascii_case("objects.rlb"))
-}
-
-fn resolve_objects_registry_model(
- game_root: &Path,
- registry_archive_path: &Path,
- object_key: &str,
-) -> Result<Option<ObjectPrototype>> {
- let archive = Archive::open_path(registry_archive_path)?;
- let Some(entry_id) = find_registry_entry_id(&archive, object_key) else {
- return Ok(None);
- };
-
- let payload = archive.read(entry_id)?.into_owned();
- let refs = parse_object_refs(&payload);
- if refs.is_empty() {
- return Ok(None);
- }
-
- for item in refs
- .iter()
- .filter(|item| has_extension(&item.resource_name, "msh"))
- {
- if let Some(proto) = resolve_object_ref_model(game_root, item, &item.resource_name)? {
- return Ok(Some(proto));
- }
- }
-
- for item in refs
- .iter()
- .filter(|item| has_extension(&item.resource_name, "bas"))
- {
- let Some(stem) = Path::new(&item.resource_name)
- .file_stem()
- .and_then(|stem| stem.to_str())
- else {
- continue;
- };
- if stem.is_empty() {
- continue;
- }
- let candidate = format!("{stem}.msh");
- if let Some(proto) = resolve_object_ref_model(game_root, item, &candidate)? {
- return Ok(Some(proto));
- }
- }
-
- Ok(None)
-}
-
-fn find_registry_entry_id(archive: &Archive, object_key: &str) -> Option<nres::EntryId> {
- mesh_name_candidates(object_key)
- .into_iter()
- .find_map(|candidate| archive.find(&candidate))
-}
-
-fn resolve_object_ref_model(
- game_root: &Path,
- item: &ObjectRef,
- model_name: &str,
-) -> Result<Option<ObjectPrototype>> {
- let archive_path = game_root.join(pathbuf_from_rel(&item.archive_name));
- if !archive_path.is_file() {
- return Ok(None);
- }
- if !archive_has_mesh_entry(&archive_path, model_name)? {
- return Ok(None);
- }
-
- Ok(Some(ObjectPrototype {
- archive_path,
- model_name: model_name.to_ascii_lowercase(),
- }))
-}
-
-fn parse_object_refs(payload: &[u8]) -> Vec<ObjectRef> {
- if !payload.len().is_multiple_of(OBJECT_REF_STRIDE) {
- return Vec::new();
- }
-
- let mut refs = Vec::with_capacity(payload.len() / OBJECT_REF_STRIDE);
- for chunk in payload.chunks_exact(OBJECT_REF_STRIDE) {
- let archive_name = decode_cp1251_cstr(&chunk[..OBJECT_REF_ARCHIVE_BYTES]);
- let resource_name = decode_cp1251_cstr(&chunk[OBJECT_REF_ARCHIVE_BYTES..]);
- if archive_name.is_empty() || resource_name.is_empty() {
- continue;
- }
- refs.push(ObjectRef {
- archive_name,
- resource_name,
- });
- }
- refs
-}
-
-fn archive_has_mesh_entry(archive_path: &Path, requested_name: &str) -> Result<bool> {
- let archive = Archive::open_path(archive_path)?;
- Ok(find_mesh_entry_id(&archive, requested_name).is_some())
-}
-
-fn find_mesh_entry_id(archive: &Archive, requested_name: &str) -> Option<nres::EntryId> {
- for candidate in mesh_name_candidates(requested_name) {
- let Some(id) = archive.find(&candidate) else {
- continue;
- };
- let Some(entry) = archive.get(id) else {
- continue;
- };
- if entry.meta.kind == MESH_KIND || has_extension(&entry.meta.name, "msh") {
- return Some(id);
- }
- }
- None
-}
-
-fn mesh_name_candidates(name: &str) -> Vec<String> {
- let mut out = Vec::new();
- let trimmed = name.trim();
- if trimmed.is_empty() {
- return out;
- }
-
- push_unique_string(&mut out, trimmed.to_string());
- if let Some(stem) = trimmed
- .strip_suffix(".msh")
- .or_else(|| trimmed.strip_suffix(".MSH"))
- {
- if !stem.is_empty() {
- push_unique_string(&mut out, stem.to_string());
- }
- } else {
- push_unique_string(&mut out, format!("{trimmed}.msh"));
- }
-
- out
-}
-
-fn push_unique_string(items: &mut Vec<String>, value: String) {
- if !items.iter().any(|item| item.eq_ignore_ascii_case(&value)) {
- items.push(value);
- }
-}
-
-fn ensure_msh_suffix(name: &str) -> String {
- let trimmed = name.trim();
- if trimmed.to_ascii_lowercase().ends_with(".msh") {
- trimmed.to_string()
- } else {
- format!("{trimmed}.msh")
- }
-}
-
-fn has_extension(name: &str, ext: &str) -> bool {
- Path::new(name)
- .extension()
- .and_then(|value| value.to_str())
- .is_some_and(|value| value.eq_ignore_ascii_case(ext))
-}
-
-fn resolve_terrain_texture(
- game_root: &Path,
- map_folder_rel: &Path,
-) -> Result<Option<LoadedTexture>> {
- let material_archive_path = game_root.join("material.lib");
- let texture_archive_path = game_root.join("textures.lib");
- if !material_archive_path.is_file() || !texture_archive_path.is_file() {
- return Ok(None);
- }
-
- for wear_name in ["Land1.wea", "Land2.wea"] {
- let wear_path = game_root.join(map_folder_rel).join(wear_name);
- if !wear_path.is_file() {
- continue;
- }
- let wear_payload = fs::read(&wear_path)?;
- let Some(material_name) = parse_primary_material_from_wear(&wear_payload) else {
- continue;
- };
- let Some(texture_name) =
- resolve_texture_name_from_material_archive(&material_archive_path, &material_name)?
- else {
- continue;
- };
- if let Some(texture) = load_texm_by_name(&texture_archive_path, &texture_name)? {
- return Ok(Some(texture));
- }
- }
-
- Ok(None)
-}
-
-fn parse_primary_material_from_wear(bytes: &[u8]) -> Option<String> {
- let text = decode_cp1251(bytes).replace('\r', "");
- let mut lines = text.lines();
- let count = lines.next()?.trim().parse::<usize>().ok()?;
- if count == 0 {
- return None;
- }
-
- for line in lines.take(count) {
- let mut parts = line.split_whitespace();
- let _legacy = parts.next()?;
- let name = parts.next()?;
- if !name.is_empty() {
- return Some(name.to_string());
- }
- }
- None
-}
-
-fn resolve_texture_name_from_material_archive(
- archive_path: &Path,
- material_name: &str,
-) -> Result<Option<String>> {
- let archive = Archive::open_path(archive_path)?;
-
- let entry = if let Some(id) = archive.find(material_name) {
- archive
- .get(id)
- .filter(|entry| entry.meta.kind == MAT0_KIND)
- .or_else(|| {
- archive
- .find("DEFAULT")
- .and_then(|id| archive.get(id))
- .filter(|entry| entry.meta.kind == MAT0_KIND)
- })
- } else {
- archive
- .find("DEFAULT")
- .and_then(|id| archive.get(id))
- .filter(|entry| entry.meta.kind == MAT0_KIND)
- }
- .or_else(|| archive.entries().find(|entry| entry.meta.kind == MAT0_KIND));
-
- let Some(entry) = entry else {
- return Ok(None);
- };
-
- let payload = archive.read(entry.id)?.into_owned();
- parse_primary_texture_name_from_mat0(&payload, entry.meta.attr2)
-}
-
-fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Option<String>> {
- if payload.len() < 4 {
- return Ok(None);
- }
-
- let phase_count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
- if phase_count == 0 {
- return Ok(None);
- }
-
- let mut offset = 4usize;
- if attr2 >= 2 {
- offset = offset.saturating_add(2);
- }
- if attr2 >= 3 {
- offset = offset.saturating_add(4);
- }
- if attr2 >= 4 {
- offset = offset.saturating_add(4);
- }
-
- for phase in 0..phase_count {
- let phase_off = offset.saturating_add(phase.saturating_mul(34));
- let Some(rec) = payload.get(phase_off..phase_off + 34) else {
- break;
- };
- let name_raw = &rec[18..34];
- let end = name_raw
- .iter()
- .position(|&b| b == 0)
- .unwrap_or(name_raw.len());
- let name = decode_cp1251(&name_raw[..end]).trim().to_string();
- if !name.is_empty() {
- return Ok(Some(name));
- }
- }
-
- Ok(None)
-}
-
-fn load_texm_by_name(archive_path: &Path, texture_name: &str) -> Result<Option<LoadedTexture>> {
- let archive = Archive::open_path(archive_path)?;
- let Some(id) = archive.find(texture_name) else {
- return Ok(None);
- };
- let Some(entry) = archive.get(id) else {
- return Ok(None);
- };
- if entry.meta.kind != texm::TEXM_MAGIC {
- return Ok(None);
- }
-
- let payload = archive.read(id)?.into_owned();
- let parsed = texm::parse_texm(&payload)?;
- let decoded = texm::decode_mip_rgba8(&parsed, &payload, 0)?;
-
- Ok(Some(LoadedTexture {
- name: entry.meta.name.clone(),
- width: decoded.width,
- height: decoded.height,
- rgba8: decoded.rgba8,
- }))
-}
-
-fn split_relative_path(path: &str) -> Vec<&str> {
- path.split(['\\', '/'])
- .filter(|part| !part.is_empty())
- .collect()
-}
-
-fn pathbuf_from_rel(path: &str) -> PathBuf {
- let mut out = PathBuf::new();
- for part in split_relative_path(path) {
- out.push(part);
- }
- out
-}
-
-fn decode_cp1251_cstr(bytes: &[u8]) -> String {
- let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
- let (decoded, _, _) = WINDOWS_1251.decode(&bytes[..end]);
- decoded.trim().to_string()
-}
-
-fn decode_cp1251(bytes: &[u8]) -> String {
- let (decoded, _, _) = WINDOWS_1251.decode(bytes);
- decoded.into_owned()
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::path::Path;
-
- fn game_root() -> Option<PathBuf> {
- let root = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("..")
- .join("..")
- .join("testdata")
- .join("Parkan - Iron Strategy");
- root.is_dir().then_some(root)
- }
-
- #[test]
- fn detects_game_root_from_mission_path() {
- let Some(root) = game_root() else {
- eprintln!("skipping: game root missing");
- return;
- };
-
- let mission = root
- .join("MISSIONS")
- .join("CAMPAIGN")
- .join("CAMPAIGN.00")
- .join("Mission.01")
- .join("data.tma");
- if !mission.is_file() {
- eprintln!("skipping missing mission sample");
- return;
- }
-
- let detected = detect_game_root_from_mission_path(&mission)
- .expect("failed to detect game root from mission path");
- assert_eq!(detected, root);
- }
-
- #[test]
- fn loads_scene_cpu_without_textures() {
- let Some(root) = game_root() else {
- eprintln!("skipping: game root missing");
- return;
- };
-
- let mission = root
- .join("MISSIONS")
- .join("CAMPAIGN")
- .join("CAMPAIGN.00")
- .join("Mission.01")
- .join("data.tma");
- if !mission.is_file() {
- eprintln!("skipping missing mission sample");
- return;
- }
-
- let scene = load_scene_with_options(
- &root,
- &mission,
- LoadOptions {
- load_model_textures: false,
- load_terrain_texture: false,
- },
- )
- .unwrap_or_else(|err| panic!("failed to load scene {}: {err}", mission.display()));
-
- assert!(!scene.terrain.positions.is_empty());
- assert!(!scene.terrain.faces.is_empty());
- assert!(!scene.models.is_empty());
-
- let instance_count = scene
- .models
- .iter()
- .map(|model| model.instances.len())
- .sum::<usize>();
- assert!(instance_count >= 10);
-
- let bounds = compute_scene_bounds(&scene).expect("scene bounds should exist");
- assert!(bounds.0[0] <= bounds.1[0]);
- assert!(bounds.0[1] <= bounds.1[1]);
- assert!(bounds.0[2] <= bounds.1[2]);
- }
-
- #[test]
- fn loads_scene_with_textures() {
- let Some(root) = game_root() else {
- eprintln!("skipping: game root missing");
- return;
- };
-
- let mission = root
- .join("MISSIONS")
- .join("CAMPAIGN")
- .join("CAMPAIGN.00")
- .join("Mission.01")
- .join("data.tma");
- if !mission.is_file() {
- eprintln!("skipping missing mission sample");
- return;
- }
-
- let scene = load_scene_with_options(&root, &mission, LoadOptions::default())
- .unwrap_or_else(|err| panic!("failed to load textured scene {}: {err}", mission.display()));
-
- assert!(!scene.models.is_empty());
- let textured_models = scene.models.iter().filter(|model| model.texture.is_some()).count();
- assert!(textured_models > 0, "no model textures resolved");
- assert!(scene.terrain_texture.is_some(), "terrain texture was not resolved");
- }
-
- #[test]
- fn resolves_objects_registry_models() {
- let Some(root) = game_root() else {
- eprintln!("skipping: game root missing");
- return;
- };
-
- let registry = root.join("objects.rlb");
- if !registry.is_file() {
- eprintln!("skipping missing objects.rlb");
- return;
- }
-
- let cases = [
- ("r_h_01", "bases.rlb", "r_h_01.msh"),
- ("s_tree_04", "static.rlb", "s_tree_0_04.msh"),
- ("fr_m_brige", "fortif.rlb", "fr_m_brige.msh"),
- ];
-
- for (key, archive_name, model_name) in cases {
- let proto = resolve_objects_registry_model(&root, &registry, key)
- .unwrap_or_else(|err| panic!("failed to resolve '{key}' from objects.rlb: {err}"))
- .unwrap_or_else(|| panic!("missing model resolution for '{key}'"));
-
- let got_archive = proto
- .archive_path
- .file_name()
- .and_then(|name| name.to_str())
- .map(|name| name.to_ascii_lowercase())
- .unwrap_or_default();
- assert_eq!(got_archive, archive_name.to_ascii_lowercase());
- assert!(
- proto.model_name.eq_ignore_ascii_case(model_name),
- "unexpected model for key '{key}': got '{}', expected '{}'",
- proto.model_name,
- model_name
- );
- }
- }
-}
diff --git a/crates/render-mission-demo/src/main.rs b/crates/render-mission-demo/src/main.rs
deleted file mode 100644
index 01b6e06..0000000
--- a/crates/render-mission-demo/src/main.rs
+++ /dev/null
@@ -1,924 +0,0 @@
-use glow::HasContext as _;
-use render_mission_demo::{
- compute_scene_bounds, detect_game_root_from_mission_path, load_scene_with_options, LoadOptions,
- MissionScene, ModelInstance,
-};
-use std::io::Write as _;
-use std::path::PathBuf;
-use std::time::{Duration, Instant};
-
-struct Args {
- mission: PathBuf,
- game_root: Option<PathBuf>,
- width: u32,
- height: u32,
- fov_deg: f32,
- no_model_texture: bool,
- no_terrain_texture: bool,
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq)]
-enum GlBackend {
- Gles2,
- Core33,
-}
-
-struct GpuTexture {
- handle: glow::NativeTexture,
-}
-
-struct GpuRenderable {
- vbo: glow::NativeBuffer,
- ebo: glow::NativeBuffer,
- index_count: usize,
- texture: Option<GpuTexture>,
-}
-
-struct ModelRenderable {
- gpu: GpuRenderable,
- instances: Vec<ModelInstance>,
-}
-
-#[derive(Copy, Clone, Debug)]
-struct Camera {
- position: [f32; 3],
- yaw: f32,
- pitch: f32,
- move_speed: f32,
- mouse_sensitivity: f32,
-}
-
-fn parse_args() -> Result<Args, String> {
- let mut mission = None;
- let mut game_root = None;
- let mut width = 1600u32;
- let mut height = 900u32;
- let mut fov_deg = 60.0f32;
- let mut no_model_texture = false;
- let mut no_terrain_texture = false;
-
- let mut it = std::env::args().skip(1);
- while let Some(arg) = it.next() {
- match arg.as_str() {
- "--mission" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --mission"))?;
- mission = Some(PathBuf::from(value));
- }
- "--game-root" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --game-root"))?;
- game_root = Some(PathBuf::from(value));
- }
- "--width" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --width"))?;
- width = value
- .parse::<u32>()
- .map_err(|_| String::from("invalid --width value"))?;
- if width == 0 {
- return Err(String::from("--width must be > 0"));
- }
- }
- "--height" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --height"))?;
- height = value
- .parse::<u32>()
- .map_err(|_| String::from("invalid --height value"))?;
- if height == 0 {
- return Err(String::from("--height must be > 0"));
- }
- }
- "--fov" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --fov"))?;
- fov_deg = value
- .parse::<f32>()
- .map_err(|_| String::from("invalid --fov value"))?;
- if !(1.0..=179.0).contains(&fov_deg) {
- return Err(String::from("--fov must be in range [1, 179]"));
- }
- }
- "--no-model-texture" => {
- no_model_texture = true;
- }
- "--no-terrain-texture" => {
- no_terrain_texture = true;
- }
- "--help" | "-h" => {
- print_help();
- std::process::exit(0);
- }
- other => {
- return Err(format!("unknown argument: {other}"));
- }
- }
- }
-
- let mission = mission.ok_or_else(|| String::from("missing required --mission"))?;
- Ok(Args {
- mission,
- game_root,
- width,
- height,
- fov_deg,
- no_model_texture,
- no_terrain_texture,
- })
-}
-
-fn print_help() {
- eprintln!("parkan-render-mission-demo --mission <path/to/data.tma> [--game-root <path>] [--width W] [--height H] [--fov DEG]");
- eprintln!(" [--no-model-texture] [--no-terrain-texture]");
- eprintln!("controls: arrows/WASD move, PageUp/PageDown vertical move, Right Mouse drag look, Shift speed-up, Esc exit");
-}
-
-fn main() {
- let args = match parse_args() {
- Ok(v) => v,
- Err(err) => {
- eprintln!("{err}");
- print_help();
- std::process::exit(2);
- }
- };
-
- if let Err(err) = run(args) {
- eprintln!("{err}");
- std::process::exit(1);
- }
-}
-
-fn run(args: Args) -> Result<(), String> {
- let game_root = if let Some(path) = args.game_root.clone() {
- path
- } else {
- detect_game_root_from_mission_path(&args.mission).ok_or_else(|| {
- format!(
- "failed to detect game root from mission path {} (use --game-root)",
- args.mission.display()
- )
- })?
- };
-
- let scene = load_scene_with_options(
- &game_root,
- &args.mission,
- LoadOptions {
- load_model_textures: !args.no_model_texture,
- load_terrain_texture: !args.no_terrain_texture,
- },
- )
- .map_err(|err| format!("failed to load mission scene: {err}"))?;
-
- let terrain_mesh = terrain_core::build_render_mesh(&scene.terrain)
- .map_err(|err| format!("failed to build terrain render mesh: {err}"))?;
-
- let instance_count = scene
- .models
- .iter()
- .map(|model| model.instances.len())
- .sum::<usize>();
- println!(
- "mission loaded: map='{}', terrain_vertices={}, terrain_faces={}, models={}, instances={}, skipped={}",
- scene.mission.footer.map_path,
- scene.terrain.positions.len(),
- scene.terrain.faces.len(),
- scene.models.len(),
- instance_count,
- scene.skipped_objects
- );
-
- let sdl = sdl2::init().map_err(|err| format!("failed to init SDL2: {err}"))?;
- let video = sdl
- .video()
- .map_err(|err| format!("failed to init SDL2 video: {err}"))?;
-
- let (mut window, _gl_ctx, gl_backend) =
- create_window_and_context(&video, args.width, args.height)?;
- let _ = video.gl_set_swap_interval(1);
-
- let gl = unsafe {
- glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _)
- };
-
- let program = unsafe { create_program(&gl, gl_backend)? };
- let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") };
- let u_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") };
- let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") };
- let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }
- .ok_or_else(|| String::from("shader attribute a_pos is missing"))?;
- let a_uv = unsafe { gl.get_attrib_location(program, "a_uv") }
- .ok_or_else(|| String::from("shader attribute a_uv is missing"))?;
-
- let terrain_gpu =
- unsafe { upload_terrain_renderable(&gl, &terrain_mesh, scene.terrain_texture.as_ref())? };
-
- let mut model_gpus = Vec::new();
- for model in &scene.models {
- let renderable = unsafe { upload_model_renderable(&gl, model)? };
- model_gpus.push(renderable);
- }
-
- let (scene_center, scene_radius) = initial_scene_sphere(&scene);
- let mut camera = Camera {
- position: [
- scene_center[0],
- scene_center[1] + scene_radius * 0.6,
- scene_center[2] + scene_radius * 1.4,
- ],
- yaw: std::f32::consts::PI,
- pitch: -0.28,
- move_speed: (scene_radius * 0.55).max(60.0),
- mouse_sensitivity: 0.005,
- };
-
- let mut events = sdl
- .event_pump()
- .map_err(|err| format!("failed to get SDL event pump: {err}"))?;
- let mut last = Instant::now();
- let mut fps_window_start = Instant::now();
- let mut fps_frames = 0u32;
- let mut fps_printed = false;
- let mut mouse_look = false;
-
- 'main_loop: loop {
- for event in events.poll_iter() {
- match event {
- sdl2::event::Event::Quit { .. } => break 'main_loop,
- sdl2::event::Event::KeyDown {
- keycode: Some(sdl2::keyboard::Keycode::Escape),
- ..
- } => break 'main_loop,
- sdl2::event::Event::MouseButtonDown {
- mouse_btn: sdl2::mouse::MouseButton::Right,
- ..
- } => {
- mouse_look = true;
- sdl.mouse().set_relative_mouse_mode(true);
- }
- sdl2::event::Event::MouseButtonUp {
- mouse_btn: sdl2::mouse::MouseButton::Right,
- ..
- } => {
- mouse_look = false;
- sdl.mouse().set_relative_mouse_mode(false);
- }
- sdl2::event::Event::MouseMotion { xrel, yrel, .. } if mouse_look => {
- camera.yaw += xrel as f32 * camera.mouse_sensitivity;
- camera.pitch -= yrel as f32 * camera.mouse_sensitivity;
- camera.pitch = camera.pitch.clamp(-1.54, 1.54);
- }
- _ => {}
- }
- }
-
- let now = Instant::now();
- let dt = (now - last).as_secs_f32().clamp(0.0, 0.05);
- last = now;
-
- update_camera(&events, &mut camera, dt);
-
- let (w, h) = window.size();
- let proj = mat4_perspective(
- args.fov_deg.to_radians(),
- (w as f32 / h.max(1) as f32).max(0.01),
- 0.1,
- (scene_radius * 25.0).max(5000.0),
- );
- let forward = camera_forward(camera.yaw, camera.pitch);
- let view = mat4_look_at(
- camera.position,
- [
- camera.position[0] + forward[0],
- camera.position[1] + forward[1],
- camera.position[2] + forward[2],
- ],
- [0.0, 1.0, 0.0],
- );
-
- unsafe {
- draw_frame_begin(&gl, w, h);
-
- let terrain_mvp = mat4_mul(&proj, &view);
- draw_gpu_renderable(
- &gl,
- program,
- u_mvp.as_ref(),
- u_use_tex.as_ref(),
- u_tex.as_ref(),
- a_pos,
- a_uv,
- &terrain_gpu,
- &terrain_mvp,
- );
-
- for model in &model_gpus {
- for instance in &model.instances {
- let model_m = model_matrix(instance.position, instance.yaw_rad, instance.scale);
- let view_model = mat4_mul(&view, &model_m);
- let mvp = mat4_mul(&proj, &view_model);
- draw_gpu_renderable(
- &gl,
- program,
- u_mvp.as_ref(),
- u_use_tex.as_ref(),
- u_tex.as_ref(),
- a_pos,
- a_uv,
- &model.gpu,
- &mvp,
- );
- }
- }
- }
-
- window.gl_swap_window();
-
- fps_frames = fps_frames.saturating_add(1);
- let elapsed = fps_window_start.elapsed();
- if elapsed >= Duration::from_millis(500) {
- let fps = fps_frames as f32 / elapsed.as_secs_f32().max(0.000_1);
- let frame_time_ms = 1000.0 / fps.max(0.000_1);
- let _ = window.set_title(&format!(
- "Parkan Mission Demo | FPS: {fps:.1} ({frame_time_ms:.2} ms) | objects: {instance_count}"
- ));
- print!("\rFPS: {fps:.1} ({frame_time_ms:.2} ms)");
- let _ = std::io::stdout().flush();
- fps_printed = true;
- fps_frames = 0;
- fps_window_start = Instant::now();
- }
- }
-
- if fps_printed {
- println!();
- }
-
- unsafe {
- cleanup_renderable(&gl, terrain_gpu);
- for model in model_gpus {
- cleanup_renderable(&gl, model.gpu);
- }
- gl.delete_program(program);
- }
-
- Ok(())
-}
-
-fn initial_scene_sphere(scene: &MissionScene) -> ([f32; 3], f32) {
- if let Some((min_v, max_v)) = compute_scene_bounds(scene) {
- let center = [
- 0.5 * (min_v[0] + max_v[0]),
- 0.5 * (min_v[1] + max_v[1]),
- 0.5 * (min_v[2] + max_v[2]),
- ];
- let extent = [
- max_v[0] - min_v[0],
- max_v[1] - min_v[1],
- max_v[2] - min_v[2],
- ];
- let radius = ((extent[0] * extent[0]) + (extent[1] * extent[1]) + (extent[2] * extent[2]))
- .sqrt()
- .max(10.0)
- * 0.5;
- return (center, radius);
- }
- ([0.0, 0.0, 0.0], 100.0)
-}
-
-fn update_camera(events: &sdl2::EventPump, camera: &mut Camera, dt: f32) {
- use sdl2::keyboard::Scancode;
-
- let keys = events.keyboard_state();
- let mut move_dir = [0.0f32, 0.0f32, 0.0f32];
-
- let forward = camera_forward(camera.yaw, camera.pitch);
- let right = normalize3(cross3(forward, [0.0, 1.0, 0.0]));
-
- if keys.is_scancode_pressed(Scancode::Up) || keys.is_scancode_pressed(Scancode::W) {
- move_dir[0] += forward[0];
- move_dir[1] += forward[1];
- move_dir[2] += forward[2];
- }
- if keys.is_scancode_pressed(Scancode::Down) || keys.is_scancode_pressed(Scancode::S) {
- move_dir[0] -= forward[0];
- move_dir[1] -= forward[1];
- move_dir[2] -= forward[2];
- }
- if keys.is_scancode_pressed(Scancode::Left) || keys.is_scancode_pressed(Scancode::A) {
- move_dir[0] -= right[0];
- move_dir[1] -= right[1];
- move_dir[2] -= right[2];
- }
- if keys.is_scancode_pressed(Scancode::Right) || keys.is_scancode_pressed(Scancode::D) {
- move_dir[0] += right[0];
- move_dir[1] += right[1];
- move_dir[2] += right[2];
- }
- if keys.is_scancode_pressed(Scancode::PageUp) || keys.is_scancode_pressed(Scancode::E) {
- move_dir[1] += 1.0;
- }
- if keys.is_scancode_pressed(Scancode::PageDown) || keys.is_scancode_pressed(Scancode::Q) {
- move_dir[1] -= 1.0;
- }
-
- let shift =
- keys.is_scancode_pressed(Scancode::LShift) || keys.is_scancode_pressed(Scancode::RShift);
- let speed_mul = if shift { 3.0 } else { 1.0 };
-
- let norm = normalize3(move_dir);
- camera.position[0] += norm[0] * camera.move_speed * speed_mul * dt;
- camera.position[1] += norm[1] * camera.move_speed * speed_mul * dt;
- camera.position[2] += norm[2] * camera.move_speed * speed_mul * dt;
-}
-
-unsafe fn upload_model_renderable(
- gl: &glow::Context,
- model: &render_mission_demo::SceneModel,
-) -> Result<ModelRenderable, String> {
- let mut vertex_data = Vec::with_capacity(model.mesh.vertices.len() * 5);
- for vertex in &model.mesh.vertices {
- vertex_data.push(vertex.position[0]);
- vertex_data.push(vertex.position[1]);
- vertex_data.push(vertex.position[2]);
- vertex_data.push(vertex.uv0[0]);
- vertex_data.push(vertex.uv0[1]);
- }
-
- let gpu = upload_gpu_renderable(
- gl,
- &vertex_data,
- &model.mesh.indices,
- model.texture.as_ref(),
- )?;
-
- Ok(ModelRenderable {
- gpu,
- instances: model.instances.clone(),
- })
-}
-
-unsafe fn upload_terrain_renderable(
- gl: &glow::Context,
- mesh: &terrain_core::TerrainRenderMesh,
- texture: Option<&render_demo::LoadedTexture>,
-) -> Result<GpuRenderable, String> {
- let mut vertex_data = Vec::with_capacity(mesh.vertices.len() * 5);
- for vertex in &mesh.vertices {
- vertex_data.push(vertex.position[0]);
- vertex_data.push(vertex.position[1]);
- vertex_data.push(vertex.position[2]);
- vertex_data.push(vertex.uv0[0]);
- vertex_data.push(vertex.uv0[1]);
- }
-
- upload_gpu_renderable(gl, &vertex_data, &mesh.indices, texture)
-}
-
-unsafe fn upload_gpu_renderable(
- gl: &glow::Context,
- vertices: &[f32],
- indices: &[u16],
- texture: Option<&render_demo::LoadedTexture>,
-) -> Result<GpuRenderable, String> {
- let vbo = gl.create_buffer().map_err(|e| e.to_string())?;
- let ebo = gl.create_buffer().map_err(|e| e.to_string())?;
-
- let vertex_bytes = f32_slice_to_ne_bytes(vertices);
- let index_bytes = u16_slice_to_ne_bytes(indices);
-
- gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
- gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW);
- gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
- gl.buffer_data_u8_slice(glow::ELEMENT_ARRAY_BUFFER, &index_bytes, glow::STATIC_DRAW);
- gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
- gl.bind_buffer(glow::ARRAY_BUFFER, None);
-
- let gpu_texture = if let Some(texture) = texture {
- Some(create_texture(gl, texture)?)
- } else {
- None
- };
-
- Ok(GpuRenderable {
- vbo,
- ebo,
- index_count: indices.len(),
- texture: gpu_texture,
- })
-}
-
-unsafe fn cleanup_renderable(gl: &glow::Context, renderable: GpuRenderable) {
- if let Some(tex) = renderable.texture {
- gl.delete_texture(tex.handle);
- }
- gl.delete_buffer(renderable.ebo);
- gl.delete_buffer(renderable.vbo);
-}
-
-unsafe fn draw_frame_begin(gl: &glow::Context, width: u32, height: u32) {
- gl.viewport(
- 0,
- 0,
- width.min(i32::MAX as u32) as i32,
- height.min(i32::MAX as u32) as i32,
- );
- gl.enable(glow::DEPTH_TEST);
- gl.clear_color(0.06, 0.08, 0.12, 1.0);
- gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
-}
-
-unsafe fn draw_gpu_renderable(
- gl: &glow::Context,
- program: glow::NativeProgram,
- u_mvp: Option<&glow::NativeUniformLocation>,
- u_use_tex: Option<&glow::NativeUniformLocation>,
- u_tex: Option<&glow::NativeUniformLocation>,
- a_pos: u32,
- a_uv: u32,
- renderable: &GpuRenderable,
- mvp: &[f32; 16],
-) {
- gl.use_program(Some(program));
- gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp);
-
- let texture_enabled = renderable.texture.is_some();
- gl.uniform_1_f32(u_use_tex, if texture_enabled { 1.0 } else { 0.0 });
-
- if let Some(tex) = &renderable.texture {
- gl.active_texture(glow::TEXTURE0);
- gl.bind_texture(glow::TEXTURE_2D, Some(tex.handle));
- gl.uniform_1_i32(u_tex, 0);
- } else {
- gl.bind_texture(glow::TEXTURE_2D, None);
- }
-
- gl.bind_buffer(glow::ARRAY_BUFFER, Some(renderable.vbo));
- gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(renderable.ebo));
- gl.enable_vertex_attrib_array(a_pos);
- gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
- gl.enable_vertex_attrib_array(a_uv);
- gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
-
- gl.draw_elements(
- glow::TRIANGLES,
- renderable.index_count.min(i32::MAX as usize) as i32,
- glow::UNSIGNED_SHORT,
- 0,
- );
-
- gl.disable_vertex_attrib_array(a_uv);
- gl.disable_vertex_attrib_array(a_pos);
- gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
- gl.bind_buffer(glow::ARRAY_BUFFER, None);
- gl.bind_texture(glow::TEXTURE_2D, None);
- gl.use_program(None);
-}
-
-fn create_window_and_context(
- video: &sdl2::VideoSubsystem,
- width: u32,
- height: u32,
-) -> Result<(sdl2::video::Window, sdl2::video::GLContext, GlBackend), String> {
- let candidates = [
- (GlBackend::Gles2, sdl2::video::GLProfile::GLES, 2, 0),
- (GlBackend::Core33, sdl2::video::GLProfile::Core, 3, 3),
- ];
- let mut errors = Vec::new();
-
- for (backend, profile, major, minor) in candidates {
- {
- let gl_attr = video.gl_attr();
- gl_attr.set_context_profile(profile);
- gl_attr.set_context_version(major, minor);
- gl_attr.set_depth_size(24);
- gl_attr.set_double_buffer(true);
- }
-
- let mut window_builder = video.window("Parkan Mission Demo", width, height);
- window_builder.opengl().resizable();
-
- let window = match window_builder.build() {
- Ok(window) => window,
- Err(err) => {
- errors.push(format!(
- "{profile:?} {major}.{minor}: window build failed ({err})"
- ));
- continue;
- }
- };
-
- let gl_ctx = match window.gl_create_context() {
- Ok(ctx) => ctx,
- Err(err) => {
- errors.push(format!(
- "{profile:?} {major}.{minor}: context create failed ({err})"
- ));
- continue;
- }
- };
-
- if let Err(err) = window.gl_make_current(&gl_ctx) {
- errors.push(format!(
- "{profile:?} {major}.{minor}: make current failed ({err})"
- ));
- continue;
- }
-
- return Ok((window, gl_ctx, backend));
- }
-
- Err(format!(
- "failed to create OpenGL context. Attempts: {}",
- errors.join(" | ")
- ))
-}
-
-unsafe fn create_texture(
- gl: &glow::Context,
- texture: &render_demo::LoadedTexture,
-) -> Result<GpuTexture, String> {
- let handle = gl.create_texture().map_err(|e| e.to_string())?;
- gl.bind_texture(glow::TEXTURE_2D, Some(handle));
- gl.tex_parameter_i32(
- glow::TEXTURE_2D,
- glow::TEXTURE_MIN_FILTER,
- glow::LINEAR as i32,
- );
- gl.tex_parameter_i32(
- glow::TEXTURE_2D,
- glow::TEXTURE_MAG_FILTER,
- glow::LINEAR as i32,
- );
- gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
- gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
- gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 1);
- gl.tex_image_2d(
- glow::TEXTURE_2D,
- 0,
- glow::RGBA as i32,
- texture.width.min(i32::MAX as u32) as i32,
- texture.height.min(i32::MAX as u32) as i32,
- 0,
- glow::RGBA,
- glow::UNSIGNED_BYTE,
- glow::PixelUnpackData::Slice(Some(texture.rgba8.as_slice())),
- );
- gl.bind_texture(glow::TEXTURE_2D, None);
- Ok(GpuTexture { handle })
-}
-
-unsafe fn create_program(
- gl: &glow::Context,
- backend: GlBackend,
-) -> Result<glow::NativeProgram, String> {
- let (vs_src, fs_src) = match backend {
- GlBackend::Gles2 => (
- r#"
-attribute vec3 a_pos;
-attribute vec2 a_uv;
-uniform mat4 u_mvp;
-varying vec2 v_uv;
-void main() {
- v_uv = a_uv;
- gl_Position = u_mvp * vec4(a_pos, 1.0);
-}
-"#,
- r#"
-precision mediump float;
-uniform sampler2D u_tex;
-uniform float u_use_tex;
-varying vec2 v_uv;
-void main() {
- vec4 base = vec4(0.82, 0.87, 0.95, 1.0);
- vec4 texColor = texture2D(u_tex, v_uv);
- gl_FragColor = mix(base, texColor, u_use_tex);
-}
-"#,
- ),
- GlBackend::Core33 => (
- r#"#version 330 core
-in vec3 a_pos;
-in vec2 a_uv;
-uniform mat4 u_mvp;
-out vec2 v_uv;
-void main() {
- v_uv = a_uv;
- gl_Position = u_mvp * vec4(a_pos, 1.0);
-}
-"#,
- r#"#version 330 core
-uniform sampler2D u_tex;
-uniform float u_use_tex;
-in vec2 v_uv;
-out vec4 fragColor;
-void main() {
- vec4 base = vec4(0.82, 0.87, 0.95, 1.0);
- vec4 texColor = texture(u_tex, v_uv);
- fragColor = mix(base, texColor, u_use_tex);
-}
-"#,
- ),
- };
-
- let program = gl.create_program().map_err(|e| e.to_string())?;
- let vs = gl
- .create_shader(glow::VERTEX_SHADER)
- .map_err(|e| e.to_string())?;
- let fs = gl
- .create_shader(glow::FRAGMENT_SHADER)
- .map_err(|e| e.to_string())?;
-
- gl.shader_source(vs, vs_src);
- gl.compile_shader(vs);
- if !gl.get_shader_compile_status(vs) {
- let log = gl.get_shader_info_log(vs);
- gl.delete_shader(vs);
- gl.delete_shader(fs);
- gl.delete_program(program);
- return Err(format!("vertex shader compile failed: {log}"));
- }
-
- gl.shader_source(fs, fs_src);
- gl.compile_shader(fs);
- if !gl.get_shader_compile_status(fs) {
- let log = gl.get_shader_info_log(fs);
- gl.delete_shader(vs);
- gl.delete_shader(fs);
- gl.delete_program(program);
- return Err(format!("fragment shader compile failed: {log}"));
- }
-
- gl.attach_shader(program, vs);
- gl.attach_shader(program, fs);
- gl.link_program(program);
-
- gl.detach_shader(program, vs);
- gl.detach_shader(program, fs);
- gl.delete_shader(vs);
- gl.delete_shader(fs);
-
- if !gl.get_program_link_status(program) {
- let log = gl.get_program_info_log(program);
- gl.delete_program(program);
- return Err(format!("program link failed: {log}"));
- }
-
- Ok(program)
-}
-
-fn model_matrix(position: [f32; 3], yaw: f32, scale: [f32; 3]) -> [f32; 16] {
- let translation = mat4_translation(position[0], position[1], position[2]);
- let rotation = mat4_rotation_y(yaw);
- let scaling = mat4_scale(scale[0], scale[1], scale[2]);
- let tr = mat4_mul(&translation, &rotation);
- mat4_mul(&tr, &scaling)
-}
-
-fn camera_forward(yaw: f32, pitch: f32) -> [f32; 3] {
- let cp = pitch.cos();
- normalize3([yaw.sin() * cp, pitch.sin(), yaw.cos() * cp])
-}
-
-fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
- [
- a[1] * b[2] - a[2] * b[1],
- a[2] * b[0] - a[0] * b[2],
- a[0] * b[1] - a[1] * b[0],
- ]
-}
-
-fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
- a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
-}
-
-fn normalize3(v: [f32; 3]) -> [f32; 3] {
- let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
- if len <= 1e-6 {
- [0.0, 0.0, 0.0]
- } else {
- [v[0] / len, v[1] / len, v[2] / len]
- }
-}
-
-fn mat4_identity() -> [f32; 16] {
- [
- 1.0, 0.0, 0.0, 0.0, //
- 0.0, 1.0, 0.0, 0.0, //
- 0.0, 0.0, 1.0, 0.0, //
- 0.0, 0.0, 0.0, 1.0, //
- ]
-}
-
-fn mat4_translation(x: f32, y: f32, z: f32) -> [f32; 16] {
- let mut m = mat4_identity();
- m[12] = x;
- m[13] = y;
- m[14] = z;
- m
-}
-
-fn mat4_scale(x: f32, y: f32, z: f32) -> [f32; 16] {
- [
- x, 0.0, 0.0, 0.0, //
- 0.0, y, 0.0, 0.0, //
- 0.0, 0.0, z, 0.0, //
- 0.0, 0.0, 0.0, 1.0, //
- ]
-}
-
-fn mat4_rotation_y(rad: f32) -> [f32; 16] {
- let c = rad.cos();
- let s = rad.sin();
- [
- c, 0.0, -s, 0.0, //
- 0.0, 1.0, 0.0, 0.0, //
- s, 0.0, c, 0.0, //
- 0.0, 0.0, 0.0, 1.0, //
- ]
-}
-
-fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] {
- let f = 1.0 / (0.5 * fovy).tan();
- let nf = 1.0 / (near - far);
- [
- f / aspect,
- 0.0,
- 0.0,
- 0.0,
- 0.0,
- f,
- 0.0,
- 0.0,
- 0.0,
- 0.0,
- (far + near) * nf,
- -1.0,
- 0.0,
- 0.0,
- (2.0 * far * near) * nf,
- 0.0,
- ]
-}
-
-fn mat4_look_at(eye: [f32; 3], target: [f32; 3], up: [f32; 3]) -> [f32; 16] {
- let f = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
- let s = normalize3(cross3(f, up));
- let u = cross3(s, f);
-
- [
- s[0],
- u[0],
- -f[0],
- 0.0,
- s[1],
- u[1],
- -f[1],
- 0.0,
- s[2],
- u[2],
- -f[2],
- 0.0,
- -dot3(s, eye),
- -dot3(u, eye),
- dot3(f, eye),
- 1.0,
- ]
-}
-
-fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
- let mut out = [0.0f32; 16];
- for c in 0..4 {
- for r in 0..4 {
- let mut acc = 0.0f32;
- for k in 0..4 {
- acc += a[k * 4 + r] * b[c * 4 + k];
- }
- out[c * 4 + r] = acc;
- }
- }
- out
-}
-
-fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec<u8> {
- let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<f32>()));
- for &value in slice {
- out.extend_from_slice(&value.to_ne_bytes());
- }
- out
-}
-
-fn u16_slice_to_ne_bytes(slice: &[u16]) -> Vec<u8> {
- let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<u16>()));
- for &value in slice {
- out.extend_from_slice(&value.to_ne_bytes());
- }
- out
-}
diff --git a/crates/render-parity/Cargo.toml b/crates/render-parity/Cargo.toml
deleted file mode 100644
index 865f97e..0000000
--- a/crates/render-parity/Cargo.toml
+++ /dev/null
@@ -1,9 +0,0 @@
-[package]
-name = "render-parity"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
-image = { version = "0.25", default-features = false, features = ["png"] }
-serde = { version = "1", features = ["derive"] }
-toml = "1.0"
diff --git a/crates/render-parity/README.md b/crates/render-parity/README.md
deleted file mode 100644
index a94520e..0000000
--- a/crates/render-parity/README.md
+++ /dev/null
@@ -1,16 +0,0 @@
-# render-parity
-
-Deterministic frame-diff runner for `parkan-render-demo`.
-
-Usage:
-
-```bash
-cargo run -p render-parity -- \
- --manifest parity/cases.toml \
- --output-dir target/render-parity/current
-```
-
-Options:
-
-- `--demo-bin <path>`: use prebuilt `parkan-render-demo` binary instead of `cargo run`.
-- `--keep-going`: continue all cases even after failures.
diff --git a/crates/render-parity/src/lib.rs b/crates/render-parity/src/lib.rs
deleted file mode 100644
index cb412e9..0000000
--- a/crates/render-parity/src/lib.rs
+++ /dev/null
@@ -1,212 +0,0 @@
-use image::{ImageBuffer, Rgba, RgbaImage};
-use serde::Deserialize;
-
-#[derive(Debug, Clone, Deserialize, Default)]
-pub struct ManifestMeta {
- pub width: Option<u32>,
- pub height: Option<u32>,
- pub lod: Option<usize>,
- pub group: Option<usize>,
- pub angle: Option<f32>,
- pub diff_threshold: Option<u8>,
- pub max_mean_abs: Option<f32>,
- pub max_changed_ratio: Option<f32>,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct CaseSpec {
- pub id: String,
- pub archive: String,
- pub model: Option<String>,
- pub reference: String,
- pub width: Option<u32>,
- pub height: Option<u32>,
- pub lod: Option<usize>,
- pub group: Option<usize>,
- pub angle: Option<f32>,
- pub diff_threshold: Option<u8>,
- pub max_mean_abs: Option<f32>,
- pub max_changed_ratio: Option<f32>,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct ParityManifest {
- #[serde(default)]
- pub meta: ManifestMeta,
- #[serde(rename = "case", default)]
- pub cases: Vec<CaseSpec>,
-}
-
-#[derive(Debug, Clone)]
-pub struct DiffMetrics {
- pub width: u32,
- pub height: u32,
- pub mean_abs: f32,
- pub max_abs: u8,
- pub changed_pixels: u64,
- pub changed_ratio: f32,
-}
-
-pub fn compare_images(
- reference: &RgbaImage,
- actual: &RgbaImage,
- diff_threshold: u8,
-) -> Result<DiffMetrics, String> {
- let (rw, rh) = reference.dimensions();
- let (aw, ah) = actual.dimensions();
- if rw != aw || rh != ah {
- return Err(format!(
- "image size mismatch: reference={}x{}, actual={}x{}",
- rw, rh, aw, ah
- ));
- }
-
- let mut diff_sum = 0u64;
- let mut max_abs = 0u8;
- let mut changed_pixels = 0u64;
- let pixel_count = u64::from(rw).saturating_mul(u64::from(rh));
-
- for (ref_px, act_px) in reference.pixels().zip(actual.pixels()) {
- let mut pixel_changed = false;
- for chan in 0..3 {
- let a = i16::from(ref_px[chan]);
- let b = i16::from(act_px[chan]);
- let diff = (a - b).unsigned_abs() as u8;
- diff_sum = diff_sum.saturating_add(u64::from(diff));
- if diff > max_abs {
- max_abs = diff;
- }
- if diff > diff_threshold {
- pixel_changed = true;
- }
- }
- if pixel_changed {
- changed_pixels = changed_pixels.saturating_add(1);
- }
- }
-
- let channels = pixel_count.saturating_mul(3);
- let mean_abs = if channels == 0 {
- 0.0
- } else {
- diff_sum as f32 / channels as f32
- };
- let changed_ratio = if pixel_count == 0 {
- 0.0
- } else {
- changed_pixels as f32 / pixel_count as f32
- };
-
- Ok(DiffMetrics {
- width: rw,
- height: rh,
- mean_abs,
- max_abs,
- changed_pixels,
- changed_ratio,
- })
-}
-
-pub fn build_diff_image(reference: &RgbaImage, actual: &RgbaImage) -> Result<RgbaImage, String> {
- let (rw, rh) = reference.dimensions();
- let (aw, ah) = actual.dimensions();
- if rw != aw || rh != ah {
- return Err(format!(
- "image size mismatch: reference={}x{}, actual={}x{}",
- rw, rh, aw, ah
- ));
- }
-
- let mut out: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(rw, rh);
- for (dst, (ref_px, act_px)) in out
- .pixels_mut()
- .zip(reference.pixels().zip(actual.pixels()))
- {
- let dr = (i16::from(ref_px[0]) - i16::from(act_px[0])).unsigned_abs() as u8;
- let dg = (i16::from(ref_px[1]) - i16::from(act_px[1])).unsigned_abs() as u8;
- let db = (i16::from(ref_px[2]) - i16::from(act_px[2])).unsigned_abs() as u8;
- *dst = Rgba([dr, dg, db, 255]);
- }
- Ok(out)
-}
-
-pub fn evaluate_metrics(
- metrics: &DiffMetrics,
- max_mean_abs: f32,
- max_changed_ratio: f32,
-) -> Vec<String> {
- let mut violations = Vec::new();
- if metrics.mean_abs > max_mean_abs {
- violations.push(format!(
- "mean_abs {:.4} > allowed {:.4}",
- metrics.mean_abs, max_mean_abs
- ));
- }
- if metrics.changed_ratio > max_changed_ratio {
- violations.push(format!(
- "changed_ratio {:.4}% > allowed {:.4}%",
- metrics.changed_ratio * 100.0,
- max_changed_ratio * 100.0
- ));
- }
- violations
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn solid(w: u32, h: u32, r: u8, g: u8, b: u8) -> RgbaImage {
- let mut img = RgbaImage::new(w, h);
- for px in img.pixels_mut() {
- *px = Rgba([r, g, b, 255]);
- }
- img
- }
-
- #[test]
- fn compare_identical_images() {
- let ref_img = solid(4, 3, 10, 20, 30);
- let act_img = solid(4, 3, 10, 20, 30);
- let metrics = compare_images(&ref_img, &act_img, 2).expect("comparison must succeed");
- assert_eq!(metrics.width, 4);
- assert_eq!(metrics.height, 3);
- assert_eq!(metrics.max_abs, 0);
- assert_eq!(metrics.changed_pixels, 0);
- assert_eq!(metrics.mean_abs, 0.0);
- assert_eq!(metrics.changed_ratio, 0.0);
- }
-
- #[test]
- fn compare_detects_changes_and_thresholds() {
- let mut ref_img = solid(2, 2, 100, 100, 100);
- let mut act_img = solid(2, 2, 100, 100, 100);
- ref_img.put_pixel(1, 1, Rgba([120, 100, 100, 255]));
- act_img.put_pixel(1, 1, Rgba([100, 100, 100, 255]));
-
- let metrics = compare_images(&ref_img, &act_img, 5).expect("comparison must succeed");
- assert_eq!(metrics.max_abs, 20);
- assert_eq!(metrics.changed_pixels, 1);
- assert!((metrics.changed_ratio - 0.25).abs() < 1e-6);
- assert!(metrics.mean_abs > 0.0);
-
- let violations = evaluate_metrics(&metrics, 2.0, 0.20);
- assert_eq!(violations.len(), 1);
- assert!(violations[0].contains("changed_ratio"));
- }
-
- #[test]
- fn build_diff_image_returns_per_channel_abs_diff() {
- let mut ref_img = solid(1, 1, 100, 150, 200);
- let mut act_img = solid(1, 1, 90, 180, 170);
- ref_img.put_pixel(0, 0, Rgba([100, 150, 200, 255]));
- act_img.put_pixel(0, 0, Rgba([90, 180, 170, 255]));
-
- let diff = build_diff_image(&ref_img, &act_img).expect("diff image must build");
- let px = diff.get_pixel(0, 0);
- assert_eq!(px[0], 10);
- assert_eq!(px[1], 30);
- assert_eq!(px[2], 30);
- assert_eq!(px[3], 255);
- }
-}
diff --git a/crates/render-parity/src/main.rs b/crates/render-parity/src/main.rs
deleted file mode 100644
index 22795bc..0000000
--- a/crates/render-parity/src/main.rs
+++ /dev/null
@@ -1,405 +0,0 @@
-use image::RgbaImage;
-use render_parity::{
- build_diff_image, compare_images, evaluate_metrics, CaseSpec, ManifestMeta, ParityManifest,
-};
-use std::fs;
-use std::path::{Path, PathBuf};
-use std::process::Command;
-
-const DEFAULT_MANIFEST: &str = "parity/cases.toml";
-const DEFAULT_OUTPUT_DIR: &str = "target/render-parity/current";
-const DEFAULT_WIDTH: u32 = 1280;
-const DEFAULT_HEIGHT: u32 = 720;
-const DEFAULT_LOD: usize = 0;
-const DEFAULT_GROUP: usize = 0;
-const DEFAULT_ANGLE: f32 = 0.0;
-const DEFAULT_DIFF_THRESHOLD: u8 = 8;
-const DEFAULT_MAX_MEAN_ABS: f32 = 2.0;
-const DEFAULT_MAX_CHANGED_RATIO: f32 = 0.01;
-
-struct Args {
- manifest: PathBuf,
- output_dir: PathBuf,
- demo_bin: Option<PathBuf>,
- keep_going: bool,
-}
-
-#[derive(Debug, Clone)]
-struct EffectiveCase {
- id: String,
- archive: PathBuf,
- model: Option<String>,
- reference: PathBuf,
- width: u32,
- height: u32,
- lod: usize,
- group: usize,
- angle: f32,
- diff_threshold: u8,
- max_mean_abs: f32,
- max_changed_ratio: f32,
-}
-
-fn main() {
- let args = match parse_args() {
- Ok(v) => v,
- Err(err) => {
- eprintln!("{err}");
- print_help();
- std::process::exit(2);
- }
- };
-
- if let Err(err) = run(args) {
- eprintln!("{err}");
- std::process::exit(1);
- }
-}
-
-fn parse_args() -> Result<Args, String> {
- let mut manifest = PathBuf::from(DEFAULT_MANIFEST);
- let mut output_dir = PathBuf::from(DEFAULT_OUTPUT_DIR);
- let mut demo_bin = None;
- let mut keep_going = false;
-
- let mut it = std::env::args().skip(1);
- while let Some(arg) = it.next() {
- match arg.as_str() {
- "--manifest" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --manifest"))?;
- manifest = PathBuf::from(value);
- }
- "--output-dir" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --output-dir"))?;
- output_dir = PathBuf::from(value);
- }
- "--demo-bin" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --demo-bin"))?;
- demo_bin = Some(PathBuf::from(value));
- }
- "--keep-going" => {
- keep_going = true;
- }
- "--help" | "-h" => {
- print_help();
- std::process::exit(0);
- }
- other => {
- return Err(format!("unknown argument: {other}"));
- }
- }
- }
-
- Ok(Args {
- manifest,
- output_dir,
- demo_bin,
- keep_going,
- })
-}
-
-fn print_help() {
- eprintln!(
- "render-parity [--manifest <cases.toml>] [--output-dir <dir>] [--demo-bin <path>] [--keep-going]"
- );
- eprintln!(" --manifest path to parity manifest (default: {DEFAULT_MANIFEST})");
- eprintln!(" --output-dir where current renders and diff images are written");
- eprintln!(" --demo-bin prebuilt parkan-render-demo binary path");
- eprintln!(" --keep-going continue all cases even after failures");
-}
-
-fn run(args: Args) -> Result<(), String> {
- let workspace = workspace_root()?;
- let manifest_path = resolve_path(&workspace, &args.manifest);
- let output_dir = resolve_path(&workspace, &args.output_dir);
- let demo_bin = args
- .demo_bin
- .as_ref()
- .map(|path| resolve_path(&workspace, path));
-
- let manifest_raw = fs::read_to_string(&manifest_path)
- .map_err(|err| format!("failed to read manifest {}: {err}", manifest_path.display()))?;
- let manifest: ParityManifest = toml::from_str(&manifest_raw).map_err(|err| {
- format!(
- "failed to parse manifest {}: {err}",
- manifest_path.display()
- )
- })?;
-
- if manifest.cases.is_empty() {
- println!(
- "render-parity: no cases in {} (nothing to validate)",
- manifest_path.display()
- );
- return Ok(());
- }
-
- fs::create_dir_all(&output_dir).map_err(|err| {
- format!(
- "failed to create output directory {}: {err}",
- output_dir.display()
- )
- })?;
-
- let manifest_dir = manifest_path
- .parent()
- .map(Path::to_path_buf)
- .unwrap_or_else(|| workspace.clone());
-
- let mut failed_cases = 0usize;
- for case in &manifest.cases {
- let effective = make_effective_case(&manifest.meta, case, &manifest_dir)?;
- let case_file = output_dir.join(format!("{}.png", sanitize_case_id(&effective.id)));
- let diff_file = output_dir
- .join("diff")
- .join(format!("{}.png", sanitize_case_id(&effective.id)));
-
- let run_res = run_single_case(
- &workspace, // ensure `cargo run` executes from workspace root
- demo_bin.as_deref(),
- &effective,
- &case_file,
- &diff_file,
- );
-
- match run_res {
- Ok(()) => {}
- Err(err) => {
- failed_cases = failed_cases.saturating_add(1);
- eprintln!("[FAIL] {}: {}", effective.id, err);
- if !args.keep_going {
- break;
- }
- }
- }
- }
-
- if failed_cases > 0 {
- return Err(format!(
- "render-parity failed: {} case(s) did not match reference frames",
- failed_cases
- ));
- }
-
- println!("render-parity: all cases passed");
- Ok(())
-}
-
-fn run_single_case(
- workspace: &Path,
- demo_bin: Option<&Path>,
- case: &EffectiveCase,
- case_file: &Path,
- diff_file: &Path,
-) -> Result<(), String> {
- run_render_capture(workspace, demo_bin, case, case_file)?;
-
- let reference = load_rgba(&case.reference)?;
- let actual = load_rgba(case_file)?;
- let metrics = compare_images(&reference, &actual, case.diff_threshold)?;
- let violations = evaluate_metrics(&metrics, case.max_mean_abs, case.max_changed_ratio);
-
- if violations.is_empty() {
- println!(
- "[OK] {} mean_abs={:.4} changed={:.4}% max_abs={} ({}x{})",
- case.id,
- metrics.mean_abs,
- metrics.changed_ratio * 100.0,
- metrics.max_abs,
- metrics.width,
- metrics.height
- );
- return Ok(());
- }
-
- if let Some(parent) = diff_file.parent() {
- fs::create_dir_all(parent).map_err(|err| {
- format!(
- "failed to create diff output directory {}: {err}",
- parent.display()
- )
- })?;
- }
- let diff = build_diff_image(&reference, &actual)?;
- diff.save(diff_file)
- .map_err(|err| format!("failed to save diff image {}: {err}", diff_file.display()))?;
-
- let mut details = String::new();
- for item in violations {
- if !details.is_empty() {
- details.push_str("; ");
- }
- details.push_str(&item);
- }
- Err(format!(
- "{} | diff={} | mean_abs={:.4}, changed={:.4}% ({} px), max_abs={}",
- details,
- diff_file.display(),
- metrics.mean_abs,
- metrics.changed_ratio * 100.0,
- metrics.changed_pixels,
- metrics.max_abs
- ))
-}
-
-fn run_render_capture(
- workspace: &Path,
- demo_bin: Option<&Path>,
- case: &EffectiveCase,
- out_path: &Path,
-) -> Result<(), String> {
- if let Some(parent) = out_path.parent() {
- fs::create_dir_all(parent).map_err(|err| {
- format!(
- "failed to create capture directory {}: {err}",
- parent.display()
- )
- })?;
- }
-
- let mut cmd = if let Some(bin) = demo_bin {
- Command::new(bin)
- } else {
- let mut command = Command::new("cargo");
- command.args(["run", "-p", "render-demo", "--features", "demo", "--"]);
- command
- };
-
- cmd.current_dir(workspace)
- .arg("--archive")
- .arg(&case.archive)
- .arg("--lod")
- .arg(case.lod.to_string())
- .arg("--group")
- .arg(case.group.to_string())
- .arg("--width")
- .arg(case.width.to_string())
- .arg("--height")
- .arg(case.height.to_string())
- .arg("--angle")
- .arg(case.angle.to_string())
- .arg("--capture")
- .arg(out_path);
-
- if let Some(model) = case.model.as_deref() {
- cmd.arg("--model").arg(model);
- }
-
- let output = cmd.output().map_err(|err| {
- let mode = if demo_bin.is_some() {
- "parkan-render-demo"
- } else {
- "cargo run -p render-demo"
- };
- format!("failed to execute {} for case {}: {err}", mode, case.id)
- })?;
- if !output.status.success() {
- let stdout = String::from_utf8_lossy(&output.stdout);
- let stderr = String::from_utf8_lossy(&output.stderr);
- return Err(format!(
- "render command exited with status {:?}\nstdout:\n{}\nstderr:\n{}",
- output.status.code(),
- stdout,
- stderr
- ));
- }
-
- Ok(())
-}
-
-fn load_rgba(path: &Path) -> Result<RgbaImage, String> {
- image::open(path)
- .map_err(|err| format!("failed to load image {}: {err}", path.display()))
- .map(|img| img.to_rgba8())
-}
-
-fn make_effective_case(
- meta: &ManifestMeta,
- case: &CaseSpec,
- manifest_dir: &Path,
-) -> Result<EffectiveCase, String> {
- let width = case.width.or(meta.width).unwrap_or(DEFAULT_WIDTH);
- let height = case.height.or(meta.height).unwrap_or(DEFAULT_HEIGHT);
- if width == 0 || height == 0 {
- return Err(format!(
- "case '{}' has invalid dimensions {}x{}",
- case.id, width, height
- ));
- }
-
- let archive = resolve_path(manifest_dir, Path::new(&case.archive));
- let reference = resolve_path(manifest_dir, Path::new(&case.reference));
- if !archive.is_file() {
- return Err(format!(
- "case '{}' archive not found: {}",
- case.id,
- archive.display()
- ));
- }
- if !reference.is_file() {
- return Err(format!(
- "case '{}' reference frame not found: {}",
- case.id,
- reference.display()
- ));
- }
-
- Ok(EffectiveCase {
- id: case.id.clone(),
- archive,
- model: case.model.clone(),
- reference,
- width,
- height,
- lod: case.lod.or(meta.lod).unwrap_or(DEFAULT_LOD),
- group: case.group.or(meta.group).unwrap_or(DEFAULT_GROUP),
- angle: case.angle.or(meta.angle).unwrap_or(DEFAULT_ANGLE),
- diff_threshold: case
- .diff_threshold
- .or(meta.diff_threshold)
- .unwrap_or(DEFAULT_DIFF_THRESHOLD),
- max_mean_abs: case
- .max_mean_abs
- .or(meta.max_mean_abs)
- .unwrap_or(DEFAULT_MAX_MEAN_ABS),
- max_changed_ratio: case
- .max_changed_ratio
- .or(meta.max_changed_ratio)
- .unwrap_or(DEFAULT_MAX_CHANGED_RATIO),
- })
-}
-
-fn sanitize_case_id(id: &str) -> String {
- id.chars()
- .map(|c| {
- if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
- c
- } else {
- '_'
- }
- })
- .collect()
-}
-
-fn workspace_root() -> Result<PathBuf, String> {
- let root = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("..")
- .join("..")
- .canonicalize()
- .map_err(|err| format!("failed to resolve workspace root: {err}"))?;
- Ok(root)
-}
-
-fn resolve_path(base: &Path, path: &Path) -> PathBuf {
- if path.is_absolute() {
- path.to_path_buf()
- } else {
- base.join(path)
- }
-}
diff --git a/crates/rsli/Cargo.toml b/crates/rsli/Cargo.toml
deleted file mode 100644
index 0ab3036..0000000
--- a/crates/rsli/Cargo.toml
+++ /dev/null
@@ -1,11 +0,0 @@
-[package]
-name = "rsli"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
-common = { path = "../common" }
-flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
-
-[dev-dependencies]
-proptest = "1"
diff --git a/crates/rsli/README.md b/crates/rsli/README.md
deleted file mode 100644
index 27816d6..0000000
--- a/crates/rsli/README.md
+++ /dev/null
@@ -1,58 +0,0 @@
-# rsli
-
-Rust-библиотека для чтения архивов формата **RsLi**.
-
-## Что умеет
-
-- Открытие библиотеки из файла (`open_path`, `open_path_with`).
-- Дешифрование таблицы записей (XOR stream cipher).
-- Поддержка AO-трейлера и media overlay (`allow_ao_trailer`).
-- Поддержка quirk для Deflate `EOF+1` (`allow_deflate_eof_plus_one`).
-- Поиск по имени (`find`, c приведением запроса к uppercase).
-- Загрузка данных:
-- `load`, `load_into`, `load_packed`, `unpack`, `load_fast`.
-
-## Поддерживаемые методы упаковки
-
-- `0x000` None
-- `0x020` XorOnly
-- `0x040` Lzss
-- `0x060` XorLzss
-- `0x080` LzssHuffman
-- `0x0A0` XorLzssHuffman
-- `0x100` Deflate
-
-## Модель ошибок
-
-Типизированные ошибки без паник в production-коде (`InvalidMagic`, `UnsupportedVersion`, `EntryTableOutOfBounds`, `PackedSizePastEof`, `DeflateEofPlusOneQuirkRejected`, `UnsupportedMethod`, и др.).
-
-## Покрытие тестами
-
-### Реальные файлы
-
-- Рекурсивный прогон по `testdata/rsli/**`.
-- Сейчас в наборе: **2 архива**.
-- На реальных данных подтверждены и проходят byte-to-byte проверки методы:
-- `0x040` (LZSS)
-- `0x100` (Deflate)
-- Для каждого архива проверяется:
-- `load`/`load_into`/`load_packed`/`unpack`/`load_fast`;
-- `find`;
-- пересборка и сравнение **byte-to-byte**.
-
-### Синтетические тесты
-
-Из-за отсутствия реальных файлов для части методов добавлены синтетические архивы и тесты:
-
-- Методы:
-- `0x000`, `0x020`, `0x060`, `0x080`, `0x0A0`.
-- Спецкейсы формата:
- - AO trailer + overlay;
- - Deflate `EOF+1` (оба режима: accepted/rejected);
-- некорректные заголовки/таблицы/смещения/методы.
-
-## Быстрый запуск тестов
-
-```bash
-cargo test -p rsli -- --nocapture
-```
diff --git a/crates/rsli/src/compress/deflate.rs b/crates/rsli/src/compress/deflate.rs
deleted file mode 100644
index 6b8ea73..0000000
--- a/crates/rsli/src/compress/deflate.rs
+++ /dev/null
@@ -1,14 +0,0 @@
-use crate::error::Error;
-use crate::Result;
-use flate2::read::DeflateDecoder;
-use std::io::Read;
-
-/// Decode raw Deflate (RFC 1951) payload.
-pub fn decode_deflate(packed: &[u8]) -> Result<Vec<u8>> {
- let mut out = Vec::new();
- let mut decoder = DeflateDecoder::new(packed);
- decoder
- .read_to_end(&mut out)
- .map_err(|_| Error::DecompressionFailed("deflate"))?;
- Ok(out)
-}
diff --git a/crates/rsli/src/compress/lzh.rs b/crates/rsli/src/compress/lzh.rs
deleted file mode 100644
index 9486c50..0000000
--- a/crates/rsli/src/compress/lzh.rs
+++ /dev/null
@@ -1,303 +0,0 @@
-use super::xor::XorState;
-use crate::error::Error;
-use crate::Result;
-
-pub(crate) const LZH_N: usize = 4096;
-pub(crate) const LZH_F: usize = 60;
-pub(crate) const LZH_THRESHOLD: usize = 2;
-pub(crate) const LZH_N_CHAR: usize = 256 - LZH_THRESHOLD + LZH_F;
-pub(crate) const LZH_T: usize = LZH_N_CHAR * 2 - 1;
-pub(crate) const LZH_R: usize = LZH_T - 1;
-pub(crate) const LZH_MAX_FREQ: u16 = 0x8000;
-
-/// LZSS-Huffman decompression with optional on-the-fly XOR decryption.
-pub fn lzss_huffman_decompress(
- data: &[u8],
- expected_size: usize,
- xor_key: Option<u16>,
-) -> Result<Vec<u8>> {
- let mut decoder = LzhDecoder::new(data, xor_key);
- decoder.decode(expected_size)
-}
-
-struct LzhDecoder<'a> {
- bit_reader: BitReader<'a>,
- text: [u8; LZH_N],
- freq: [u16; LZH_T + 1],
- parent: [usize; LZH_T + LZH_N_CHAR],
- son: [usize; LZH_T],
- d_code: [u8; 256],
- d_len: [u8; 256],
- ring_pos: usize,
-}
-
-impl<'a> LzhDecoder<'a> {
- fn new(data: &'a [u8], xor_key: Option<u16>) -> Self {
- let mut decoder = Self {
- bit_reader: BitReader::new(data, xor_key),
- text: [0x20u8; LZH_N],
- freq: [0u16; LZH_T + 1],
- parent: [0usize; LZH_T + LZH_N_CHAR],
- son: [0usize; LZH_T],
- d_code: [0u8; 256],
- d_len: [0u8; 256],
- ring_pos: LZH_N - LZH_F,
- };
- decoder.init_tables();
- decoder.start_huff();
- decoder
- }
-
- fn decode(&mut self, expected_size: usize) -> Result<Vec<u8>> {
- let mut out = Vec::with_capacity(expected_size);
-
- while out.len() < expected_size {
- let c = self.decode_char()?;
- if c < 256 {
- let byte = c as u8;
- out.push(byte);
- self.text[self.ring_pos] = byte;
- self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1);
- } else {
- let mut offset = self.decode_position()?;
- offset = (self.ring_pos.wrapping_sub(offset).wrapping_sub(1)) & (LZH_N - 1);
- let mut length = c.saturating_sub(253);
-
- while length > 0 && out.len() < expected_size {
- let byte = self.text[offset];
- out.push(byte);
- self.text[self.ring_pos] = byte;
- self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1);
- offset = (offset + 1) & (LZH_N - 1);
- length -= 1;
- }
- }
- }
-
- if out.len() != expected_size {
- return Err(Error::DecompressionFailed("lzss-huffman"));
- }
- Ok(out)
- }
-
- fn init_tables(&mut self) {
- let d_code_group_counts = [1usize, 3, 8, 12, 24, 16];
- let d_len_group_counts = [32usize, 48, 64, 48, 48, 16];
-
- let mut group_index = 0u8;
- let mut idx = 0usize;
- let mut run = 32usize;
- for count in d_code_group_counts {
- for _ in 0..count {
- for _ in 0..run {
- self.d_code[idx] = group_index;
- idx += 1;
- }
- group_index = group_index.wrapping_add(1);
- }
- run >>= 1;
- }
-
- let mut len = 3u8;
- idx = 0;
- for count in d_len_group_counts {
- for _ in 0..count {
- self.d_len[idx] = len;
- idx += 1;
- }
- len = len.saturating_add(1);
- }
- }
-
- fn start_huff(&mut self) {
- for i in 0..LZH_N_CHAR {
- self.freq[i] = 1;
- self.son[i] = i + LZH_T;
- self.parent[i + LZH_T] = i;
- }
-
- let mut i = 0usize;
- let mut j = LZH_N_CHAR;
- while j <= LZH_R {
- self.freq[j] = self.freq[i].saturating_add(self.freq[i + 1]);
- self.son[j] = i;
- self.parent[i] = j;
- self.parent[i + 1] = j;
- i += 2;
- j += 1;
- }
-
- self.freq[LZH_T] = u16::MAX;
- self.parent[LZH_R] = 0;
- }
-
- fn decode_char(&mut self) -> Result<usize> {
- let mut node = self.son[LZH_R];
- while node < LZH_T {
- let bit = usize::from(self.bit_reader.read_bit()?);
- let branch = node
- .checked_add(bit)
- .ok_or(Error::DecompressionFailed("lzss-huffman tree overflow"))?;
- node = *self.son.get(branch).ok_or(Error::DecompressionFailed(
- "lzss-huffman tree out of bounds",
- ))?;
- }
-
- let c = node - LZH_T;
- self.update(c);
- Ok(c)
- }
-
- fn decode_position(&mut self) -> Result<usize> {
- let i = self.bit_reader.read_bits(8)? as usize;
- let mut c = usize::from(self.d_code[i]) << 6;
- let mut j = usize::from(self.d_len[i]).saturating_sub(2);
-
- while j > 0 {
- j -= 1;
- c |= usize::from(self.bit_reader.read_bit()?) << j;
- }
-
- Ok(c | (i & 0x3F))
- }
-
- fn update(&mut self, c: usize) {
- if self.freq[LZH_R] == LZH_MAX_FREQ {
- self.reconstruct();
- }
-
- let mut current = self.parent[c + LZH_T];
- loop {
- self.freq[current] = self.freq[current].saturating_add(1);
- let freq = self.freq[current];
-
- if current + 1 < self.freq.len() && freq > self.freq[current + 1] {
- let mut swap_idx = current + 1;
- while swap_idx + 1 < self.freq.len() && freq > self.freq[swap_idx + 1] {
- swap_idx += 1;
- }
-
- self.freq.swap(current, swap_idx);
-
- let left = self.son[current];
- let right = self.son[swap_idx];
- self.son[current] = right;
- self.son[swap_idx] = left;
-
- self.parent[left] = swap_idx;
- if left < LZH_T {
- self.parent[left + 1] = swap_idx;
- }
-
- self.parent[right] = current;
- if right < LZH_T {
- self.parent[right + 1] = current;
- }
-
- current = swap_idx;
- }
-
- current = self.parent[current];
- if current == 0 {
- break;
- }
- }
- }
-
- fn reconstruct(&mut self) {
- let mut j = 0usize;
- for i in 0..LZH_T {
- if self.son[i] >= LZH_T {
- self.freq[j] = (self.freq[i].saturating_add(1)) / 2;
- self.son[j] = self.son[i];
- j += 1;
- }
- }
-
- let mut i = 0usize;
- let mut current = LZH_N_CHAR;
- while current < LZH_T {
- let sum = self.freq[i].saturating_add(self.freq[i + 1]);
- self.freq[current] = sum;
-
- let mut insert_at = current;
- while insert_at > 0 && sum < self.freq[insert_at - 1] {
- insert_at -= 1;
- }
-
- for move_idx in (insert_at..current).rev() {
- self.freq[move_idx + 1] = self.freq[move_idx];
- self.son[move_idx + 1] = self.son[move_idx];
- }
-
- self.freq[insert_at] = sum;
- self.son[insert_at] = i;
-
- i += 2;
- current += 1;
- }
-
- for idx in 0..LZH_T {
- let node = self.son[idx];
- self.parent[node] = idx;
- if node < LZH_T {
- self.parent[node + 1] = idx;
- }
- }
-
- self.freq[LZH_T] = u16::MAX;
- self.parent[LZH_R] = 0;
- }
-}
-
-struct BitReader<'a> {
- data: &'a [u8],
- byte_pos: usize,
- bit_mask: u8,
- current_byte: u8,
- xor_state: Option<XorState>,
-}
-
-impl<'a> BitReader<'a> {
- fn new(data: &'a [u8], xor_key: Option<u16>) -> Self {
- Self {
- data,
- byte_pos: 0,
- bit_mask: 0x80,
- current_byte: 0,
- xor_state: xor_key.map(XorState::new),
- }
- }
-
- fn read_bit(&mut self) -> Result<u8> {
- if self.bit_mask == 0x80 {
- let Some(mut byte) = self.data.get(self.byte_pos).copied() else {
- return Err(Error::DecompressionFailed("lzss-huffman: unexpected EOF"));
- };
- if let Some(state) = &mut self.xor_state {
- byte = state.decrypt_byte(byte);
- }
- self.current_byte = byte;
- }
-
- let bit = if (self.current_byte & self.bit_mask) != 0 {
- 1
- } else {
- 0
- };
- self.bit_mask >>= 1;
- if self.bit_mask == 0 {
- self.bit_mask = 0x80;
- self.byte_pos = self.byte_pos.saturating_add(1);
- }
- Ok(bit)
- }
-
- fn read_bits(&mut self, bits: usize) -> Result<u32> {
- let mut value = 0u32;
- for _ in 0..bits {
- value = (value << 1) | u32::from(self.read_bit()?);
- }
- Ok(value)
- }
-}
diff --git a/crates/rsli/src/compress/lzss.rs b/crates/rsli/src/compress/lzss.rs
deleted file mode 100644
index d30345c..0000000
--- a/crates/rsli/src/compress/lzss.rs
+++ /dev/null
@@ -1,79 +0,0 @@
-use super::xor::XorState;
-use crate::error::Error;
-use crate::Result;
-
-/// Simple LZSS decompression with optional on-the-fly XOR decryption
-pub fn lzss_decompress_simple(
- data: &[u8],
- expected_size: usize,
- xor_key: Option<u16>,
-) -> Result<Vec<u8>> {
- let mut ring = [0x20u8; 0x1000];
- let mut ring_pos = 0xFEEusize;
- let mut out = Vec::with_capacity(expected_size);
- let mut in_pos = 0usize;
-
- let mut control = 0u8;
- let mut bits_left = 0u8;
-
- // XOR state for on-the-fly decryption
- let mut xor_state = xor_key.map(XorState::new);
-
- // Helper to read byte with optional XOR decryption
- let read_byte = |pos: usize, state: &mut Option<XorState>| -> Option<u8> {
- let encrypted = data.get(pos).copied()?;
- Some(if let Some(ref mut s) = state {
- s.decrypt_byte(encrypted)
- } else {
- encrypted
- })
- };
-
- while out.len() < expected_size {
- if bits_left == 0 {
- let byte = read_byte(in_pos, &mut xor_state)
- .ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
- control = byte;
- in_pos += 1;
- bits_left = 8;
- }
-
- if (control & 1) != 0 {
- let byte = read_byte(in_pos, &mut xor_state)
- .ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
- in_pos += 1;
-
- out.push(byte);
- ring[ring_pos] = byte;
- ring_pos = (ring_pos + 1) & 0x0FFF;
- } else {
- let low = read_byte(in_pos, &mut xor_state)
- .ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
- let high = read_byte(in_pos + 1, &mut xor_state)
- .ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
- in_pos += 2;
-
- let offset = usize::from(low) | (usize::from(high & 0xF0) << 4);
- let length = usize::from((high & 0x0F) + 3);
-
- for step in 0..length {
- let byte = ring[(offset + step) & 0x0FFF];
- out.push(byte);
- ring[ring_pos] = byte;
- ring_pos = (ring_pos + 1) & 0x0FFF;
- if out.len() >= expected_size {
- break;
- }
- }
- }
-
- control >>= 1;
- bits_left -= 1;
- }
-
- if out.len() != expected_size {
- return Err(Error::DecompressionFailed("lzss-simple"));
- }
-
- Ok(out)
-}
diff --git a/crates/rsli/src/compress/mod.rs b/crates/rsli/src/compress/mod.rs
deleted file mode 100644
index bd23143..0000000
--- a/crates/rsli/src/compress/mod.rs
+++ /dev/null
@@ -1,9 +0,0 @@
-pub mod deflate;
-pub mod lzh;
-pub mod lzss;
-pub mod xor;
-
-pub use deflate::decode_deflate;
-pub use lzh::lzss_huffman_decompress;
-pub use lzss::lzss_decompress_simple;
-pub use xor::{xor_stream, XorState};
diff --git a/crates/rsli/src/compress/xor.rs b/crates/rsli/src/compress/xor.rs
deleted file mode 100644
index c4c3d7d..0000000
--- a/crates/rsli/src/compress/xor.rs
+++ /dev/null
@@ -1,29 +0,0 @@
-/// XOR cipher state for RsLi format
-pub struct XorState {
- lo: u8,
- hi: u8,
-}
-
-impl XorState {
- /// Create new XOR state from 16-bit key
- pub fn new(key16: u16) -> Self {
- Self {
- lo: (key16 & 0xFF) as u8,
- hi: ((key16 >> 8) & 0xFF) as u8,
- }
- }
-
- /// Decrypt a single byte and update state
- pub fn decrypt_byte(&mut self, encrypted: u8) -> u8 {
- self.lo = self.hi ^ self.lo.wrapping_shl(1);
- let decrypted = encrypted ^ self.lo;
- self.hi = self.lo ^ (self.hi >> 1);
- decrypted
- }
-}
-
-/// Decrypt entire buffer with XOR stream cipher
-pub fn xor_stream(data: &[u8], key16: u16) -> Vec<u8> {
- let mut state = XorState::new(key16);
- data.iter().map(|&b| state.decrypt_byte(b)).collect()
-}
diff --git a/crates/rsli/src/error.rs b/crates/rsli/src/error.rs
deleted file mode 100644
index 5a36101..0000000
--- a/crates/rsli/src/error.rs
+++ /dev/null
@@ -1,140 +0,0 @@
-use core::fmt;
-
-#[derive(Debug)]
-#[non_exhaustive]
-pub enum Error {
- Io(std::io::Error),
-
- InvalidMagic {
- got: [u8; 2],
- },
- UnsupportedVersion {
- got: u8,
- },
- InvalidEntryCount {
- got: i16,
- },
- TooManyEntries {
- got: usize,
- },
-
- EntryTableOutOfBounds {
- table_offset: u64,
- table_len: u64,
- file_len: u64,
- },
- EntryTableDecryptFailed,
- CorruptEntryTable(&'static str),
-
- EntryIdOutOfRange {
- id: u32,
- entry_count: u32,
- },
- EntryDataOutOfBounds {
- id: u32,
- offset: u64,
- size: u32,
- file_len: u64,
- },
-
- AoTrailerInvalid,
- MediaOverlayOutOfBounds {
- overlay: u32,
- file_len: u64,
- },
-
- UnsupportedMethod {
- raw: u32,
- },
- PackedSizePastEof {
- id: u32,
- offset: u64,
- packed_size: u32,
- file_len: u64,
- },
- DeflateEofPlusOneQuirkRejected {
- id: u32,
- },
-
- DecompressionFailed(&'static str),
- OutputSizeMismatch {
- expected: u32,
- got: u32,
- },
-
- IntegerOverflow,
-}
-
-impl From<std::io::Error> for Error {
- fn from(value: std::io::Error) -> Self {
- Self::Io(value)
- }
-}
-
-impl fmt::Display for Error {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Error::Io(e) => write!(f, "I/O error: {e}"),
- Error::InvalidMagic { got } => write!(f, "invalid RsLi magic: {got:02X?}"),
- Error::UnsupportedVersion { got } => write!(f, "unsupported RsLi version: {got:#x}"),
- Error::InvalidEntryCount { got } => write!(f, "invalid entry_count: {got}"),
- Error::TooManyEntries { got } => write!(f, "too many entries: {got} exceeds u32::MAX"),
- Error::EntryTableOutOfBounds {
- table_offset,
- table_len,
- file_len,
- } => write!(
- f,
- "entry table out of bounds: off={table_offset}, len={table_len}, file={file_len}"
- ),
- Error::EntryTableDecryptFailed => write!(f, "failed to decrypt entry table"),
- Error::CorruptEntryTable(s) => write!(f, "corrupt entry table: {s}"),
- Error::EntryIdOutOfRange { id, entry_count } => {
- write!(f, "entry id out of range: id={id}, count={entry_count}")
- }
- Error::EntryDataOutOfBounds {
- id,
- offset,
- size,
- file_len,
- } => write!(
- f,
- "entry data out of bounds: id={id}, off={offset}, size={size}, file={file_len}"
- ),
- Error::AoTrailerInvalid => write!(f, "invalid AO trailer"),
- Error::MediaOverlayOutOfBounds { overlay, file_len } => {
- write!(
- f,
- "media overlay out of bounds: overlay={overlay}, file={file_len}"
- )
- }
- Error::UnsupportedMethod { raw } => write!(f, "unsupported packing method: {raw:#x}"),
- Error::PackedSizePastEof {
- id,
- offset,
- packed_size,
- file_len,
- } => write!(
- f,
- "packed range past EOF: id={id}, off={offset}, size={packed_size}, file={file_len}"
- ),
- Error::DeflateEofPlusOneQuirkRejected { id } => {
- write!(f, "deflate EOF+1 quirk rejected for entry {id}")
- }
- Error::DecompressionFailed(s) => write!(f, "decompression failed: {s}"),
- Error::OutputSizeMismatch { expected, got } => {
- write!(f, "output size mismatch: expected={expected}, got={got}")
- }
- Error::IntegerOverflow => write!(f, "integer overflow"),
- }
- }
-}
-
-impl std::error::Error for Error {
- fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
- match self {
- Self::Io(err) => Some(err),
- _ => None,
- }
- }
-}
diff --git a/crates/rsli/src/lib.rs b/crates/rsli/src/lib.rs
deleted file mode 100644
index 1ce3b1f..0000000
--- a/crates/rsli/src/lib.rs
+++ /dev/null
@@ -1,470 +0,0 @@
-pub mod compress;
-pub mod error;
-pub mod parse;
-
-use crate::compress::{
- decode_deflate, lzss_decompress_simple, lzss_huffman_decompress, xor_stream,
-};
-use crate::error::Error;
-use crate::parse::{c_name_bytes, cmp_c_string, parse_library};
-use common::{OutputBuffer, ResourceData};
-use std::cmp::Ordering;
-use std::fs;
-use std::path::Path;
-use std::sync::Arc;
-
-pub type Result<T> = core::result::Result<T, Error>;
-
-#[derive(Clone, Debug)]
-pub struct OpenOptions {
- pub allow_ao_trailer: bool,
- pub allow_deflate_eof_plus_one: bool,
-}
-
-impl Default for OpenOptions {
- fn default() -> Self {
- Self {
- allow_ao_trailer: true,
- allow_deflate_eof_plus_one: true,
- }
- }
-}
-
-#[derive(Clone, Debug)]
-pub struct LibraryHeader {
- pub raw: [u8; 32],
- pub magic: [u8; 2],
- pub reserved: u8,
- pub version: u8,
- pub entry_count: i16,
- pub presorted_flag: u16,
- pub xor_seed: u32,
-}
-
-#[derive(Clone, Debug)]
-pub struct AoTrailer {
- pub raw: [u8; 6],
- pub overlay: u32,
-}
-
-#[derive(Debug)]
-pub struct Library {
- bytes: Arc<[u8]>,
- entries: Vec<EntryRecord>,
- header: LibraryHeader,
- ao_trailer: Option<AoTrailer>,
- #[cfg(test)]
- pub(crate) table_plain_original: Vec<u8>,
- #[cfg(test)]
- pub(crate) source_size: usize,
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
-pub struct EntryId(pub u32);
-
-#[derive(Clone, Debug)]
-pub struct EntryMeta {
- pub name: String,
- pub flags: i32,
- pub method: PackMethod,
- pub data_offset: u64,
- pub packed_size: u32,
- pub unpacked_size: u32,
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq)]
-pub enum PackMethod {
- None,
- XorOnly,
- Lzss,
- XorLzss,
- LzssHuffman,
- XorLzssHuffman,
- Deflate,
- Unknown(u32),
-}
-
-#[derive(Copy, Clone, Debug)]
-pub struct EntryRef<'a> {
- pub id: EntryId,
- pub meta: &'a EntryMeta,
-}
-
-#[derive(Copy, Clone, Debug)]
-pub struct EntryInspect<'a> {
- pub id: EntryId,
- pub meta: &'a EntryMeta,
- pub name_raw: &'a [u8; 12],
- pub service_tail: &'a [u8; 4],
- pub sort_to_original: i16,
- pub data_offset_raw: u32,
-}
-
-pub struct PackedResource {
- pub meta: EntryMeta,
- pub packed: Vec<u8>,
-}
-
-#[derive(Clone, Debug)]
-pub(crate) struct EntryRecord {
- pub(crate) meta: EntryMeta,
- pub(crate) name_raw: [u8; 12],
- pub(crate) service_tail: [u8; 4],
- pub(crate) sort_to_original: i16,
- pub(crate) key16: u16,
- pub(crate) data_offset_raw: u32,
- pub(crate) packed_size_declared: u32,
- pub(crate) packed_size_available: usize,
- pub(crate) effective_offset: usize,
-}
-
-impl Library {
- pub fn open_path(path: impl AsRef<Path>) -> Result<Self> {
- Self::open_path_with(path, OpenOptions::default())
- }
-
- pub fn open_path_with(path: impl AsRef<Path>, opts: OpenOptions) -> Result<Self> {
- let bytes = fs::read(path.as_ref())?;
- let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice());
- parse_library(arc, opts)
- }
-
- pub fn header(&self) -> &LibraryHeader {
- &self.header
- }
-
- pub fn ao_trailer(&self) -> Option<&AoTrailer> {
- self.ao_trailer.as_ref()
- }
-
- pub fn entry_count(&self) -> usize {
- self.entries.len()
- }
-
- pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
- self.entries.iter().enumerate().filter_map(|(idx, entry)| {
- let id = u32::try_from(idx).ok()?;
- Some(EntryRef {
- id: EntryId(id),
- meta: &entry.meta,
- })
- })
- }
-
- pub fn entries_inspect(&self) -> impl Iterator<Item = EntryInspect<'_>> {
- self.entries.iter().enumerate().filter_map(|(idx, entry)| {
- let id = u32::try_from(idx).ok()?;
- Some(EntryInspect {
- id: EntryId(id),
- meta: &entry.meta,
- name_raw: &entry.name_raw,
- service_tail: &entry.service_tail,
- sort_to_original: entry.sort_to_original,
- data_offset_raw: entry.data_offset_raw,
- })
- })
- }
-
- pub fn find(&self, name: &str) -> Option<EntryId> {
- if self.entries.is_empty() {
- return None;
- }
-
- const MAX_INLINE_NAME: usize = 12;
-
- // Fast path: use stack allocation for short ASCII names (95% of cases)
- if name.len() <= MAX_INLINE_NAME && name.is_ascii() {
- let mut buf = [0u8; MAX_INLINE_NAME];
- for (i, &b) in name.as_bytes().iter().enumerate() {
- buf[i] = b.to_ascii_uppercase();
- }
- return self.find_impl(&buf[..name.len()]);
- }
-
- // Slow path: heap allocation for long or non-ASCII names
- let query = name.to_ascii_uppercase();
- self.find_impl(query.as_bytes())
- }
-
- fn find_impl(&self, query_bytes: &[u8]) -> Option<EntryId> {
- // Binary search
- let mut low = 0usize;
- let mut high = self.entries.len();
- while low < high {
- let mid = low + (high - low) / 2;
- let idx = self.entries[mid].sort_to_original;
- if idx < 0 {
- break;
- }
- let idx = usize::try_from(idx).ok()?;
- if idx >= self.entries.len() {
- break;
- }
-
- let cmp = cmp_c_string(query_bytes, c_name_bytes(&self.entries[idx].name_raw));
- match cmp {
- Ordering::Less => high = mid,
- Ordering::Greater => low = mid + 1,
- Ordering::Equal => {
- let id = u32::try_from(idx).ok()?;
- return Some(EntryId(id));
- }
- }
- }
-
- // Linear fallback search
- self.entries.iter().enumerate().find_map(|(idx, entry)| {
- if cmp_c_string(query_bytes, c_name_bytes(&entry.name_raw)) == Ordering::Equal {
- let id = u32::try_from(idx).ok()?;
- Some(EntryId(id))
- } else {
- None
- }
- })
- }
-
- pub fn get(&self, id: EntryId) -> Option<EntryRef<'_>> {
- let idx = usize::try_from(id.0).ok()?;
- let entry = self.entries.get(idx)?;
- Some(EntryRef {
- id,
- meta: &entry.meta,
- })
- }
-
- pub fn inspect(&self, id: EntryId) -> Option<EntryInspect<'_>> {
- let idx = usize::try_from(id.0).ok()?;
- let entry = self.entries.get(idx)?;
- Some(EntryInspect {
- id,
- meta: &entry.meta,
- name_raw: &entry.name_raw,
- service_tail: &entry.service_tail,
- sort_to_original: entry.sort_to_original,
- data_offset_raw: entry.data_offset_raw,
- })
- }
-
- pub fn load(&self, id: EntryId) -> Result<Vec<u8>> {
- let entry = self.entry_by_id(id)?;
- let packed = self.packed_slice(id, entry)?;
- decode_payload(
- packed,
- entry.meta.method,
- entry.key16,
- entry.meta.unpacked_size,
- )
- }
-
- pub fn load_into(&self, id: EntryId, out: &mut dyn OutputBuffer) -> Result<usize> {
- let decoded = self.load(id)?;
- out.write_exact(&decoded)?;
- Ok(decoded.len())
- }
-
- pub fn load_packed(&self, id: EntryId) -> Result<PackedResource> {
- let entry = self.entry_by_id(id)?;
- let packed = self.packed_slice(id, entry)?.to_vec();
- Ok(PackedResource {
- meta: entry.meta.clone(),
- packed,
- })
- }
-
- pub fn unpack(&self, packed: &PackedResource) -> Result<Vec<u8>> {
- let key16 = self.resolve_key_for_meta(&packed.meta).unwrap_or(0);
-
- let method = packed.meta.method;
- if needs_xor_key(method) && self.resolve_key_for_meta(&packed.meta).is_none() {
- return Err(Error::CorruptEntryTable(
- "cannot resolve XOR key for packed resource",
- ));
- }
-
- decode_payload(&packed.packed, method, key16, packed.meta.unpacked_size)
- }
-
- pub fn load_fast(&self, id: EntryId) -> Result<ResourceData<'_>> {
- let entry = self.entry_by_id(id)?;
- if entry.meta.method == PackMethod::None {
- let packed = self.packed_slice(id, entry)?;
- let size =
- usize::try_from(entry.meta.unpacked_size).map_err(|_| Error::IntegerOverflow)?;
- if packed.len() < size {
- return Err(Error::OutputSizeMismatch {
- expected: entry.meta.unpacked_size,
- got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
- });
- }
- return Ok(ResourceData::Borrowed(&packed[..size]));
- }
- Ok(ResourceData::Owned(self.load(id)?))
- }
-
- fn entry_by_id(&self, id: EntryId) -> Result<&EntryRecord> {
- let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
- self.entries
- .get(idx)
- .ok_or_else(|| Error::EntryIdOutOfRange {
- id: id.0,
- entry_count: saturating_u32_len(self.entries.len()),
- })
- }
-
- fn packed_slice<'a>(&'a self, id: EntryId, entry: &EntryRecord) -> Result<&'a [u8]> {
- let start = entry.effective_offset;
- let end = start
- .checked_add(entry.packed_size_available)
- .ok_or(Error::IntegerOverflow)?;
- self.bytes
- .get(start..end)
- .ok_or(Error::EntryDataOutOfBounds {
- id: id.0,
- offset: u64::try_from(start).unwrap_or(u64::MAX),
- size: entry.packed_size_declared,
- file_len: u64::try_from(self.bytes.len()).unwrap_or(u64::MAX),
- })
- }
-
- fn resolve_key_for_meta(&self, meta: &EntryMeta) -> Option<u16> {
- self.entries
- .iter()
- .find(|entry| {
- entry.meta.name == meta.name
- && entry.meta.flags == meta.flags
- && entry.meta.data_offset == meta.data_offset
- && entry.meta.packed_size == meta.packed_size
- && entry.meta.unpacked_size == meta.unpacked_size
- && entry.meta.method == meta.method
- })
- .map(|entry| entry.key16)
- }
-
- #[cfg(test)]
- pub(crate) fn rebuild_from_parsed_metadata(&self) -> Result<Vec<u8>> {
- let trailer_len = usize::from(self.ao_trailer.is_some()) * 6;
- let pre_trailer_size = self
- .source_size
- .checked_sub(trailer_len)
- .ok_or(Error::IntegerOverflow)?;
-
- let count = self.entries.len();
- let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?;
- let table_end = 32usize
- .checked_add(table_len)
- .ok_or(Error::IntegerOverflow)?;
- if pre_trailer_size < table_end {
- return Err(Error::EntryTableOutOfBounds {
- table_offset: 32,
- table_len: u64::try_from(table_len).map_err(|_| Error::IntegerOverflow)?,
- file_len: u64::try_from(pre_trailer_size).map_err(|_| Error::IntegerOverflow)?,
- });
- }
-
- let mut out = vec![0u8; pre_trailer_size];
- out[0..32].copy_from_slice(&self.header.raw);
- let encrypted_table = xor_stream(
- &self.table_plain_original,
- (self.header.xor_seed & 0xFFFF) as u16,
- );
- out[32..table_end].copy_from_slice(&encrypted_table);
-
- let mut occupied = vec![false; pre_trailer_size];
- for byte in occupied.iter_mut().take(table_end) {
- *byte = true;
- }
-
- for (idx, entry) in self.entries.iter().enumerate() {
- let id = u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?;
- let packed = self.load_packed(EntryId(id))?.packed;
- let start =
- usize::try_from(entry.data_offset_raw).map_err(|_| Error::IntegerOverflow)?;
- for (offset, byte) in packed.iter().copied().enumerate() {
- let pos = start.checked_add(offset).ok_or(Error::IntegerOverflow)?;
- if pos >= out.len() {
- return Err(Error::PackedSizePastEof {
- id,
- offset: u64::from(entry.data_offset_raw),
- packed_size: entry.packed_size_declared,
- file_len: u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?,
- });
- }
- if occupied[pos] && out[pos] != byte {
- return Err(Error::CorruptEntryTable("packed payload overlap conflict"));
- }
- out[pos] = byte;
- occupied[pos] = true;
- }
- }
-
- if let Some(trailer) = &self.ao_trailer {
- out.extend_from_slice(&trailer.raw);
- }
- Ok(out)
- }
-}
-
-fn decode_payload(
- packed: &[u8],
- method: PackMethod,
- key16: u16,
- unpacked_size: u32,
-) -> Result<Vec<u8>> {
- let expected = usize::try_from(unpacked_size).map_err(|_| Error::IntegerOverflow)?;
-
- let out = match method {
- PackMethod::None => {
- if packed.len() < expected {
- return Err(Error::OutputSizeMismatch {
- expected: unpacked_size,
- got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
- });
- }
- packed[..expected].to_vec()
- }
- PackMethod::XorOnly => {
- if packed.len() < expected {
- return Err(Error::OutputSizeMismatch {
- expected: unpacked_size,
- got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
- });
- }
- xor_stream(&packed[..expected], key16)
- }
- PackMethod::Lzss => lzss_decompress_simple(packed, expected, None)?,
- PackMethod::XorLzss => {
- // Optimized: XOR on-the-fly during decompression instead of creating temp buffer
- lzss_decompress_simple(packed, expected, Some(key16))?
- }
- PackMethod::LzssHuffman => lzss_huffman_decompress(packed, expected, None)?,
- PackMethod::XorLzssHuffman => {
- // Optimized: XOR on-the-fly during decompression
- lzss_huffman_decompress(packed, expected, Some(key16))?
- }
- PackMethod::Deflate => decode_deflate(packed)?,
- PackMethod::Unknown(raw) => return Err(Error::UnsupportedMethod { raw }),
- };
-
- if out.len() != expected {
- return Err(Error::OutputSizeMismatch {
- expected: unpacked_size,
- got: u32::try_from(out.len()).unwrap_or(u32::MAX),
- });
- }
-
- Ok(out)
-}
-
-fn needs_xor_key(method: PackMethod) -> bool {
- matches!(
- method,
- PackMethod::XorOnly | PackMethod::XorLzss | PackMethod::XorLzssHuffman
- )
-}
-
-fn saturating_u32_len(len: usize) -> u32 {
- u32::try_from(len).unwrap_or(u32::MAX)
-}
-
-#[cfg(test)]
-mod tests;
diff --git a/crates/rsli/src/parse.rs b/crates/rsli/src/parse.rs
deleted file mode 100644
index d3afcd9..0000000
--- a/crates/rsli/src/parse.rs
+++ /dev/null
@@ -1,278 +0,0 @@
-use crate::compress::xor::xor_stream;
-use crate::error::Error;
-use crate::{
- AoTrailer, EntryMeta, EntryRecord, Library, LibraryHeader, OpenOptions, PackMethod, Result,
-};
-use std::cmp::Ordering;
-use std::sync::Arc;
-
-pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
- if bytes.len() < 32 {
- return Err(Error::EntryTableOutOfBounds {
- table_offset: 32,
- table_len: 0,
- file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
- });
- }
-
- let mut header_raw = [0u8; 32];
- header_raw.copy_from_slice(&bytes[0..32]);
-
- let mut magic = [0u8; 2];
- magic.copy_from_slice(&bytes[0..2]);
- if &magic != b"NL" {
- let mut got = [0u8; 2];
- got.copy_from_slice(&bytes[0..2]);
- return Err(Error::InvalidMagic { got });
- }
- let reserved = bytes[2];
- let version = bytes[3];
- if version != 0x01 {
- return Err(Error::UnsupportedVersion { got: version });
- }
-
- let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]);
- if entry_count < 0 {
- return Err(Error::InvalidEntryCount { got: entry_count });
- }
- let count = usize::try_from(entry_count).map_err(|_| Error::IntegerOverflow)?;
-
- // Validate entry_count fits in u32 (required for EntryId)
- if count > u32::MAX as usize {
- return Err(Error::TooManyEntries { got: count });
- }
-
- let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
- let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
- let header = LibraryHeader {
- raw: header_raw,
- magic,
- reserved,
- version,
- entry_count,
- presorted_flag,
- xor_seed,
- };
-
- let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?;
- let table_offset = 32usize;
- let table_end = table_offset
- .checked_add(table_len)
- .ok_or(Error::IntegerOverflow)?;
- if table_end > bytes.len() {
- return Err(Error::EntryTableOutOfBounds {
- table_offset: u64::try_from(table_offset).map_err(|_| Error::IntegerOverflow)?,
- table_len: u64::try_from(table_len).map_err(|_| Error::IntegerOverflow)?,
- file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
- });
- }
-
- let table_enc = &bytes[table_offset..table_end];
- let table_plain_original = xor_stream(table_enc, (xor_seed & 0xFFFF) as u16);
- if table_plain_original.len() != table_len {
- return Err(Error::EntryTableDecryptFailed);
- }
-
- let (overlay, trailer_raw) = parse_ao_trailer(&bytes, opts.allow_ao_trailer)?;
-
- let mut entries = Vec::with_capacity(count);
- for idx in 0..count {
- let row = &table_plain_original[idx * 32..(idx + 1) * 32];
-
- let mut name_raw = [0u8; 12];
- name_raw.copy_from_slice(&row[0..12]);
- let mut service_tail = [0u8; 4];
- service_tail.copy_from_slice(&row[12..16]);
-
- let flags_signed = i16::from_le_bytes([row[16], row[17]]);
- let sort_to_original = i16::from_le_bytes([row[18], row[19]]);
- let unpacked_size = u32::from_le_bytes([row[20], row[21], row[22], row[23]]);
- let data_offset_raw = u32::from_le_bytes([row[24], row[25], row[26], row[27]]);
- let packed_size_declared = u32::from_le_bytes([row[28], row[29], row[30], row[31]]);
-
- let method_raw = (flags_signed as u16 as u32) & 0x1E0;
- let method = parse_method(method_raw);
-
- let effective_offset_u64 = u64::from(data_offset_raw)
- .checked_add(u64::from(overlay))
- .ok_or(Error::IntegerOverflow)?;
- let effective_offset =
- usize::try_from(effective_offset_u64).map_err(|_| Error::IntegerOverflow)?;
-
- let packed_size_usize =
- usize::try_from(packed_size_declared).map_err(|_| Error::IntegerOverflow)?;
- let mut packed_size_available = packed_size_usize;
-
- let end = effective_offset_u64
- .checked_add(u64::from(packed_size_declared))
- .ok_or(Error::IntegerOverflow)?;
- let file_len_u64 = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
-
- if end > file_len_u64 {
- if method_raw == 0x100 && end == file_len_u64 + 1 {
- if opts.allow_deflate_eof_plus_one {
- packed_size_available = packed_size_available
- .checked_sub(1)
- .ok_or(Error::IntegerOverflow)?;
- } else {
- return Err(Error::DeflateEofPlusOneQuirkRejected {
- id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?,
- });
- }
- } else {
- return Err(Error::PackedSizePastEof {
- id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?,
- offset: effective_offset_u64,
- packed_size: packed_size_declared,
- file_len: file_len_u64,
- });
- }
- }
-
- let available_end = effective_offset
- .checked_add(packed_size_available)
- .ok_or(Error::IntegerOverflow)?;
- if available_end > bytes.len() {
- return Err(Error::EntryDataOutOfBounds {
- id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?,
- offset: effective_offset_u64,
- size: packed_size_declared,
- file_len: file_len_u64,
- });
- }
-
- let name = decode_name(c_name_bytes(&name_raw));
-
- entries.push(EntryRecord {
- meta: EntryMeta {
- name,
- flags: i32::from(flags_signed),
- method,
- data_offset: effective_offset_u64,
- packed_size: packed_size_declared,
- unpacked_size,
- },
- name_raw,
- service_tail,
- sort_to_original,
- key16: sort_to_original as u16,
- data_offset_raw,
- packed_size_declared,
- packed_size_available,
- effective_offset,
- });
- }
-
- if presorted_flag == 0xABBA {
- let mut seen = vec![false; count];
- for entry in &entries {
- let idx = i32::from(entry.sort_to_original);
- if idx < 0 {
- return Err(Error::CorruptEntryTable(
- "sort_to_original is not a valid permutation index",
- ));
- }
- let idx = usize::try_from(idx).map_err(|_| Error::IntegerOverflow)?;
- if idx >= count {
- return Err(Error::CorruptEntryTable(
- "sort_to_original is not a valid permutation index",
- ));
- }
- if seen[idx] {
- return Err(Error::CorruptEntryTable(
- "sort_to_original is not a permutation",
- ));
- }
- seen[idx] = true;
- }
- if seen.iter().any(|value| !*value) {
- return Err(Error::CorruptEntryTable(
- "sort_to_original is not a permutation",
- ));
- }
- } else {
- let mut sorted: Vec<usize> = (0..count).collect();
- sorted.sort_by(|a, b| {
- cmp_c_string(
- c_name_bytes(&entries[*a].name_raw),
- c_name_bytes(&entries[*b].name_raw),
- )
- });
- for (idx, entry) in entries.iter_mut().enumerate() {
- entry.sort_to_original =
- i16::try_from(sorted[idx]).map_err(|_| Error::IntegerOverflow)?;
- entry.key16 = entry.sort_to_original as u16;
- }
- }
-
- #[cfg(test)]
- let source_size = bytes.len();
-
- Ok(Library {
- bytes,
- entries,
- header,
- ao_trailer: trailer_raw.map(|raw| AoTrailer { raw, overlay }),
- #[cfg(test)]
- table_plain_original,
- #[cfg(test)]
- source_size,
- })
-}
-
-fn parse_ao_trailer(bytes: &[u8], allow: bool) -> Result<(u32, Option<[u8; 6]>)> {
- if !allow || bytes.len() < 6 {
- return Ok((0, None));
- }
-
- if &bytes[bytes.len() - 6..bytes.len() - 4] != b"AO" {
- return Ok((0, None));
- }
-
- let mut trailer = [0u8; 6];
- trailer.copy_from_slice(&bytes[bytes.len() - 6..]);
- let overlay = u32::from_le_bytes([trailer[2], trailer[3], trailer[4], trailer[5]]);
-
- if u64::from(overlay) > u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)? {
- return Err(Error::MediaOverlayOutOfBounds {
- overlay,
- file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
- });
- }
-
- Ok((overlay, Some(trailer)))
-}
-
-pub fn parse_method(raw: u32) -> PackMethod {
- match raw {
- 0x000 => PackMethod::None,
- 0x020 => PackMethod::XorOnly,
- 0x040 => PackMethod::Lzss,
- 0x060 => PackMethod::XorLzss,
- 0x080 => PackMethod::LzssHuffman,
- 0x0A0 => PackMethod::XorLzssHuffman,
- 0x100 => PackMethod::Deflate,
- other => PackMethod::Unknown(other),
- }
-}
-
-fn decode_name(name: &[u8]) -> String {
- name.iter().map(|b| char::from(*b)).collect()
-}
-
-pub fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
- let len = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
- &raw[..len]
-}
-
-pub fn cmp_c_string(a: &[u8], b: &[u8]) -> Ordering {
- let min_len = a.len().min(b.len());
- let mut idx = 0usize;
- while idx < min_len {
- if a[idx] != b[idx] {
- return a[idx].cmp(&b[idx]);
- }
- idx += 1;
- }
- a.len().cmp(&b.len())
-}
diff --git a/crates/rsli/src/tests.rs b/crates/rsli/src/tests.rs
deleted file mode 100644
index ffd611d..0000000
--- a/crates/rsli/src/tests.rs
+++ /dev/null
@@ -1,1338 +0,0 @@
-use super::*;
-use crate::compress::lzh::{LZH_MAX_FREQ, LZH_N_CHAR, LZH_R, LZH_T};
-use crate::compress::xor::xor_stream;
-use common::collect_files_recursive;
-use flate2::write::DeflateEncoder;
-use flate2::write::ZlibEncoder;
-use flate2::Compression;
-use proptest::prelude::*;
-use std::any::Any;
-use std::fs;
-use std::io::Write as _;
-use std::panic::{catch_unwind, AssertUnwindSafe};
-use std::path::PathBuf;
-use std::sync::Arc;
-
-#[derive(Clone, Debug)]
-struct SyntheticRsliEntry {
- name: String,
- method_raw: u16,
- plain: Vec<u8>,
- declared_packed_size: Option<u32>,
-}
-
-#[derive(Clone, Debug)]
-struct RsliBuildOptions {
- seed: u32,
- presorted: bool,
- overlay: u32,
- add_ao_trailer: bool,
-}
-
-impl Default for RsliBuildOptions {
- fn default() -> Self {
- Self {
- seed: 0x1234_5678,
- presorted: true,
- overlay: 0,
- add_ao_trailer: false,
- }
- }
-}
-
-fn rsli_test_files() -> Vec<PathBuf> {
- let root = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("..")
- .join("..")
- .join("testdata")
- .join("rsli");
- let mut files = Vec::new();
- collect_files_recursive(&root, &mut files);
- files.sort();
- files
- .into_iter()
- .filter(|path| {
- fs::read(path)
- .map(|data| data.get(0..4) == Some(b"NL\0\x01"))
- .unwrap_or(false)
- })
- .collect()
-}
-
-fn panic_message(payload: Box<dyn Any + Send>) -> String {
- let any = payload.as_ref();
- if let Some(message) = any.downcast_ref::<String>() {
- return message.clone();
- }
- if let Some(message) = any.downcast_ref::<&str>() {
- return (*message).to_string();
- }
- String::from("panic without message")
-}
-
-fn write_temp_file(prefix: &str, bytes: &[u8]) -> PathBuf {
- let mut path = std::env::temp_dir();
- path.push(format!(
- "{}-{}-{}.bin",
- prefix,
- std::process::id(),
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map(|d| d.as_nanos())
- .unwrap_or(0)
- ));
- fs::write(&path, bytes).expect("failed to write temp archive");
- path
-}
-
-fn deflate_raw(data: &[u8]) -> Vec<u8> {
- let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
- encoder
- .write_all(data)
- .expect("deflate encoder write failed");
- encoder.finish().expect("deflate encoder finish failed")
-}
-
-fn deflate_zlib(data: &[u8]) -> Vec<u8> {
- let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
- encoder.write_all(data).expect("zlib encoder write failed");
- encoder.finish().expect("zlib encoder finish failed")
-}
-
-fn lzss_pack_literals(data: &[u8]) -> Vec<u8> {
- let mut out = Vec::new();
- for chunk in data.chunks(8) {
- let mask = if chunk.len() == 8 {
- 0xFF
- } else {
- (1u16
- .checked_shl(u32::try_from(chunk.len()).expect("chunk len overflow"))
- .expect("shift overflow")
- - 1) as u8
- };
- out.push(mask);
- out.extend_from_slice(chunk);
- }
- out
-}
-
-struct BitWriter {
- bytes: Vec<u8>,
- current: u8,
- mask: u8,
-}
-
-impl BitWriter {
- fn new() -> Self {
- Self {
- bytes: Vec::new(),
- current: 0,
- mask: 0x80,
- }
- }
-
- fn write_bit(&mut self, bit: u8) {
- if bit != 0 {
- self.current |= self.mask;
- }
- self.mask >>= 1;
- if self.mask == 0 {
- self.bytes.push(self.current);
- self.current = 0;
- self.mask = 0x80;
- }
- }
-
- fn finish(mut self) -> Vec<u8> {
- if self.mask != 0x80 {
- self.bytes.push(self.current);
- }
- self.bytes
- }
-}
-
-struct LzhLiteralModel {
- freq: [u16; LZH_T + 1],
- parent: [usize; LZH_T + LZH_N_CHAR],
- son: [usize; LZH_T + 1],
-}
-
-impl LzhLiteralModel {
- fn new() -> Self {
- let mut model = Self {
- freq: [0; LZH_T + 1],
- parent: [0; LZH_T + LZH_N_CHAR],
- son: [0; LZH_T + 1],
- };
- model.start_huff();
- model
- }
-
- fn encode_literal(&mut self, literal: u8, writer: &mut BitWriter) {
- let target = usize::from(literal) + LZH_T;
- let mut path = Vec::new();
- let mut visited = [false; LZH_T + 1];
- let found = self.find_path(self.son[LZH_R], target, &mut path, &mut visited);
- assert!(found, "failed to encode literal {literal}");
- for bit in path {
- writer.write_bit(bit);
- }
-
- self.update(usize::from(literal));
- }
-
- fn find_path(
- &self,
- node: usize,
- target: usize,
- path: &mut Vec<u8>,
- visited: &mut [bool; LZH_T + 1],
- ) -> bool {
- if node == target {
- return true;
- }
- if node >= LZH_T {
- return false;
- }
- if visited[node] {
- return false;
- }
- visited[node] = true;
-
- for bit in [0u8, 1u8] {
- let child = self.son[node + usize::from(bit)];
- path.push(bit);
- if self.find_path(child, target, path, visited) {
- visited[node] = false;
- return true;
- }
- path.pop();
- }
-
- visited[node] = false;
- false
- }
-
- fn start_huff(&mut self) {
- for i in 0..LZH_N_CHAR {
- self.freq[i] = 1;
- self.son[i] = i + LZH_T;
- self.parent[i + LZH_T] = i;
- }
-
- let mut i = 0usize;
- let mut j = LZH_N_CHAR;
- while j <= LZH_R {
- self.freq[j] = self.freq[i].saturating_add(self.freq[i + 1]);
- self.son[j] = i;
- self.parent[i] = j;
- self.parent[i + 1] = j;
- i += 2;
- j += 1;
- }
-
- self.freq[LZH_T] = u16::MAX;
- self.parent[LZH_R] = 0;
- }
-
- fn update(&mut self, c: usize) {
- if self.freq[LZH_R] == LZH_MAX_FREQ {
- self.reconstruct();
- }
-
- let mut current = self.parent[c + LZH_T];
- loop {
- self.freq[current] = self.freq[current].saturating_add(1);
- let freq = self.freq[current];
-
- if current + 1 < self.freq.len() && freq > self.freq[current + 1] {
- let mut swap_idx = current + 1;
- while swap_idx + 1 < self.freq.len() && freq > self.freq[swap_idx + 1] {
- swap_idx += 1;
- }
-
- self.freq.swap(current, swap_idx);
-
- let left = self.son[current];
- let right = self.son[swap_idx];
- self.son[current] = right;
- self.son[swap_idx] = left;
-
- self.parent[left] = swap_idx;
- if left < LZH_T {
- self.parent[left + 1] = swap_idx;
- }
-
- self.parent[right] = current;
- if right < LZH_T {
- self.parent[right + 1] = current;
- }
-
- current = swap_idx;
- }
-
- current = self.parent[current];
- if current == 0 {
- break;
- }
- }
- }
-
- fn reconstruct(&mut self) {
- let mut j = 0usize;
- for i in 0..LZH_T {
- if self.son[i] >= LZH_T {
- self.freq[j] = self.freq[i].div_ceil(2);
- self.son[j] = self.son[i];
- j += 1;
- }
- }
-
- let mut i = 0usize;
- let mut current = LZH_N_CHAR;
- while current < LZH_T {
- let sum = self.freq[i].saturating_add(self.freq[i + 1]);
- self.freq[current] = sum;
-
- let mut insert_at = current;
- while insert_at > 0 && sum < self.freq[insert_at - 1] {
- insert_at -= 1;
- }
-
- for move_idx in (insert_at..current).rev() {
- self.freq[move_idx + 1] = self.freq[move_idx];
- self.son[move_idx + 1] = self.son[move_idx];
- }
-
- self.freq[insert_at] = sum;
- self.son[insert_at] = i;
- i += 2;
- current += 1;
- }
-
- for idx in 0..LZH_T {
- let node = self.son[idx];
- self.parent[node] = idx;
- if node < LZH_T {
- self.parent[node + 1] = idx;
- }
- }
-
- self.freq[LZH_T] = u16::MAX;
- self.parent[LZH_R] = 0;
- }
-}
-
-fn lzh_pack_literals(data: &[u8]) -> Vec<u8> {
- let mut writer = BitWriter::new();
- let mut model = LzhLiteralModel::new();
- for byte in data {
- model.encode_literal(*byte, &mut writer);
- }
- writer.finish()
-}
-
-fn packed_for_method(method_raw: u16, plain: &[u8], key16: u16) -> Vec<u8> {
- match (u32::from(method_raw)) & 0x1E0 {
- 0x000 => plain.to_vec(),
- 0x020 => xor_stream(plain, key16),
- 0x040 => lzss_pack_literals(plain),
- 0x060 => xor_stream(&lzss_pack_literals(plain), key16),
- 0x080 => lzh_pack_literals(plain),
- 0x0A0 => xor_stream(&lzh_pack_literals(plain), key16),
- 0x100 => deflate_raw(plain),
- _ => plain.to_vec(),
- }
-}
-
-fn build_rsli_bytes(entries: &[SyntheticRsliEntry], opts: &RsliBuildOptions) -> Vec<u8> {
- let count = entries.len();
- let mut rows_plain = vec![0u8; count * 32];
- let table_end = 32 + rows_plain.len();
-
- let mut sort_lookup: Vec<usize> = (0..count).collect();
- sort_lookup.sort_by(|a, b| entries[*a].name.as_bytes().cmp(entries[*b].name.as_bytes()));
-
- let mut packed_blobs = Vec::with_capacity(count);
- for index in 0..count {
- let key16 = u16::try_from(sort_lookup[index]).expect("sort index overflow");
- let packed = packed_for_method(entries[index].method_raw, &entries[index].plain, key16);
- packed_blobs.push(packed);
- }
-
- let overlay = usize::try_from(opts.overlay).expect("overlay overflow");
- let mut cursor = table_end + overlay;
- let mut output = vec![0u8; cursor];
-
- let mut data_offsets = Vec::with_capacity(count);
- for (index, packed) in packed_blobs.iter().enumerate() {
- let raw_offset = cursor
- .checked_sub(overlay)
- .expect("overlay larger than cursor");
- data_offsets.push(raw_offset);
-
- let end = cursor.checked_add(packed.len()).expect("cursor overflow");
- if output.len() < end {
- output.resize(end, 0);
- }
- output[cursor..end].copy_from_slice(packed);
- cursor = end;
-
- let base = index * 32;
- let mut name_raw = [0u8; 12];
- let uppercase = entries[index].name.to_ascii_uppercase();
- let name_bytes = uppercase.as_bytes();
- assert!(name_bytes.len() <= 12, "name too long in synthetic fixture");
- name_raw[..name_bytes.len()].copy_from_slice(name_bytes);
-
- rows_plain[base..base + 12].copy_from_slice(&name_raw);
-
- let sort_field: i16 = if opts.presorted {
- i16::try_from(sort_lookup[index]).expect("sort field overflow")
- } else {
- 0
- };
-
- let packed_size = entries[index]
- .declared_packed_size
- .unwrap_or_else(|| u32::try_from(packed.len()).expect("packed size overflow"));
-
- rows_plain[base + 16..base + 18].copy_from_slice(&entries[index].method_raw.to_le_bytes());
- rows_plain[base + 18..base + 20].copy_from_slice(&sort_field.to_le_bytes());
- rows_plain[base + 20..base + 24].copy_from_slice(
- &u32::try_from(entries[index].plain.len())
- .expect("unpacked size overflow")
- .to_le_bytes(),
- );
- rows_plain[base + 24..base + 28].copy_from_slice(
- &u32::try_from(data_offsets[index])
- .expect("data offset overflow")
- .to_le_bytes(),
- );
- rows_plain[base + 28..base + 32].copy_from_slice(&packed_size.to_le_bytes());
- }
-
- if output.len() < table_end {
- output.resize(table_end, 0);
- }
-
- output[0..2].copy_from_slice(b"NL");
- output[2] = 0;
- output[3] = 1;
- output[4..6].copy_from_slice(
- &i16::try_from(count)
- .expect("entry count overflow")
- .to_le_bytes(),
- );
-
- let presorted_flag = if opts.presorted { 0xABBA_u16 } else { 0_u16 };
- output[14..16].copy_from_slice(&presorted_flag.to_le_bytes());
- output[20..24].copy_from_slice(&opts.seed.to_le_bytes());
-
- let encrypted_table = xor_stream(&rows_plain, (opts.seed & 0xFFFF) as u16);
- output[32..table_end].copy_from_slice(&encrypted_table);
-
- if opts.add_ao_trailer {
- output.extend_from_slice(b"AO");
- output.extend_from_slice(&opts.overlay.to_le_bytes());
- }
-
- output
-}
-
-fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
- let slice = bytes
- .get(offset..offset + 4)
- .expect("u32 read out of bounds in test");
- let arr: [u8; 4] = slice.try_into().expect("u32 conversion failed in test");
- u32::from_le_bytes(arr)
-}
-
-#[test]
-fn rsli_read_unpack_and_repack_all_files() {
- let files = rsli_test_files();
- if files.is_empty() {
- eprintln!(
- "skipping rsli_read_unpack_and_repack_all_files: no RsLi archives in testdata/rsli"
- );
- return;
- }
-
- let checked = files.len();
- let mut success = 0usize;
- let mut failures = Vec::new();
-
- for path in files {
- let display_path = path.display().to_string();
- let result = catch_unwind(AssertUnwindSafe(|| {
- let original = fs::read(&path).expect("failed to read archive");
- let library = Library::open_path(&path)
- .unwrap_or_else(|err| panic!("failed to open {}: {err}", path.display()));
-
- let count = library.entry_count();
- assert_eq!(
- count,
- library.entries().count(),
- "entry count mismatch: {}",
- path.display()
- );
-
- for idx in 0..count {
- let id = EntryId(idx as u32);
- let meta_ref = library
- .get(id)
- .unwrap_or_else(|| panic!("missing entry #{idx} in {}", path.display()));
-
- let loaded = library.load(id).unwrap_or_else(|err| {
- panic!("load failed for {} entry #{idx}: {err}", path.display())
- });
-
- let packed = library.load_packed(id).unwrap_or_else(|err| {
- panic!(
- "load_packed failed for {} entry #{idx}: {err}",
- path.display()
- )
- });
- let unpacked = library.unpack(&packed).unwrap_or_else(|err| {
- panic!("unpack failed for {} entry #{idx}: {err}", path.display())
- });
- assert_eq!(
- loaded,
- unpacked,
- "load != unpack in {} entry #{idx}",
- path.display()
- );
-
- let mut out = Vec::new();
- let written = library.load_into(id, &mut out).unwrap_or_else(|err| {
- panic!(
- "load_into failed for {} entry #{idx}: {err}",
- path.display()
- )
- });
- assert_eq!(
- written,
- loaded.len(),
- "load_into size mismatch in {} entry #{idx}",
- path.display()
- );
- assert_eq!(
- out,
- loaded,
- "load_into payload mismatch in {} entry #{idx}",
- path.display()
- );
-
- let fast = library.load_fast(id).unwrap_or_else(|err| {
- panic!(
- "load_fast failed for {} entry #{idx}: {err}",
- path.display()
- )
- });
- assert_eq!(
- fast.as_slice(),
- loaded.as_slice(),
- "load_fast mismatch in {} entry #{idx}",
- path.display()
- );
-
- let found = library.find(&meta_ref.meta.name).unwrap_or_else(|| {
- panic!(
- "find failed for '{}' in {}",
- meta_ref.meta.name,
- path.display()
- )
- });
- let found_meta = library.get(found).expect("find returned invalid entry id");
- assert_eq!(
- found_meta.meta.name,
- meta_ref.meta.name,
- "find returned a different entry in {}",
- path.display()
- );
- }
-
- let rebuilt = library
- .rebuild_from_parsed_metadata()
- .unwrap_or_else(|err| panic!("rebuild failed for {}: {err}", path.display()));
- assert_eq!(
- rebuilt,
- original,
- "byte-to-byte roundtrip mismatch for {}",
- path.display()
- );
- }));
-
- match result {
- Ok(()) => success += 1,
- Err(payload) => failures.push(format!("{}: {}", display_path, panic_message(payload))),
- }
- }
-
- let failed = failures.len();
- eprintln!(
- "RsLi summary: checked={}, success={}, failed={}",
- checked, success, failed
- );
- if !failures.is_empty() {
- panic!(
- "RsLi validation failed.\nsummary: checked={}, success={}, failed={}\n{}",
- checked,
- success,
- failed,
- failures.join("\n")
- );
- }
-}
-
-#[test]
-fn rsli_docs_structural_invariants_all_files() {
- let files = rsli_test_files();
- if files.is_empty() {
- eprintln!(
- "skipping rsli_docs_structural_invariants_all_files: no RsLi archives in testdata/rsli"
- );
- return;
- }
-
- let mut deflate_eof_plus_one_quirks = Vec::new();
-
- for path in files {
- let bytes = fs::read(&path).unwrap_or_else(|err| {
- panic!("failed to read {}: {err}", path.display());
- });
-
- assert!(
- bytes.len() >= 32,
- "RsLi header too short in {}",
- path.display()
- );
- assert_eq!(&bytes[0..2], b"NL", "bad magic in {}", path.display());
- assert_eq!(
- bytes[2],
- 0,
- "reserved header byte must be zero in {}",
- path.display()
- );
- assert_eq!(bytes[3], 1, "bad version in {}", path.display());
-
- let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]);
- assert!(
- entry_count >= 0,
- "negative entry_count={} in {}",
- entry_count,
- path.display()
- );
- let count = usize::try_from(entry_count).expect("entry_count overflow");
- let table_size = count.checked_mul(32).expect("table_size overflow");
- let table_end = 32usize.checked_add(table_size).expect("table_end overflow");
- assert!(
- table_end <= bytes.len(),
- "table out of bounds in {}",
- path.display()
- );
-
- let seed = read_u32_le(&bytes, 20);
- let table_plain = xor_stream(&bytes[32..table_end], (seed & 0xFFFF) as u16);
- assert_eq!(
- table_plain.len(),
- table_size,
- "decrypted table size mismatch in {}",
- path.display()
- );
-
- let mut overlay = 0u32;
- if bytes.len() >= 6 && &bytes[bytes.len() - 6..bytes.len() - 4] == b"AO" {
- overlay = read_u32_le(&bytes, bytes.len() - 4);
- assert!(
- usize::try_from(overlay).expect("overlay overflow") <= bytes.len(),
- "overlay beyond EOF in {}",
- path.display()
- );
- }
-
- let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
- let mut sort_values = Vec::with_capacity(count);
-
- for index in 0..count {
- let base = index * 32;
- let row = &table_plain[base..base + 32];
- let flags_signed = i16::from_le_bytes([row[16], row[17]]);
- let sort_to_original = i16::from_le_bytes([row[18], row[19]]);
- let data_offset = u64::from(read_u32_le(row, 24));
- let packed_size = u64::from(read_u32_le(row, 28));
-
- let method = (flags_signed as u16 as u32) & 0x1E0;
- let effective_offset = data_offset + u64::from(overlay);
- let end = effective_offset + packed_size;
- let file_len = u64::try_from(bytes.len()).expect("file size overflow");
-
- if end > file_len {
- assert!(
- method == 0x100 && end == file_len + 1,
- "packed range out of bounds in {} entry #{index}: method=0x{method:03X}, range=[{effective_offset}, {end}), file={file_len}",
- path.display()
- );
- deflate_eof_plus_one_quirks.push((path.display().to_string(), index));
- }
-
- sort_values.push(sort_to_original);
- }
-
- if presorted_flag == 0xABBA {
- let mut sorted = sort_values;
- sorted.sort_unstable();
- let expected: Vec<i16> = (0..count)
- .map(|idx| i16::try_from(idx).expect("too many entries for i16"))
- .collect();
- assert_eq!(
- sorted,
- expected,
- "sort_to_original is not a permutation in {}",
- path.display()
- );
- }
- }
-
- if !deflate_eof_plus_one_quirks.is_empty() {
- assert!(
- deflate_eof_plus_one_quirks
- .iter()
- .all(|(file, idx)| file.ends_with("sprites.lib") && *idx == 23),
- "unexpected deflate EOF+1 quirks: {:?}",
- deflate_eof_plus_one_quirks
- );
- }
-}
-
-#[test]
-fn rsli_synthetic_all_methods_roundtrip() {
- let entries = vec![
- SyntheticRsliEntry {
- name: "M_NONE".to_string(),
- method_raw: 0x000,
- plain: b"plain-data".to_vec(),
- declared_packed_size: None,
- },
- SyntheticRsliEntry {
- name: "M_XOR".to_string(),
- method_raw: 0x020,
- plain: b"xor-only".to_vec(),
- declared_packed_size: None,
- },
- SyntheticRsliEntry {
- name: "M_LZSS".to_string(),
- method_raw: 0x040,
- plain: b"lzss literals payload".to_vec(),
- declared_packed_size: None,
- },
- SyntheticRsliEntry {
- name: "M_XLZS".to_string(),
- method_raw: 0x060,
- plain: b"xor lzss payload".to_vec(),
- declared_packed_size: None,
- },
- SyntheticRsliEntry {
- name: "M_LZHU".to_string(),
- method_raw: 0x080,
- plain: b"huffman literals payload".to_vec(),
- declared_packed_size: None,
- },
- SyntheticRsliEntry {
- name: "M_XLZH".to_string(),
- method_raw: 0x0A0,
- plain: b"xor huffman payload".to_vec(),
- declared_packed_size: None,
- },
- SyntheticRsliEntry {
- name: "M_DEFL".to_string(),
- method_raw: 0x100,
- plain: b"deflate payload with repetition repetition repetition".to_vec(),
- declared_packed_size: None,
- },
- ];
-
- let bytes = build_rsli_bytes(
- &entries,
- &RsliBuildOptions {
- seed: 0xA1B2_C3D4,
- presorted: false,
- overlay: 0,
- add_ao_trailer: false,
- },
- );
- let path = write_temp_file("rsli-all-methods", &bytes);
-
- let library = Library::open_path(&path).expect("open synthetic rsli failed");
- assert_eq!(library.entry_count(), entries.len());
-
- for entry in &entries {
- let id = library
- .find(&entry.name)
- .unwrap_or_else(|| panic!("find failed for {}", entry.name));
- let loaded = library
- .load(id)
- .unwrap_or_else(|err| panic!("load failed for {}: {err}", entry.name));
- assert_eq!(
- loaded, entry.plain,
- "decoded payload mismatch for {}",
- entry.name
- );
-
- let packed = library
- .load_packed(id)
- .unwrap_or_else(|err| panic!("load_packed failed for {}: {err}", entry.name));
- let unpacked = library
- .unpack(&packed)
- .unwrap_or_else(|err| panic!("unpack failed for {}: {err}", entry.name));
- assert_eq!(unpacked, entry.plain, "unpack mismatch for {}", entry.name);
- }
-
- let _ = fs::remove_file(&path);
-}
-
-#[test]
-fn rsli_empty_archive_roundtrip() {
- let bytes = build_rsli_bytes(&[], &RsliBuildOptions::default());
- let path = write_temp_file("rsli-empty", &bytes);
-
- let library = Library::open_path(&path).expect("open empty rsli failed");
- assert_eq!(library.entry_count(), 0);
- assert_eq!(library.find("ANYTHING"), None);
-
- let rebuilt = library
- .rebuild_from_parsed_metadata()
- .expect("rebuild empty rsli failed");
- assert_eq!(rebuilt, bytes, "empty rsli roundtrip mismatch");
-
- let _ = fs::remove_file(&path);
-}
-
-#[test]
-fn rsli_max_name_length_without_nul_roundtrip() {
- let max_name = "NAME12345678";
- assert_eq!(max_name.len(), 12);
-
- let bytes = build_rsli_bytes(
- &[SyntheticRsliEntry {
- name: max_name.to_string(),
- method_raw: 0x000,
- plain: b"payload".to_vec(),
- declared_packed_size: None,
- }],
- &RsliBuildOptions::default(),
- );
- let path = write_temp_file("rsli-max-name", &bytes);
-
- let library = Library::open_path(&path).expect("open max-name rsli failed");
- assert_eq!(library.entry_count(), 1);
- assert_eq!(library.find(max_name), Some(EntryId(0)));
- assert_eq!(
- library.find(&max_name.to_ascii_lowercase()),
- Some(EntryId(0))
- );
- assert_eq!(
- library.entries[0]
- .name_raw
- .iter()
- .position(|byte| *byte == 0),
- None,
- "name_raw must occupy full 12 bytes without NUL"
- );
-
- let entry = library.get(EntryId(0)).expect("missing entry");
- assert_eq!(entry.meta.name, max_name);
- assert_eq!(
- library.load(EntryId(0)).expect("load failed"),
- b"payload",
- "payload mismatch"
- );
-
- let _ = fs::remove_file(&path);
-}
-
-#[test]
-fn rsli_lzss_large_payload_over_4k_roundtrip() {
- let plain: Vec<u8> = (0..10_000u32).map(|v| (v % 251) as u8).collect();
- let entries = vec![
- SyntheticRsliEntry {
- name: "LZSS4K".to_string(),
- method_raw: 0x040,
- plain: plain.clone(),
- declared_packed_size: None,
- },
- SyntheticRsliEntry {
- name: "XLZS4K".to_string(),
- method_raw: 0x060,
- plain: plain.clone(),
- declared_packed_size: None,
- },
- ];
- let bytes = build_rsli_bytes(&entries, &RsliBuildOptions::default());
- let path = write_temp_file("rsli-lzss-4k", &bytes);
-
- let library = Library::open_path(&path).expect("open large-lzss rsli failed");
- assert_eq!(library.entry_count(), entries.len());
-
- for entry in &entries {
- let id = library
- .find(&entry.name)
- .unwrap_or_else(|| panic!("find failed for {}", entry.name));
- let loaded = library
- .load(id)
- .unwrap_or_else(|err| panic!("load failed for {}: {err}", entry.name));
- assert_eq!(loaded, plain, "payload mismatch for {}", entry.name);
- }
-
- let _ = fs::remove_file(&path);
-}
-
-#[test]
-fn rsli_find_falls_back_when_sort_table_corrupted_in_memory() {
- let entries = vec![
- SyntheticRsliEntry {
- name: "AAA".to_string(),
- method_raw: 0x000,
- plain: b"a".to_vec(),
- declared_packed_size: None,
- },
- SyntheticRsliEntry {
- name: "BBB".to_string(),
- method_raw: 0x000,
- plain: b"b".to_vec(),
- declared_packed_size: None,
- },
- SyntheticRsliEntry {
- name: "CCC".to_string(),
- method_raw: 0x000,
- plain: b"c".to_vec(),
- declared_packed_size: None,
- },
- ];
- let bytes = build_rsli_bytes(
- &entries,
- &RsliBuildOptions {
- presorted: true,
- ..RsliBuildOptions::default()
- },
- );
- let path = write_temp_file("rsli-find-fallback", &bytes);
-
- let mut library = Library::open_path(&path).expect("open synthetic rsli failed");
- library.entries[1].sort_to_original = -1;
-
- assert_eq!(library.find("AAA"), Some(EntryId(0)));
- assert_eq!(library.find("bbb"), Some(EntryId(1)));
- assert_eq!(library.find("CcC"), Some(EntryId(2)));
- assert_eq!(library.find("missing"), None);
-
- let _ = fs::remove_file(&path);
-}
-
-#[test]
-fn rsli_deflate_method_rejects_zlib_wrapped_stream() {
- let plain = b"payload".to_vec();
- let zlib_payload = deflate_zlib(&plain);
- let entries = vec![SyntheticRsliEntry {
- name: "ZLIB".to_string(),
- method_raw: 0x100,
- plain,
- declared_packed_size: Some(
- u32::try_from(zlib_payload.len()).expect("zlib payload size overflow"),
- ),
- }];
- let mut bytes = build_rsli_bytes(
- &entries,
- &RsliBuildOptions {
- presorted: true,
- ..RsliBuildOptions::default()
- },
- );
-
- let table_end = 32 + entries.len() * 32;
- let data_offset = table_end;
- let data_end = data_offset + zlib_payload.len();
- if bytes.len() < data_end {
- bytes.resize(data_end, 0);
- }
- bytes[data_offset..data_end].copy_from_slice(&zlib_payload);
-
- let path = write_temp_file("rsli-zlib-reject", &bytes);
- let library = Library::open_path(&path).expect("open zlib-wrapped rsli failed");
- match library.load(EntryId(0)) {
- Err(Error::DecompressionFailed(reason)) => {
- assert_eq!(reason, "deflate");
- }
- other => panic!("expected deflate decompression error, got {other:?}"),
- }
- let _ = fs::remove_file(&path);
-}
-
-#[test]
-fn rsli_lzss_huffman_reports_unexpected_eof() {
- let entries = vec![SyntheticRsliEntry {
- name: "TRUNC".to_string(),
- method_raw: 0x080,
- plain: b"this payload is long enough".to_vec(),
- declared_packed_size: None,
- }];
- let mut bytes = build_rsli_bytes(
- &entries,
- &RsliBuildOptions {
- presorted: true,
- ..RsliBuildOptions::default()
- },
- );
-
- let seed = read_u32_le(&bytes, 20);
- let mut table_plain = xor_stream(&bytes[32..64], (seed & 0xFFFF) as u16);
- let original_packed_size = u32::from_le_bytes([
- table_plain[28],
- table_plain[29],
- table_plain[30],
- table_plain[31],
- ]);
- assert!(
- original_packed_size > 4,
- "packed payload too small for truncation"
- );
- let truncated_size = original_packed_size - 3;
- table_plain[28..32].copy_from_slice(&truncated_size.to_le_bytes());
- let encrypted_table = xor_stream(&table_plain, (seed & 0xFFFF) as u16);
- bytes[32..64].copy_from_slice(&encrypted_table);
-
- let path = write_temp_file("rsli-lzh-truncated", &bytes);
- let library = Library::open_path(&path).expect("open truncated lzh rsli failed");
- match library.load(EntryId(0)) {
- Err(Error::DecompressionFailed(reason)) => {
- assert_eq!(reason, "lzss-huffman: unexpected EOF");
- }
- other => panic!("expected lzss-huffman EOF error, got {other:?}"),
- }
- let _ = fs::remove_file(&path);
-}
-
-#[test]
-fn rsli_presorted_flag_requires_permutation() {
- let entries = vec![
- SyntheticRsliEntry {
- name: "AAA".to_string(),
- method_raw: 0x000,
- plain: b"a".to_vec(),
- declared_packed_size: None,
- },
- SyntheticRsliEntry {
- name: "BBB".to_string(),
- method_raw: 0x000,
- plain: b"b".to_vec(),
- declared_packed_size: None,
- },
- ];
- let mut bytes = build_rsli_bytes(
- &entries,
- &RsliBuildOptions {
- presorted: true,
- ..RsliBuildOptions::default()
- },
- );
-
- let seed = read_u32_le(&bytes, 20);
- let mut table_plain = xor_stream(&bytes[32..32 + entries.len() * 32], (seed & 0xFFFF) as u16);
-
- // Corrupt sort_to_original: duplicate index 0, so the table is not a permutation.
- table_plain[18..20].copy_from_slice(&0i16.to_le_bytes());
- table_plain[50..52].copy_from_slice(&0i16.to_le_bytes());
-
- let table_encrypted = xor_stream(&table_plain, (seed & 0xFFFF) as u16);
- bytes[32..32 + table_encrypted.len()].copy_from_slice(&table_encrypted);
-
- let path = write_temp_file("rsli-bad-presorted-perm", &bytes);
- match Library::open_path(&path) {
- Err(Error::CorruptEntryTable(message)) => {
- assert!(
- message.contains("permutation"),
- "unexpected error message: {message}"
- );
- }
- other => panic!("expected CorruptEntryTable for invalid permutation, got {other:?}"),
- }
- let _ = fs::remove_file(&path);
-}
-
-#[test]
-fn rsli_load_reports_correct_entry_id_on_range_failure() {
- let entries = vec![
- SyntheticRsliEntry {
- name: "ONE".to_string(),
- method_raw: 0x000,
- plain: b"one".to_vec(),
- declared_packed_size: None,
- },
- SyntheticRsliEntry {
- name: "TWO".to_string(),
- method_raw: 0x000,
- plain: b"two".to_vec(),
- declared_packed_size: None,
- },
- ];
- let bytes = build_rsli_bytes(
- &entries,
- &RsliBuildOptions {
- presorted: true,
- ..RsliBuildOptions::default()
- },
- );
- let path = write_temp_file("rsli-entry-id-error", &bytes);
-
- let mut library = Library::open_path(&path).expect("open synthetic rsli failed");
- library.entries[1].packed_size_available = usize::MAX;
-
- match library.load(EntryId(1)) {
- Err(Error::IntegerOverflow) => {}
- other => panic!("expected IntegerOverflow, got {other:?}"),
- }
-
- library.entries[1].packed_size_available = library.bytes.len();
- match library.load(EntryId(1)) {
- Err(Error::EntryDataOutOfBounds { id, .. }) => assert_eq!(id, 1),
- other => panic!("expected EntryDataOutOfBounds with id=1, got {other:?}"),
- }
-
- let _ = fs::remove_file(&path);
-}
-
-#[test]
-fn rsli_xorlzss_huffman_on_the_fly_roundtrip() {
- let plain: Vec<u8> = (0..512u16).map(|i| b'A' + (i % 26) as u8).collect();
- let entries = vec![SyntheticRsliEntry {
- name: "XLZH_ONFLY".to_string(),
- method_raw: 0x0A0,
- plain: plain.clone(),
- declared_packed_size: None,
- }];
-
- let bytes = build_rsli_bytes(
- &entries,
- &RsliBuildOptions {
- seed: 0x0BAD_C0DE,
- presorted: true,
- overlay: 0,
- add_ao_trailer: false,
- },
- );
- let path = write_temp_file("rsli-xorlzh-onfly", &bytes);
-
- let library = Library::open_path(&path).expect("open synthetic XLZH archive failed");
- let id = library
- .find("XLZH_ONFLY")
- .expect("find XLZH_ONFLY entry failed");
-
- let loaded = library.load(id).expect("load XLZH_ONFLY failed");
- assert_eq!(loaded, plain);
-
- let packed = library
- .load_packed(id)
- .expect("load_packed XLZH_ONFLY failed");
- let unpacked = library.unpack(&packed).expect("unpack XLZH_ONFLY failed");
- assert_eq!(unpacked, loaded);
-
- let _ = fs::remove_file(&path);
-}
-
-#[test]
-fn rsli_synthetic_overlay_and_ao_trailer() {
- let entries = vec![SyntheticRsliEntry {
- name: "OVERLAY".to_string(),
- method_raw: 0x040,
- plain: b"overlay-data".to_vec(),
- declared_packed_size: None,
- }];
-
- let bytes = build_rsli_bytes(
- &entries,
- &RsliBuildOptions {
- seed: 0x4433_2211,
- presorted: true,
- overlay: 128,
- add_ao_trailer: true,
- },
- );
- let path = write_temp_file("rsli-overlay", &bytes);
-
- let library = Library::open_path_with(
- &path,
- OpenOptions {
- allow_ao_trailer: true,
- allow_deflate_eof_plus_one: true,
- },
- )
- .expect("open with AO trailer enabled failed");
-
- let id = library.find("OVERLAY").expect("find overlay entry failed");
- let payload = library.load(id).expect("load overlay entry failed");
- assert_eq!(payload, b"overlay-data");
-
- let _ = fs::remove_file(&path);
-}
-
-#[test]
-fn rsli_deflate_eof_plus_one_quirk() {
- let plain = b"quirk deflate payload".to_vec();
- let packed = deflate_raw(&plain);
- let declared = u32::try_from(packed.len() + 1).expect("declared size overflow");
-
- let entries = vec![SyntheticRsliEntry {
- name: "QUIRK".to_string(),
- method_raw: 0x100,
- plain,
- declared_packed_size: Some(declared),
- }];
- let bytes = build_rsli_bytes(&entries, &RsliBuildOptions::default());
- let path = write_temp_file("rsli-deflate-quirk", &bytes);
-
- let lib_ok = Library::open_path_with(
- &path,
- OpenOptions {
- allow_ao_trailer: true,
- allow_deflate_eof_plus_one: true,
- },
- )
- .expect("open with EOF+1 quirk enabled failed");
- let loaded = lib_ok
- .load(lib_ok.find("QUIRK").expect("find quirk entry failed"))
- .expect("load quirk entry failed");
- assert_eq!(loaded, b"quirk deflate payload");
-
- match Library::open_path_with(
- &path,
- OpenOptions {
- allow_ao_trailer: true,
- allow_deflate_eof_plus_one: false,
- },
- ) {
- Err(Error::DeflateEofPlusOneQuirkRejected { id }) => assert_eq!(id, 0),
- other => panic!("expected DeflateEofPlusOneQuirkRejected, got {other:?}"),
- }
-
- let _ = fs::remove_file(&path);
-}
-
-#[test]
-fn rsli_validation_error_cases() {
- let valid = build_rsli_bytes(
- &[SyntheticRsliEntry {
- name: "BASE".to_string(),
- method_raw: 0x000,
- plain: b"abc".to_vec(),
- declared_packed_size: None,
- }],
- &RsliBuildOptions::default(),
- );
-
- let mut bad_magic = valid.clone();
- bad_magic[0..2].copy_from_slice(b"XX");
- let path = write_temp_file("rsli-bad-magic", &bad_magic);
- match Library::open_path(&path) {
- Err(Error::InvalidMagic { .. }) => {}
- other => panic!("expected InvalidMagic, got {other:?}"),
- }
- let _ = fs::remove_file(&path);
-
- let mut bad_version = valid.clone();
- bad_version[3] = 2;
- let path = write_temp_file("rsli-bad-version", &bad_version);
- match Library::open_path(&path) {
- Err(Error::UnsupportedVersion { got }) => assert_eq!(got, 2),
- other => panic!("expected UnsupportedVersion, got {other:?}"),
- }
- let _ = fs::remove_file(&path);
-
- let mut bad_count = valid.clone();
- bad_count[4..6].copy_from_slice(&(-1_i16).to_le_bytes());
- let path = write_temp_file("rsli-bad-count", &bad_count);
- match Library::open_path(&path) {
- Err(Error::InvalidEntryCount { got }) => assert_eq!(got, -1),
- other => panic!("expected InvalidEntryCount, got {other:?}"),
- }
- let _ = fs::remove_file(&path);
-
- let mut bad_table = valid.clone();
- bad_table[4..6].copy_from_slice(&100_i16.to_le_bytes());
- let path = write_temp_file("rsli-bad-table", &bad_table);
- match Library::open_path(&path) {
- Err(Error::EntryTableOutOfBounds { .. }) => {}
- other => panic!("expected EntryTableOutOfBounds, got {other:?}"),
- }
- let _ = fs::remove_file(&path);
-
- let mut unknown_method = build_rsli_bytes(
- &[SyntheticRsliEntry {
- name: "UNK".to_string(),
- method_raw: 0x120,
- plain: b"x".to_vec(),
- declared_packed_size: None,
- }],
- &RsliBuildOptions::default(),
- );
- // Force truly unknown method by writing 0x1C0 mask bits.
- let row = 32;
- unknown_method[row + 16..row + 18].copy_from_slice(&(0x1C0_u16).to_le_bytes());
- // Re-encrypt table with the same seed.
- let seed = u32::from_le_bytes([
- unknown_method[20],
- unknown_method[21],
- unknown_method[22],
- unknown_method[23],
- ]);
- let mut plain_row = vec![0u8; 32];
- plain_row.copy_from_slice(&unknown_method[32..64]);
- plain_row = xor_stream(&plain_row, (seed & 0xFFFF) as u16);
- plain_row[16..18].copy_from_slice(&(0x1C0_u16).to_le_bytes());
- let encrypted_row = xor_stream(&plain_row, (seed & 0xFFFF) as u16);
- unknown_method[32..64].copy_from_slice(&encrypted_row);
-
- let path = write_temp_file("rsli-unknown-method", &unknown_method);
- let lib = Library::open_path(&path).expect("open archive with unknown method failed");
- match lib.load(EntryId(0)) {
- Err(Error::UnsupportedMethod { raw }) => assert_eq!(raw, 0x1C0),
- other => panic!("expected UnsupportedMethod, got {other:?}"),
- }
- let _ = fs::remove_file(&path);
-
- let mut bad_packed = valid.clone();
- bad_packed[32 + 28..32 + 32].copy_from_slice(&0xFFFF_FFF0_u32.to_le_bytes());
- let path = write_temp_file("rsli-bad-packed", &bad_packed);
- match Library::open_path(&path) {
- Err(Error::PackedSizePastEof { .. }) => {}
- other => panic!("expected PackedSizePastEof, got {other:?}"),
- }
- let _ = fs::remove_file(&path);
-
- let mut with_bad_overlay = valid;
- with_bad_overlay.extend_from_slice(b"AO");
- with_bad_overlay.extend_from_slice(&0xFFFF_FFFF_u32.to_le_bytes());
- let path = write_temp_file("rsli-bad-overlay", &with_bad_overlay);
- match Library::open_path_with(
- &path,
- OpenOptions {
- allow_ao_trailer: true,
- allow_deflate_eof_plus_one: true,
- },
- ) {
- Err(Error::MediaOverlayOutOfBounds { .. }) => {}
- other => panic!("expected MediaOverlayOutOfBounds, got {other:?}"),
- }
- let _ = fs::remove_file(&path);
-}
-
-proptest! {
- #![proptest_config(ProptestConfig::with_cases(64))]
-
- #[test]
- fn parse_library_is_panic_free_on_random_bytes(data in proptest::collection::vec(any::<u8>(), 0..4096)) {
- let _ = crate::parse::parse_library(
- Arc::from(data.into_boxed_slice()),
- OpenOptions::default(),
- );
- }
-}
diff --git a/crates/terrain-core/Cargo.toml b/crates/terrain-core/Cargo.toml
deleted file mode 100644
index fd4380f..0000000
--- a/crates/terrain-core/Cargo.toml
+++ /dev/null
@@ -1,10 +0,0 @@
-[package]
-name = "terrain-core"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
-nres = { path = "../nres" }
-
-[dev-dependencies]
-common = { path = "../common" }
diff --git a/crates/terrain-core/src/lib.rs b/crates/terrain-core/src/lib.rs
deleted file mode 100644
index 36a3e42..0000000
--- a/crates/terrain-core/src/lib.rs
+++ /dev/null
@@ -1,281 +0,0 @@
-use nres::Archive;
-use std::fmt;
-use std::path::Path;
-
-pub const TERRAIN_UV_SCALE: f32 = 1024.0;
-
-pub type Result<T> = core::result::Result<T, Error>;
-
-#[derive(Debug)]
-pub enum Error {
- Nres(nres::error::Error),
- MissingChunk(&'static str),
- InvalidChunkSize {
- label: &'static str,
- size: usize,
- stride: usize,
- },
- VertexCountOverflow {
- count: usize,
- },
-}
-
-impl fmt::Display for Error {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::Nres(err) => write!(f, "{err}"),
- Self::MissingChunk(label) => write!(f, "missing required terrain chunk: {label}"),
- Self::InvalidChunkSize {
- label,
- size,
- stride,
- } => write!(
- f,
- "invalid chunk size for {label}: {size} (must be divisible by {stride})"
- ),
- Self::VertexCountOverflow { count } => {
- write!(f, "terrain vertex count {count} exceeds u16 range")
- }
- }
- }
-}
-
-impl std::error::Error for Error {
- fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
- match self {
- Self::Nres(err) => Some(err),
- _ => None,
- }
- }
-}
-
-impl From<nres::error::Error> for Error {
- fn from(value: nres::error::Error) -> Self {
- Self::Nres(value)
- }
-}
-
-#[derive(Clone, Debug)]
-pub struct TerrainMesh {
- pub positions: Vec<[f32; 3]>,
- pub uv0: Vec<[f32; 2]>,
- pub faces: Vec<TerrainFace>,
-}
-
-#[derive(Copy, Clone, Debug)]
-pub struct TerrainFace {
- pub indices: [u16; 3],
- pub flags: u32,
- pub material_tag: u16,
- pub aux_tag: u16,
-}
-
-#[derive(Clone, Debug)]
-pub struct TerrainRenderMesh {
- pub vertices: Vec<TerrainRenderVertex>,
- pub indices: Vec<u16>,
- pub face_count_raw: usize,
- pub face_count_kept: usize,
- pub face_count_dropped_invalid: usize,
-}
-
-#[derive(Copy, Clone, Debug)]
-pub struct TerrainRenderVertex {
- pub position: [f32; 3],
- pub uv0: [f32; 2],
-}
-
-pub fn load_land_mesh(path: impl AsRef<Path>) -> Result<TerrainMesh> {
- let archive = Archive::open_path(path.as_ref())?;
-
- let positions_entry = archive
- .entries()
- .find(|entry| entry.meta.kind == 3)
- .ok_or(Error::MissingChunk("type=3 (positions)"))?;
- let uv_entry = archive.entries().find(|entry| entry.meta.kind == 5);
- let faces_entry = archive
- .entries()
- .find(|entry| entry.meta.kind == 21)
- .ok_or(Error::MissingChunk("type=21 (faces)"))?;
-
- let positions_payload = archive.read(positions_entry.id)?.into_owned();
- if positions_payload.len() % 12 != 0 {
- return Err(Error::InvalidChunkSize {
- label: "type=3 (positions)",
- size: positions_payload.len(),
- stride: 12,
- });
- }
-
- let mut positions = Vec::with_capacity(positions_payload.len() / 12);
- for chunk in positions_payload.chunks_exact(12) {
- let x = f32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4]));
- let y = f32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0; 4]));
- let z = f32::from_le_bytes(chunk[8..12].try_into().unwrap_or([0; 4]));
- positions.push([x, y, z]);
- }
-
- let mut uv0 = vec![[0.0f32, 0.0f32]; positions.len()];
- if let Some(uv_entry) = uv_entry {
- let uv_payload = archive.read(uv_entry.id)?.into_owned();
- if uv_payload.len() % 4 != 0 {
- return Err(Error::InvalidChunkSize {
- label: "type=5 (uv)",
- size: uv_payload.len(),
- stride: 4,
- });
- }
- let uv_count = uv_payload.len() / 4;
- for idx in 0..uv_count.min(uv0.len()) {
- let off = idx * 4;
- let u = i16::from_le_bytes([uv_payload[off], uv_payload[off + 1]]) as f32;
- let v = i16::from_le_bytes([uv_payload[off + 2], uv_payload[off + 3]]) as f32;
- uv0[idx] = [u / TERRAIN_UV_SCALE, v / TERRAIN_UV_SCALE];
- }
- }
-
- let face_payload = archive.read(faces_entry.id)?.into_owned();
- if face_payload.len() % 28 != 0 {
- return Err(Error::InvalidChunkSize {
- label: "type=21 (faces)",
- size: face_payload.len(),
- stride: 28,
- });
- }
-
- let mut faces = Vec::with_capacity(face_payload.len() / 28);
- for chunk in face_payload.chunks_exact(28) {
- let flags = u32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4]));
- let material_tag = u16::from_le_bytes(chunk[4..6].try_into().unwrap_or([0; 2]));
- let aux_tag = u16::from_le_bytes(chunk[6..8].try_into().unwrap_or([0; 2]));
- let i0 = u16::from_le_bytes(chunk[8..10].try_into().unwrap_or([0; 2]));
- let i1 = u16::from_le_bytes(chunk[10..12].try_into().unwrap_or([0; 2]));
- let i2 = u16::from_le_bytes(chunk[12..14].try_into().unwrap_or([0; 2]));
- if usize::from(i0) >= positions.len()
- || usize::from(i1) >= positions.len()
- || usize::from(i2) >= positions.len()
- {
- continue;
- }
- faces.push(TerrainFace {
- indices: [i0, i1, i2],
- flags,
- material_tag,
- aux_tag,
- });
- }
-
- Ok(TerrainMesh {
- positions,
- uv0,
- faces,
- })
-}
-
-pub fn build_render_mesh(mesh: &TerrainMesh) -> Result<TerrainRenderMesh> {
- if mesh.positions.len() > usize::from(u16::MAX) + 1 {
- return Err(Error::VertexCountOverflow {
- count: mesh.positions.len(),
- });
- }
-
- let vertices = mesh
- .positions
- .iter()
- .enumerate()
- .map(|(idx, &position)| TerrainRenderVertex {
- position,
- uv0: mesh.uv0.get(idx).copied().unwrap_or([0.0, 0.0]),
- })
- .collect::<Vec<_>>();
-
- let mut indices = Vec::with_capacity(mesh.faces.len() * 3);
- for face in &mesh.faces {
- indices.extend_from_slice(&face.indices);
- }
-
- Ok(TerrainRenderMesh {
- vertices,
- indices,
- face_count_raw: mesh.faces.len(),
- face_count_kept: mesh.faces.len(),
- face_count_dropped_invalid: 0,
- })
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use common::collect_files_recursive;
- use std::path::{Path, PathBuf};
-
- fn game_root() -> Option<PathBuf> {
- let root = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("..")
- .join("..")
- .join("testdata")
- .join("Parkan - Iron Strategy");
- root.is_dir().then_some(root)
- }
-
- #[test]
- fn loads_known_land_mesh() {
- let Some(root) = game_root() else {
- eprintln!("skipping: game root missing");
- return;
- };
-
- let land = root
- .join("DATA")
- .join("MAPS")
- .join("Tut_1")
- .join("Land.msh");
- if !land.is_file() {
- eprintln!("skipping missing sample {}", land.display());
- return;
- }
-
- let mesh = load_land_mesh(&land)
- .unwrap_or_else(|err| panic!("failed to parse {}: {err}", land.display()));
- assert!(mesh.positions.len() > 1000);
- assert!(mesh.faces.len() > 1000);
-
- let render = build_render_mesh(&mesh).expect("failed to build render mesh");
- assert_eq!(render.vertices.len(), mesh.positions.len());
- assert_eq!(render.indices.len(), mesh.faces.len() * 3);
- }
-
- #[test]
- fn loads_all_retail_land_meshes() {
- let Some(root) = game_root() else {
- eprintln!("skipping: game root missing");
- return;
- };
-
- let maps_root = root.join("DATA").join("MAPS");
- let mut files = Vec::new();
- collect_files_recursive(&maps_root, &mut files);
- files.sort();
-
- let mut parsed = 0usize;
- for path in files {
- if !path
- .file_name()
- .and_then(|n| n.to_str())
- .is_some_and(|n| n.eq_ignore_ascii_case("Land.msh"))
- {
- continue;
- }
- let mesh = load_land_mesh(&path)
- .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
- assert!(
- !mesh.positions.is_empty() && !mesh.faces.is_empty(),
- "{} parsed but empty",
- path.display()
- );
- parsed += 1;
- }
-
- assert!(parsed > 0, "no Land.msh files parsed");
- }
-}
diff --git a/crates/texm/Cargo.toml b/crates/texm/Cargo.toml
deleted file mode 100644
index f9c49b6..0000000
--- a/crates/texm/Cargo.toml
+++ /dev/null
@@ -1,9 +0,0 @@
-[package]
-name = "texm"
-version = "0.1.0"
-edition = "2021"
-
-[dev-dependencies]
-common = { path = "../common" }
-nres = { path = "../nres" }
-proptest = "1"
diff --git a/crates/texm/README.md b/crates/texm/README.md
deleted file mode 100644
index 370ac54..0000000
--- a/crates/texm/README.md
+++ /dev/null
@@ -1,15 +0,0 @@
-# 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
deleted file mode 100644
index 90d618d..0000000
--- a/crates/texm/src/error.rs
+++ /dev/null
@@ -1,86 +0,0 @@
-use core::fmt;
-
-#[derive(Debug)]
-#[non_exhaustive]
-pub enum Error {
- HeaderTooSmall {
- size: usize,
- },
- InvalidMagic {
- got: u32,
- },
- InvalidDimensions {
- width: u32,
- height: u32,
- },
- InvalidMipCount {
- mip_count: u32,
- },
- UnknownFormat {
- format: u32,
- },
- IntegerOverflow,
- CoreDataOutOfBounds {
- expected_end: usize,
- actual_size: usize,
- },
- MipIndexOutOfRange {
- requested: usize,
- mip_count: usize,
- },
- MipDataOutOfBounds {
- offset: usize,
- size: usize,
- payload_size: usize,
- },
- InvalidPageMagic,
- InvalidPageSize {
- expected: usize,
- actual: usize,
- },
-}
-
-impl fmt::Display for Error {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::HeaderTooSmall { size } => {
- write!(f, "Texm payload too small for header: {size}")
- }
- Self::InvalidMagic { got } => write!(f, "invalid Texm magic: 0x{got:08X}"),
- Self::InvalidDimensions { width, height } => {
- write!(f, "invalid Texm dimensions: {width}x{height}")
- }
- Self::InvalidMipCount { mip_count } => write!(f, "invalid Texm mip_count={mip_count}"),
- Self::UnknownFormat { format } => write!(f, "unknown Texm format={format}"),
- Self::IntegerOverflow => write!(f, "integer overflow"),
- Self::CoreDataOutOfBounds {
- expected_end,
- actual_size,
- } => write!(
- f,
- "Texm core data out of bounds: expected_end={expected_end}, actual_size={actual_size}"
- ),
- Self::MipIndexOutOfRange {
- requested,
- mip_count,
- } => write!(
- f,
- "Texm mip index out of range: requested={requested}, mip_count={mip_count}"
- ),
- Self::MipDataOutOfBounds {
- offset,
- size,
- payload_size,
- } => write!(
- f,
- "Texm mip data out of bounds: offset={offset}, size={size}, payload_size={payload_size}"
- ),
- Self::InvalidPageMagic => write!(f, "Texm tail exists but Page magic is missing"),
- Self::InvalidPageSize { expected, actual } => {
- write!(f, "invalid Page chunk size: expected={expected}, actual={actual}")
- }
- }
- }
-}
-
-impl std::error::Error for Error {}
diff --git a/crates/texm/src/lib.rs b/crates/texm/src/lib.rs
deleted file mode 100644
index 7a166f3..0000000
--- a/crates/texm/src/lib.rs
+++ /dev/null
@@ -1,417 +0,0 @@
-pub mod error;
-
-use crate::error::Error;
-
-pub type Result<T> = core::result::Result<T, Error>;
-
-pub const TEXM_MAGIC: u32 = 0x6D78_6554;
-pub const PAGE_MAGIC: u32 = 0x6567_6150;
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq)]
-pub enum PixelFormat {
- Indexed8,
- Rgb565,
- Rgb556,
- Argb4444,
- LuminanceAlpha88,
- Rgb888,
- Argb8888,
-}
-
-impl PixelFormat {
- pub fn from_raw(raw: u32) -> Option<Self> {
- match raw {
- 0 => Some(Self::Indexed8),
- 565 => Some(Self::Rgb565),
- 556 => Some(Self::Rgb556),
- 4444 => Some(Self::Argb4444),
- 88 => Some(Self::LuminanceAlpha88),
- 888 => Some(Self::Rgb888),
- 8888 => Some(Self::Argb8888),
- _ => None,
- }
- }
-
- pub fn bytes_per_pixel(self) -> usize {
- match self {
- Self::Indexed8 => 1,
- Self::Rgb565 | Self::Rgb556 | Self::Argb4444 | Self::LuminanceAlpha88 => 2,
- // Parkan stores format 888 as 32-bit RGBX in texture payloads.
- Self::Rgb888 | Self::Argb8888 => 4,
- }
- }
-}
-
-#[derive(Clone, Debug)]
-pub struct Header {
- pub width: u32,
- pub height: u32,
- pub mip_count: u32,
- pub flags4: u32,
- pub flags5: u32,
- pub unk6: u32,
- pub format_raw: u32,
- pub format: PixelFormat,
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq)]
-pub struct MipLevel {
- pub width: u32,
- pub height: u32,
- pub offset: usize,
- pub size: usize,
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq)]
-pub struct PageRect {
- pub x: i16,
- pub w: i16,
- pub y: i16,
- pub h: i16,
-}
-
-#[derive(Clone, Debug)]
-pub struct Texture {
- pub header: Header,
- pub palette: Option<[u8; 1024]>,
- pub mip_levels: Vec<MipLevel>,
- pub page_rects: Vec<PageRect>,
-}
-
-impl Texture {
- pub fn core_size(&self) -> usize {
- let mut size = 32usize;
- if self.palette.is_some() {
- size += 1024;
- }
- for level in &self.mip_levels {
- size += level.size;
- }
- size
- }
-}
-
-#[derive(Clone, Debug)]
-pub struct DecodedMip {
- pub width: u32,
- pub height: u32,
- pub rgba8: Vec<u8>,
-}
-
-pub fn parse_texm(payload: &[u8]) -> Result<Texture> {
- if payload.len() < 32 {
- return Err(Error::HeaderTooSmall {
- size: payload.len(),
- });
- }
-
- let magic = read_u32(payload, 0)?;
- if magic != TEXM_MAGIC {
- return Err(Error::InvalidMagic { got: magic });
- }
-
- let width = read_u32(payload, 4)?;
- let height = read_u32(payload, 8)?;
- let mip_count = read_u32(payload, 12)?;
- let flags4 = read_u32(payload, 16)?;
- let flags5 = read_u32(payload, 20)?;
- let unk6 = read_u32(payload, 24)?;
- let format_raw = read_u32(payload, 28)?;
-
- if width == 0 || height == 0 {
- return Err(Error::InvalidDimensions { width, height });
- }
- if mip_count == 0 {
- return Err(Error::InvalidMipCount { mip_count });
- }
-
- let format =
- PixelFormat::from_raw(format_raw).ok_or(Error::UnknownFormat { format: format_raw })?;
- let bytes_per_pixel = format.bytes_per_pixel();
-
- let mut offset = 32usize;
- let palette = if format == PixelFormat::Indexed8 {
- let end = offset.checked_add(1024).ok_or(Error::IntegerOverflow)?;
- if end > payload.len() {
- return Err(Error::CoreDataOutOfBounds {
- expected_end: end,
- actual_size: payload.len(),
- });
- }
- let mut pal = [0u8; 1024];
- pal.copy_from_slice(&payload[offset..end]);
- offset = end;
- Some(pal)
- } else {
- None
- };
-
- let mut mip_levels =
- Vec::with_capacity(usize::try_from(mip_count).map_err(|_| Error::IntegerOverflow)?);
- let mut w = width;
- let mut h = height;
- for _ in 0..mip_count {
- let pixel_count_u64 = u64::from(w)
- .checked_mul(u64::from(h))
- .ok_or(Error::IntegerOverflow)?;
- let level_size_u64 = pixel_count_u64
- .checked_mul(u64::try_from(bytes_per_pixel).map_err(|_| Error::IntegerOverflow)?)
- .ok_or(Error::IntegerOverflow)?;
- let level_size = usize::try_from(level_size_u64).map_err(|_| Error::IntegerOverflow)?;
- let level_offset = offset;
- offset = offset
- .checked_add(level_size)
- .ok_or(Error::IntegerOverflow)?;
- if offset > payload.len() {
- return Err(Error::CoreDataOutOfBounds {
- expected_end: offset,
- actual_size: payload.len(),
- });
- }
- mip_levels.push(MipLevel {
- width: w,
- height: h,
- offset: level_offset,
- size: level_size,
- });
- w = (w >> 1).max(1);
- h = (h >> 1).max(1);
- }
-
- let page_rects = parse_page_tail(payload, offset)?;
-
- Ok(Texture {
- header: Header {
- width,
- height,
- mip_count,
- flags4,
- flags5,
- unk6,
- format_raw,
- format,
- },
- palette,
- mip_levels,
- page_rects,
- })
-}
-
-pub fn decode_mip_rgba8(texture: &Texture, payload: &[u8], mip_index: usize) -> Result<DecodedMip> {
- let Some(level) = texture.mip_levels.get(mip_index).copied() else {
- return Err(Error::MipIndexOutOfRange {
- requested: mip_index,
- mip_count: texture.mip_levels.len(),
- });
- };
-
- let end = level
- .offset
- .checked_add(level.size)
- .ok_or(Error::IntegerOverflow)?;
- let Some(level_data) = payload.get(level.offset..end) else {
- return Err(Error::MipDataOutOfBounds {
- offset: level.offset,
- size: level.size,
- payload_size: payload.len(),
- });
- };
-
- let pixel_count = usize::try_from(level.width)
- .ok()
- .and_then(|w| {
- usize::try_from(level.height)
- .ok()
- .map(|h| w.saturating_mul(h))
- })
- .ok_or(Error::IntegerOverflow)?;
- let mut rgba = vec![0u8; pixel_count.saturating_mul(4)];
-
- match texture.header.format {
- PixelFormat::Indexed8 => {
- let palette = texture.palette.as_ref().ok_or(Error::IntegerOverflow)?;
- for (i, &index) in level_data.iter().enumerate() {
- if i >= pixel_count {
- break;
- }
- let poff = usize::from(index).saturating_mul(4);
- // Keep this form to accept the last palette item (index 255).
- if poff + 4 > palette.len() {
- continue;
- }
- let out = i.saturating_mul(4);
- rgba[out] = palette[poff];
- rgba[out + 1] = palette[poff + 1];
- rgba[out + 2] = palette[poff + 2];
- rgba[out + 3] = palette[poff + 3];
- }
- }
- PixelFormat::Rgb565 => {
- decode_words(level_data, pixel_count, &mut rgba, decode_rgb565);
- }
- PixelFormat::Rgb556 => {
- decode_words(level_data, pixel_count, &mut rgba, decode_rgb556);
- }
- PixelFormat::Argb4444 => {
- decode_words(level_data, pixel_count, &mut rgba, decode_argb4444);
- }
- PixelFormat::LuminanceAlpha88 => {
- decode_words(level_data, pixel_count, &mut rgba, decode_luminance_alpha88);
- }
- PixelFormat::Rgb888 => {
- decode_dwords(level_data, pixel_count, &mut rgba, decode_rgb888x);
- }
- PixelFormat::Argb8888 => {
- decode_dwords(level_data, pixel_count, &mut rgba, decode_argb8888);
- }
- }
-
- Ok(DecodedMip {
- width: level.width,
- height: level.height,
- rgba8: rgba,
- })
-}
-
-fn parse_page_tail(payload: &[u8], core_end: usize) -> Result<Vec<PageRect>> {
- if core_end == payload.len() {
- return Ok(Vec::new());
- }
- if payload.len().saturating_sub(core_end) < 8 {
- return Err(Error::InvalidPageSize {
- expected: 8,
- actual: payload.len().saturating_sub(core_end),
- });
- }
- let magic = read_u32(payload, core_end)?;
- if magic != PAGE_MAGIC {
- return Err(Error::InvalidPageMagic);
- }
- let rect_count = read_u32(payload, core_end + 4)?;
- let rect_count_usize = usize::try_from(rect_count).map_err(|_| Error::IntegerOverflow)?;
- let expected_size = 8usize
- .checked_add(
- rect_count_usize
- .checked_mul(8)
- .ok_or(Error::IntegerOverflow)?,
- )
- .ok_or(Error::IntegerOverflow)?;
- let actual = payload.len().saturating_sub(core_end);
- if expected_size != actual {
- return Err(Error::InvalidPageSize {
- expected: expected_size,
- actual,
- });
- }
-
- let mut rects = Vec::with_capacity(rect_count_usize);
- for i in 0..rect_count_usize {
- let off = core_end
- .checked_add(8)
- .and_then(|v| v.checked_add(i * 8))
- .ok_or(Error::IntegerOverflow)?;
- rects.push(PageRect {
- x: read_i16(payload, off)?,
- w: read_i16(payload, off + 2)?,
- y: read_i16(payload, off + 4)?,
- h: read_i16(payload, off + 6)?,
- });
- }
- Ok(rects)
-}
-
-fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
- let bytes = data.get(offset..offset + 4).ok_or(Error::IntegerOverflow)?;
- let arr: [u8; 4] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
- Ok(u32::from_le_bytes(arr))
-}
-
-fn read_i16(data: &[u8], offset: usize) -> Result<i16> {
- let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?;
- let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
- Ok(i16::from_le_bytes(arr))
-}
-
-fn decode_words(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u16) -> [u8; 4]) {
- for i in 0..pixel_count {
- let off = i.saturating_mul(2);
- let Some(bytes) = data.get(off..off + 2) else {
- break;
- };
- let word = u16::from_le_bytes([bytes[0], bytes[1]]);
- let px = decode(word);
- let out = i.saturating_mul(4);
- rgba[out..out + 4].copy_from_slice(&px);
- }
-}
-
-fn decode_dwords(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u32) -> [u8; 4]) {
- for i in 0..pixel_count {
- let off = i.saturating_mul(4);
- let Some(bytes) = data.get(off..off + 4) else {
- break;
- };
- let dword = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
- let px = decode(dword);
- let out = i.saturating_mul(4);
- rgba[out..out + 4].copy_from_slice(&px);
- }
-}
-
-fn expand5(v: u16) -> u8 {
- ((u32::from(v) * 255 + 15) / 31) as u8
-}
-
-fn expand6(v: u16) -> u8 {
- ((u32::from(v) * 255 + 31) / 63) as u8
-}
-
-fn expand4(v: u16) -> u8 {
- (u32::from(v) * 17) as u8
-}
-
-fn decode_rgb565(word: u16) -> [u8; 4] {
- let r = expand5((word >> 11) & 0x1F);
- let g = expand6((word >> 5) & 0x3F);
- let b = expand5(word & 0x1F);
- [r, g, b, 255]
-}
-
-fn decode_rgb556(word: u16) -> [u8; 4] {
- let r = expand5((word >> 11) & 0x1F);
- let g = expand5((word >> 6) & 0x1F);
- let b = expand6(word & 0x3F);
- [r, g, b, 255]
-}
-
-fn decode_argb4444(word: u16) -> [u8; 4] {
- let a = expand4((word >> 12) & 0x0F);
- let r = expand4((word >> 8) & 0x0F);
- let g = expand4((word >> 4) & 0x0F);
- let b = expand4(word & 0x0F);
- [r, g, b, a]
-}
-
-fn decode_luminance_alpha88(word: u16) -> [u8; 4] {
- let l = ((word >> 8) & 0xFF) as u8;
- let a = (word & 0xFF) as u8;
- [l, l, l, a]
-}
-
-fn decode_rgb888x(dword: u32) -> [u8; 4] {
- let r = (dword & 0xFF) as u8;
- let g = ((dword >> 8) & 0xFF) as u8;
- let b = ((dword >> 16) & 0xFF) as u8;
- [r, g, b, 255]
-}
-
-fn decode_argb8888(dword: u32) -> [u8; 4] {
- let a = (dword & 0xFF) as u8;
- let r = ((dword >> 8) & 0xFF) as u8;
- let g = ((dword >> 16) & 0xFF) as u8;
- let b = ((dword >> 24) & 0xFF) as u8;
- [r, g, b, a]
-}
-
-#[cfg(test)]
-mod tests;
diff --git a/crates/texm/src/tests.rs b/crates/texm/src/tests.rs
deleted file mode 100644
index 49a7100..0000000
--- a/crates/texm/src/tests.rs
+++ /dev/null
@@ -1,330 +0,0 @@
-use super::*;
-use common::collect_files_recursive;
-use nres::Archive;
-use proptest::prelude::*;
-use std::fs;
-use std::path::{Path, PathBuf};
-
-fn nres_test_files() -> Vec<PathBuf> {
- let root = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("..")
- .join("..")
- .join("testdata");
- let mut files = Vec::new();
- collect_files_recursive(&root, &mut files);
- files.sort();
- files
- .into_iter()
- .filter(|path| {
- fs::read(path)
- .map(|bytes| bytes.get(0..4) == Some(b"NRes"))
- .unwrap_or(false)
- })
- .collect()
-}
-
-fn build_texm_payload(
- width: u32,
- height: u32,
- format_raw: u32,
- flags5: u32,
- palette: Option<[u8; 1024]>,
- mip_levels: &[&[u8]],
-) -> Vec<u8> {
- let mut payload = Vec::new();
- payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes());
- payload.extend_from_slice(&width.to_le_bytes());
- payload.extend_from_slice(&height.to_le_bytes());
- payload.extend_from_slice(
- &u32::try_from(mip_levels.len())
- .expect("mip level count overflow in test")
- .to_le_bytes(),
- );
- payload.extend_from_slice(&0u32.to_le_bytes()); // flags4
- payload.extend_from_slice(&flags5.to_le_bytes());
- payload.extend_from_slice(&0u32.to_le_bytes()); // unk6
- payload.extend_from_slice(&format_raw.to_le_bytes());
- if let Some(palette) = palette {
- payload.extend_from_slice(&palette);
- }
- for level in mip_levels {
- payload.extend_from_slice(level);
- }
- payload
-}
-
-#[test]
-fn texm_parse_all_game_textures() {
- let archives = nres_test_files();
- if archives.is_empty() {
- eprintln!("skipping texm_parse_all_game_textures: no NRes files in testdata");
- return;
- }
-
- let mut texm_total = 0usize;
- let mut texm_with_page = 0usize;
- for archive_path in archives {
- let archive = Archive::open_path(&archive_path)
- .unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display()));
-
- for entry in archive.entries() {
- if entry.meta.kind != TEXM_MAGIC {
- continue;
- }
- texm_total += 1;
- let payload = archive.read(entry.id).unwrap_or_else(|err| {
- panic!(
- "failed to read Texm entry '{}' in {}: {err}",
- entry.meta.name,
- archive_path.display()
- )
- });
- let texture = parse_texm(payload.as_slice()).unwrap_or_else(|err| {
- panic!(
- "failed to parse Texm '{}' in {}: {err}",
- entry.meta.name,
- archive_path.display()
- )
- });
- if !texture.page_rects.is_empty() {
- texm_with_page += 1;
- }
-
- assert!(
- texture.core_size() <= payload.as_slice().len(),
- "core size must be within payload for '{}' in {}",
- entry.meta.name,
- archive_path.display()
- );
- assert_eq!(
- usize::try_from(texture.header.mip_count).ok(),
- Some(texture.mip_levels.len()),
- "mip count mismatch for '{}' in {}",
- entry.meta.name,
- archive_path.display()
- );
- }
- }
-
- assert!(texm_total > 0, "no Texm textures found");
- assert!(
- texm_with_page > 0,
- "expected at least one Texm texture with Page chunk"
- );
-}
-
-#[test]
-fn texm_parse_minimal_argb8888_no_page() {
- let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[1, 2, 3, 4]]);
-
- let parsed = parse_texm(&payload).expect("failed to parse minimal texm");
- assert_eq!(parsed.header.width, 1);
- assert_eq!(parsed.header.height, 1);
- assert_eq!(parsed.mip_levels.len(), 1);
- assert!(parsed.page_rects.is_empty());
-}
-
-#[test]
-fn texm_decode_minimal_argb8888_no_page() {
- let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[0x40, 0x11, 0x22, 0x33]]);
- let parsed = parse_texm(&payload).expect("failed to parse minimal texm");
- let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode mip");
- assert_eq!(decoded.width, 1);
- assert_eq!(decoded.height, 1);
- assert_eq!(decoded.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
-}
-
-#[test]
-fn texm_decode_rgb565() {
- let word = 0xFFE0u16; // r=31 g=63 b=0
- let payload = build_texm_payload(1, 1, 565, 0, None, &[&word.to_le_bytes()]);
- let parsed = parse_texm(&payload).expect("failed to parse rgb565 texm");
- let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb565 texm");
- assert_eq!(decoded.rgba8, vec![255, 255, 0, 255]);
-}
-
-#[test]
-fn texm_decode_rgb556() {
- let word = 0xF800u16; // r=31 g=0 b=0
- let payload = build_texm_payload(1, 1, 556, 0, None, &[&word.to_le_bytes()]);
- let parsed = parse_texm(&payload).expect("failed to parse rgb556 texm");
- let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb556 texm");
- assert_eq!(decoded.rgba8, vec![255, 0, 0, 255]);
-}
-
-#[test]
-fn texm_decode_argb4444() {
- let word = 0xF12Eu16; // a=F r=1 g=2 b=E
- let payload = build_texm_payload(1, 1, 4444, 0, None, &[&word.to_le_bytes()]);
- let parsed = parse_texm(&payload).expect("failed to parse argb4444 texm");
- let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode argb4444 texm");
- assert_eq!(decoded.rgba8, vec![17, 34, 238, 255]);
-}
-
-#[test]
-fn texm_decode_luminance_alpha88() {
- let word = 0x7F40u16; // luminance=0x7F alpha=0x40
- let payload = build_texm_payload(1, 1, 88, 0, None, &[&word.to_le_bytes()]);
- let parsed = parse_texm(&payload).expect("failed to parse la88 texm");
- let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode la88 texm");
- assert_eq!(decoded.rgba8, vec![0x7F, 0x7F, 0x7F, 0x40]);
-}
-
-#[test]
-fn texm_decode_rgb888x() {
- let payload = build_texm_payload(1, 1, 888, 0, None, &[&[0x11, 0x22, 0x33, 0x99]]);
- let parsed = parse_texm(&payload).expect("failed to parse rgb888 texm");
- let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb888 texm");
- assert_eq!(decoded.rgba8, vec![0x11, 0x22, 0x33, 255]);
-}
-
-#[test]
-fn texm_parse_indexed_with_page_chunk() {
- let mut palette = [0u8; 1024];
- palette[4..8].copy_from_slice(&[10, 20, 30, 255]);
- let mut payload = build_texm_payload(2, 2, 0, 0, Some(palette), &[&[1, 1, 1, 1]]);
- payload.extend_from_slice(&PAGE_MAGIC.to_le_bytes());
- payload.extend_from_slice(&1u32.to_le_bytes()); // rect_count
- payload.extend_from_slice(&0i16.to_le_bytes()); // x
- payload.extend_from_slice(&2i16.to_le_bytes()); // w
- payload.extend_from_slice(&0i16.to_le_bytes()); // y
- payload.extend_from_slice(&2i16.to_le_bytes()); // h
-
- let parsed = parse_texm(&payload).expect("failed to parse indexed texm");
- assert!(parsed.palette.is_some());
- assert_eq!(parsed.page_rects.len(), 1);
- assert_eq!(
- parsed.page_rects[0],
- PageRect {
- x: 0,
- w: 2,
- y: 0,
- h: 2
- }
- );
-}
-
-#[test]
-fn texm_decode_indexed_with_palette_last_entry() {
- let mut palette = [0u8; 1024];
- palette[4..8].copy_from_slice(&[10, 20, 30, 255]); // index 1
- palette[8..12].copy_from_slice(&[40, 50, 60, 200]); // index 2
- palette[1020..1024].copy_from_slice(&[1, 2, 3, 4]); // index 255 (last)
- let payload = build_texm_payload(3, 1, 0, 0, Some(palette), &[&[1u8, 2u8, 255u8]]);
-
- let parsed = parse_texm(&payload).expect("failed to parse indexed texm");
- let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode indexed texm");
- assert_eq!(decoded.width, 3);
- assert_eq!(decoded.height, 1);
- assert_eq!(
- decoded.rgba8,
- vec![10, 20, 30, 255, 40, 50, 60, 200, 1, 2, 3, 4]
- );
-}
-
-#[test]
-fn texm_parse_multi_mip_offsets() {
- let mip0 = [0x10u8; 32]; // 4*2*4
- let mip1 = [0x20u8; 8]; // 2*1*4
- let mip2 = [0x30u8; 4]; // 1*1*4
- let payload = build_texm_payload(4, 2, 8888, 0, None, &[&mip0, &mip1, &mip2]);
-
- let parsed = parse_texm(&payload).expect("failed to parse multi-mip texm");
- assert_eq!(parsed.header.mip_count, 3);
- assert_eq!(parsed.mip_levels.len(), 3);
- assert_eq!(
- parsed.mip_levels,
- vec![
- MipLevel {
- width: 4,
- height: 2,
- offset: 32,
- size: 32
- },
- MipLevel {
- width: 2,
- height: 1,
- offset: 64,
- size: 8
- },
- MipLevel {
- width: 1,
- height: 1,
- offset: 72,
- size: 4
- },
- ]
- );
-}
-
-#[test]
-fn texm_preserves_flags5_for_mip_skip_metadata() {
- let payload = build_texm_payload(1, 1, 8888, 0x0000_00A5, None, &[&[0, 0, 0, 0]]);
- let parsed = parse_texm(&payload).expect("failed to parse texm");
- assert_eq!(parsed.header.flags5, 0x0000_00A5);
-}
-
-#[test]
-fn texm_errors_for_invalid_header_values() {
- let mut bad_magic = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]);
- bad_magic[0..4].copy_from_slice(&0u32.to_le_bytes());
- assert!(matches!(
- parse_texm(&bad_magic),
- Err(Error::InvalidMagic { .. })
- ));
-
- let zero_dims = build_texm_payload(0, 1, 8888, 0, None, &[&[]]);
- assert!(matches!(
- parse_texm(&zero_dims),
- Err(Error::InvalidDimensions { .. })
- ));
-
- let mut bad_mips = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]);
- bad_mips[12..16].copy_from_slice(&0u32.to_le_bytes());
- assert!(matches!(
- parse_texm(&bad_mips),
- Err(Error::InvalidMipCount { .. })
- ));
-
- let bad_format = build_texm_payload(1, 1, 12345, 0, None, &[&[0, 0, 0, 0]]);
- assert!(matches!(
- parse_texm(&bad_format),
- Err(Error::UnknownFormat { .. })
- ));
-}
-
-#[test]
-fn texm_errors_for_page_chunk_and_mip_bounds() {
- let mut bad_page = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]);
- bad_page.extend_from_slice(b"X");
- assert!(matches!(
- parse_texm(&bad_page),
- Err(Error::InvalidPageSize { .. })
- ));
-
- let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[1, 2, 3, 4]]);
- let parsed = parse_texm(&payload).expect("failed to parse valid texm");
- assert!(matches!(
- decode_mip_rgba8(&parsed, &payload, 7),
- Err(Error::MipIndexOutOfRange { .. })
- ));
-
- let truncated = &payload[..payload.len() - 1];
- assert!(matches!(
- decode_mip_rgba8(&parsed, truncated, 0),
- Err(Error::MipDataOutOfBounds { .. })
- ));
-}
-
-proptest! {
- #![proptest_config(ProptestConfig::with_cases(64))]
-
- #[test]
- fn parse_texm_is_panic_free_on_random_bytes(payload in proptest::collection::vec(any::<u8>(), 0..4096)) {
- if let Ok(texture) = parse_texm(&payload) {
- for mip_index in 0..texture.mip_levels.len() {
- let _ = decode_mip_rgba8(&texture, &payload, mip_index);
- }
- }
- }
-}
diff --git a/crates/tma/Cargo.toml b/crates/tma/Cargo.toml
deleted file mode 100644
index 99360c3..0000000
--- a/crates/tma/Cargo.toml
+++ /dev/null
@@ -1,10 +0,0 @@
-[package]
-name = "tma"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
-encoding_rs = "0.8"
-
-[dev-dependencies]
-common = { path = "../common" }
diff --git a/crates/tma/src/lib.rs b/crates/tma/src/lib.rs
deleted file mode 100644
index 3b41bc4..0000000
--- a/crates/tma/src/lib.rs
+++ /dev/null
@@ -1,485 +0,0 @@
-use encoding_rs::WINDOWS_1251;
-use std::fmt;
-use std::fs;
-use std::path::Path;
-
-const OBJECT_RECORD_FLAGS: u32 = 0x8000_0002;
-const FOOTER_MAGIC: &[u8; 4] = b"MtPr";
-const MAP_PATH_TOKEN: &[u8; 10] = b"DATA\\MAPS\\";
-
-pub type Result<T> = core::result::Result<T, Error>;
-
-#[derive(Debug)]
-pub enum Error {
- Io(std::io::Error),
- FooterNotFound,
- FooterCorrupt(&'static str),
-}
-
-impl fmt::Display for Error {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::Io(err) => write!(f, "{err}"),
- Self::FooterNotFound => write!(f, "footer magic 'MtPr' not found"),
- Self::FooterCorrupt(reason) => write!(f, "corrupt mission footer: {reason}"),
- }
- }
-}
-
-impl std::error::Error for Error {
- fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
- match self {
- Self::Io(err) => Some(err),
- _ => None,
- }
- }
-}
-
-impl From<std::io::Error> for Error {
- fn from(value: std::io::Error) -> Self {
- Self::Io(value)
- }
-}
-
-#[derive(Clone, Debug)]
-pub struct MissionFile {
- pub footer: MissionFooter,
- pub objects: Vec<MissionObject>,
-}
-
-#[derive(Clone, Debug)]
-pub struct MissionFooter {
- pub map_path: String,
- pub title: String,
- pub version: u32,
-}
-
-#[derive(Clone, Debug)]
-pub struct MissionObject {
- pub offset: usize,
- pub group_id: u32,
- pub flags: u32,
- pub resource_name: String,
- pub logical_id: i32,
- pub clan_id: i32,
- pub position: [f32; 3],
- pub orientation: [f32; 3],
- pub scale: [f32; 3],
- pub alias: String,
-}
-
-pub fn parse_path(path: impl AsRef<Path>) -> Result<MissionFile> {
- let bytes = fs::read(path.as_ref())?;
- parse_bytes(&bytes)
-}
-
-pub fn parse_bytes(bytes: &[u8]) -> Result<MissionFile> {
- let footer = parse_footer(bytes)?;
- let objects = parse_objects(bytes);
- Ok(MissionFile { footer, objects })
-}
-
-fn parse_footer(bytes: &[u8]) -> Result<MissionFooter> {
- let map_positions = find_all_map_path_positions(bytes);
- if map_positions.is_empty() {
- return Err(Error::FooterNotFound);
- }
-
- for map_start in map_positions.into_iter().rev() {
- if map_start < 4 {
- continue;
- }
-
- let map_end = scan_path_end(bytes, map_start);
- if map_end <= map_start {
- continue;
- }
- let map_len = map_end - map_start;
- let Some(declared_map_len) = read_u32(bytes, map_start - 4).map(|v| v as usize) else {
- continue;
- };
- if declared_map_len != map_len {
- continue;
- }
-
- let Some(zero_pad) = read_u32(bytes, map_end) else {
- continue;
- };
- if zero_pad != 0 {
- continue;
- }
-
- let title_len_off = map_end + 4;
- let Some(title_len) = read_u32(bytes, title_len_off).map(|v| v as usize) else {
- continue;
- };
- if title_len == 0 || title_len > 256 {
- continue;
- }
- let title_start = title_len_off + 4;
- let Some(title_end) = title_start.checked_add(title_len) else {
- continue;
- };
- if title_end > bytes.len() {
- continue;
- }
-
- let map_path = decode_cp1251(&bytes[map_start..map_end]);
- if !map_path.to_ascii_uppercase().contains("DATA\\MAPS\\") {
- continue;
- }
- let title = decode_title(&bytes[title_start..title_end]);
- let version = parse_footer_version(bytes, title_end)?;
-
- return Ok(MissionFooter {
- map_path,
- title,
- version,
- });
- }
-
- // Fallback for multiplayer/legacy variants where the footer tail differs,
- // but map path is still present in clear text near EOF.
- let Some(map_start) = bytes
- .windows(MAP_PATH_TOKEN.len())
- .rposition(|window| window == MAP_PATH_TOKEN)
- else {
- return Err(Error::FooterCorrupt("failed to decode map/title envelope"));
- };
- let map_end = scan_path_end(bytes, map_start);
- if map_end <= map_start {
- return Err(Error::FooterCorrupt("failed to decode map/title envelope"));
- }
- let map_path = decode_cp1251(&bytes[map_start..map_end]);
- if !map_path.to_ascii_uppercase().contains("DATA\\MAPS\\") {
- return Err(Error::FooterCorrupt("failed to decode map/title envelope"));
- }
-
- let mut title = String::new();
- if let Some(title_len) = read_u32(bytes, map_end + 8).map(|v| v as usize) {
- let title_start = map_end + 12;
- let title_end = title_start.saturating_add(title_len);
- if title_len > 0 && title_len <= 256 && title_end <= bytes.len() {
- let raw = &bytes[title_start..title_end];
- if raw.iter().all(|b| b.is_ascii_graphic() || *b == b' ') {
- title = decode_title(raw);
- }
- }
- }
-
- let version = if let Some(magic_off) = bytes
- .windows(FOOTER_MAGIC.len())
- .rposition(|window| window == FOOTER_MAGIC)
- {
- read_u32(bytes, magic_off + 4).unwrap_or(1)
- } else {
- read_u32(bytes, map_end).unwrap_or(1)
- };
-
- Ok(MissionFooter {
- map_path,
- title,
- version,
- })
-}
-
-fn parse_footer_version(bytes: &[u8], after_title_off: usize) -> Result<u32> {
- if after_title_off + 8 <= bytes.len()
- && &bytes[after_title_off..after_title_off + 4] == FOOTER_MAGIC
- {
- let version = read_u32(bytes, after_title_off + 4)
- .ok_or(Error::FooterCorrupt("missing version after MtPr"))?;
- return Ok(version);
- }
-
- let version = read_u32(bytes, after_title_off)
- .ok_or(Error::FooterCorrupt("missing version after title"))?;
- Ok(version)
-}
-
-fn find_all_map_path_positions(bytes: &[u8]) -> Vec<usize> {
- bytes
- .windows(MAP_PATH_TOKEN.len())
- .enumerate()
- .filter_map(|(idx, window)| (window == MAP_PATH_TOKEN).then_some(idx))
- .collect()
-}
-
-fn scan_path_end(bytes: &[u8], start: usize) -> usize {
- let mut off = start;
- while off < bytes.len() && is_path_byte(bytes[off]) {
- off += 1;
- }
- off
-}
-
-fn is_path_byte(byte: u8) -> bool {
- byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'/' | b'\\' | b'-' | b' ' | b':')
-}
-
-fn parse_objects(bytes: &[u8]) -> Vec<MissionObject> {
- let mut objects = Vec::new();
- let min_record_tail = 48usize;
-
- for offset in 0..bytes.len().saturating_sub(16) {
- let Some(flags) = read_u32(bytes, offset + 4) else {
- continue;
- };
- if flags != OBJECT_RECORD_FLAGS {
- continue;
- }
-
- let Some(name_len) = read_u32(bytes, offset + 8).map(|v| v as usize) else {
- continue;
- };
- if !(3..=260).contains(&name_len) {
- continue;
- }
-
- let name_start = offset + 12;
- let Some(name_end) = name_start.checked_add(name_len) else {
- continue;
- };
- if name_end + min_record_tail > bytes.len() {
- continue;
- }
-
- let name_raw = &bytes[name_start..name_end];
- if !is_object_name_bytes(name_raw) {
- continue;
- }
-
- let resource_name = decode_cp1251(name_raw);
- if !looks_like_object_name(&resource_name) {
- continue;
- }
-
- let Some(group_id) = read_u32(bytes, offset) else {
- continue;
- };
- let Some(logical_id) = read_i32(bytes, name_end) else {
- continue;
- };
- let Some(clan_id) = read_i32(bytes, name_end + 4) else {
- continue;
- };
- let Some(position) = read_vec3(bytes, name_end + 8) else {
- continue;
- };
- let Some(orientation) = read_vec3(bytes, name_end + 20) else {
- continue;
- };
- let Some(scale) = read_vec3(bytes, name_end + 32) else {
- continue;
- };
- if !all_finite(&position) || !all_finite(&orientation) || !all_finite(&scale) {
- continue;
- }
-
- let alias = parse_alias(bytes, name_end + 44);
-
- objects.push(MissionObject {
- offset,
- group_id,
- flags,
- resource_name,
- logical_id,
- clan_id,
- position,
- orientation,
- scale,
- alias,
- });
- }
-
- objects.sort_by_key(|obj| obj.offset);
- objects.dedup_by_key(|obj| obj.offset);
- objects
-}
-
-fn parse_alias(bytes: &[u8], alias_len_off: usize) -> String {
- let Some(alias_len) = read_u32(bytes, alias_len_off).map(|v| v as usize) else {
- return String::new();
- };
- if alias_len == 0 || alias_len > 96 {
- return String::new();
- }
- let alias_start = alias_len_off + 4;
- let Some(alias_end) = alias_start.checked_add(alias_len) else {
- return String::new();
- };
- if alias_end > bytes.len() {
- return String::new();
- }
- let alias_raw = &bytes[alias_start..alias_end];
- if !alias_raw
- .iter()
- .all(|&b| b == b'_' || b == b'-' || b == b'.' || b.is_ascii_alphanumeric())
- {
- return String::new();
- }
- decode_cp1251(alias_raw)
-}
-
-fn looks_like_object_name(name: &str) -> bool {
- if name.ends_with(".dat") {
- return true;
- }
- name.contains('_')
-}
-
-fn is_object_name_bytes(bytes: &[u8]) -> bool {
- bytes
- .iter()
- .all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'_' | b'.' | b'/' | b'\\' | b'-'))
-}
-
-fn all_finite(v: &[f32; 3]) -> bool {
- v.iter().all(|c| c.is_finite())
-}
-
-fn decode_cp1251(bytes: &[u8]) -> String {
- let (decoded, _, _) = WINDOWS_1251.decode(bytes);
- decoded.into_owned()
-}
-
-fn decode_title(bytes: &[u8]) -> String {
- let end = bytes
- .iter()
- .rposition(|b| *b != 0 && *b != 0xCD)
- .map(|idx| idx + 1)
- .unwrap_or(0);
- decode_cp1251(&bytes[..end]).trim().to_string()
-}
-
-fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
- let end = offset.checked_add(4)?;
- let chunk = bytes.get(offset..end)?;
- Some(u32::from_le_bytes(chunk.try_into().ok()?))
-}
-
-fn read_i32(bytes: &[u8], offset: usize) -> Option<i32> {
- read_u32(bytes, offset).map(|v| v as i32)
-}
-
-fn read_f32(bytes: &[u8], offset: usize) -> Option<f32> {
- let end = offset.checked_add(4)?;
- let chunk = bytes.get(offset..end)?;
- Some(f32::from_le_bytes(chunk.try_into().ok()?))
-}
-
-fn read_vec3(bytes: &[u8], offset: usize) -> Option<[f32; 3]> {
- Some([
- read_f32(bytes, offset)?,
- read_f32(bytes, offset + 4)?,
- read_f32(bytes, offset + 8)?,
- ])
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use common::collect_files_recursive;
- use std::path::{Path, PathBuf};
-
- fn game_root() -> Option<PathBuf> {
- let root = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("..")
- .join("..")
- .join("testdata")
- .join("Parkan - Iron Strategy");
- root.is_dir().then_some(root)
- }
-
- #[test]
- fn parses_known_mission_footer_and_objects() {
- let Some(root) = game_root() else {
- eprintln!("skipping: game root is missing");
- return;
- };
-
- let path = root
- .join("MISSIONS")
- .join("CAMPAIGN")
- .join("CAMPAIGN.00")
- .join("Mission.01")
- .join("data.tma");
- if !path.is_file() {
- eprintln!("skipping: sample mission is missing ({})", path.display());
- return;
- }
-
- let mission = parse_path(&path).expect("parse mission failed");
- assert_eq!(mission.footer.version, 1);
- assert!(
- mission
- .footer
- .map_path
- .eq_ignore_ascii_case("DATA\\MAPS\\Tut_1\\land"),
- "unexpected map path: {}",
- mission.footer.map_path
- );
- assert!(mission.objects.len() >= 20);
- assert!(mission
- .objects
- .iter()
- .any(|obj| obj.resource_name.eq_ignore_ascii_case("s_tree_04")));
- assert!(mission.objects.iter().any(|obj| {
- obj.resource_name
- .eq_ignore_ascii_case("UNITS\\UNITS\\HERO\\tut1_p.dat")
- }));
- }
-
- #[test]
- fn parses_all_retail_missions() {
- let Some(root) = game_root() else {
- eprintln!("skipping: game root is missing");
- return;
- };
-
- let mission_root = root.join("MISSIONS");
- let mut files = Vec::new();
- collect_files_recursive(&mission_root, &mut files);
- files.sort();
-
- let mut mission_count = 0usize;
- for path in files {
- if !path
- .file_name()
- .and_then(|n| n.to_str())
- .is_some_and(|n| n.eq_ignore_ascii_case("data.tma"))
- {
- continue;
- }
-
- mission_count += 1;
- let mission = parse_path(&path)
- .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
- assert!(
- mission
- .footer
- .map_path
- .to_ascii_uppercase()
- .contains("DATA\\MAPS\\"),
- "{}: invalid map path '{}'",
- path.display(),
- mission.footer.map_path
- );
- assert!(
- !mission.objects.is_empty(),
- "{}: mission has no parsed object records",
- path.display()
- );
- assert!(
- mission
- .objects
- .iter()
- .all(|obj| obj.position.iter().all(|v| v.is_finite())),
- "{}: mission has non-finite position",
- path.display()
- );
- }
-
- assert!(mission_count > 0, "no data.tma files found");
- }
-}
diff --git a/crates/unitdat/Cargo.toml b/crates/unitdat/Cargo.toml
deleted file mode 100644
index 73df4df..0000000
--- a/crates/unitdat/Cargo.toml
+++ /dev/null
@@ -1,10 +0,0 @@
-[package]
-name = "unitdat"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
-encoding_rs = "0.8"
-
-[dev-dependencies]
-common = { path = "../common" }
diff --git a/crates/unitdat/src/lib.rs b/crates/unitdat/src/lib.rs
deleted file mode 100644
index 6414e66..0000000
--- a/crates/unitdat/src/lib.rs
+++ /dev/null
@@ -1,180 +0,0 @@
-use encoding_rs::WINDOWS_1251;
-use std::fmt;
-use std::fs;
-use std::path::Path;
-
-const MIN_SIZE: usize = 0x48;
-const MAGIC: u32 = 0x0000_F0F1;
-
-pub type Result<T> = core::result::Result<T, Error>;
-
-#[derive(Debug)]
-pub enum Error {
- Io(std::io::Error),
- TooSmall { got: usize },
- InvalidMagic { got: u32 },
- MissingArchiveName,
- MissingModelKey,
-}
-
-impl fmt::Display for Error {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::Io(err) => write!(f, "{err}"),
- Self::TooSmall { got } => write!(f, "unit .dat is too small: {got} bytes"),
- Self::InvalidMagic { got } => write!(f, "invalid .dat magic: 0x{got:08X}"),
- Self::MissingArchiveName => write!(f, "unit .dat has empty archive name"),
- Self::MissingModelKey => write!(f, "unit .dat has empty model key"),
- }
- }
-}
-
-impl std::error::Error for Error {
- fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
- match self {
- Self::Io(err) => Some(err),
- _ => None,
- }
- }
-}
-
-impl From<std::io::Error> for Error {
- fn from(value: std::io::Error) -> Self {
- Self::Io(value)
- }
-}
-
-#[derive(Clone, Debug)]
-pub struct UnitDat {
- pub magic: u32,
- pub flags: u32,
- pub archive_name: String,
- pub model_key: String,
-}
-
-pub fn parse_path(path: impl AsRef<Path>) -> Result<UnitDat> {
- let bytes = fs::read(path.as_ref())?;
- parse_bytes(&bytes)
-}
-
-pub fn parse_bytes(bytes: &[u8]) -> Result<UnitDat> {
- if bytes.len() < MIN_SIZE {
- return Err(Error::TooSmall { got: bytes.len() });
- }
-
- let magic = read_u32(bytes, 0).ok_or(Error::TooSmall { got: bytes.len() })?;
- if magic != MAGIC {
- return Err(Error::InvalidMagic { got: magic });
- }
-
- let flags = read_u32(bytes, 4).ok_or(Error::TooSmall { got: bytes.len() })?;
- let archive_name = decode_c_string_fixed(&bytes[0x08..0x28]);
- if archive_name.is_empty() {
- return Err(Error::MissingArchiveName);
- }
-
- let model_key = decode_c_string_fixed(&bytes[0x28..0x48]);
- if model_key.is_empty() {
- return Err(Error::MissingModelKey);
- }
-
- Ok(UnitDat {
- magic,
- flags,
- archive_name,
- model_key,
- })
-}
-
-fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
- let end = offset.checked_add(4)?;
- let chunk = bytes.get(offset..end)?;
- Some(u32::from_le_bytes(chunk.try_into().ok()?))
-}
-
-fn decode_c_string_fixed(bytes: &[u8]) -> String {
- let used = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
- let (decoded, _, _) = WINDOWS_1251.decode(&bytes[..used]);
- decoded.trim().to_string()
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use common::collect_files_recursive;
- use std::path::{Path, PathBuf};
-
- fn game_root() -> Option<PathBuf> {
- let root = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("..")
- .join("..")
- .join("testdata")
- .join("Parkan - Iron Strategy");
- root.is_dir().then_some(root)
- }
-
- #[test]
- fn parses_known_dat_files() {
- let Some(root) = game_root() else {
- eprintln!("skipping: game root missing");
- return;
- };
-
- let samples = [
- root.join("UNITS/UNITS/HERO/tut1_p.dat"),
- root.join("UNITS/UNITS/BATTLE/l_targ.dat"),
- root.join("UNITS/BUILDS/BRIDGE/m_bridge.dat"),
- ];
-
- for path in samples {
- if !path.is_file() {
- eprintln!("skipping missing sample {}", path.display());
- continue;
- }
- let dat = parse_path(&path)
- .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
- assert_eq!(dat.magic, MAGIC);
- assert!(dat.archive_name.to_ascii_lowercase().ends_with(".rlb"));
- assert!(dat.model_key.contains('_'));
- }
- }
-
- #[test]
- fn parses_retail_dat_corpus() {
- let Some(root) = game_root() else {
- eprintln!("skipping: game root missing");
- return;
- };
-
- let units_root = root.join("UNITS");
- let mut files = Vec::new();
- collect_files_recursive(&units_root, &mut files);
- files.sort();
-
- let mut parsed = 0usize;
- for path in files {
- if !path
- .extension()
- .and_then(|ext| ext.to_str())
- .is_some_and(|ext| ext.eq_ignore_ascii_case("dat"))
- {
- continue;
- }
- let dat = parse_path(&path)
- .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
- assert!(
- !dat.archive_name.is_empty(),
- "{} empty archive",
- path.display()
- );
- assert!(
- !dat.model_key.is_empty(),
- "{} empty model key",
- path.display()
- );
- parsed += 1;
- }
-
- assert!(parsed > 0, "no .dat files parsed");
- }
-}