aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-nres/src/lib.rs
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-22 12:12:27 +0300
committerValentin Popov <valentin@popov.link>2026-06-22 12:13:32 +0300
commitd0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 (patch)
treea0bd35c3940be62a5b5de1acc2366af377ffd181 /crates/fparkan-nres/src/lib.rs
parent7416fdc7e9a48837fff5056e6dc8d0774e90964b (diff)
downloadfparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.tar.xz
fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.zip
feat: implement FParkan architecture foundation
Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation.
Diffstat (limited to 'crates/fparkan-nres/src/lib.rs')
-rw-r--r--crates/fparkan-nres/src/lib.rs1935
1 files changed, 1935 insertions, 0 deletions
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())
+ }
+}