diff options
Diffstat (limited to 'crates')
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", ¬_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(¤t).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, ¤t, 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, ¢er_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, ®istry, 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"); - } -} |
