diff options
Diffstat (limited to 'crates/fparkan-material/src')
| -rw-r--r-- | crates/fparkan-material/src/lib.rs | 1272 |
1 files changed, 1272 insertions, 0 deletions
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()); + } +} |
