From d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Mon, 22 Jun 2026 13:12:27 +0400 Subject: 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. --- crates/fparkan-resource/src/lib.rs | 880 +++++++++++++++++++++++++++++++++++++ 1 file changed, 880 insertions(+) create mode 100644 crates/fparkan-resource/src/lib.rs (limited to 'crates/fparkan-resource/src') 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, +} + +/// 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, + }, +} + +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 { + 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; + /// Finds entry. + /// + /// # Errors + /// + /// Returns [`ResourceError`] when `archive` is not a valid opened archive. + fn find( + &self, + archive: ArchiveId, + name: &ResourceName, + ) -> Result, ResourceError>; + /// Reads bytes. + /// + /// # Errors + /// + /// Returns [`ResourceError`] when `entry` is stale, invalid, or cannot be + /// decoded. + fn read(&self, entry: EntryHandle) -> Result; + /// Reads entry metadata. + /// + /// # Errors + /// + /// Returns [`ResourceError`] when `entry` is stale or invalid. + fn entry_info(&self, entry: EntryHandle) -> Result; +} + +/// Cached archive repository over a [`Vfs`]. +pub struct CachedResourceRepository { + vfs: Arc, + state: Mutex, +} + +#[derive(Default)] +struct RepositoryState { + paths: BTreeMap, + archives: Vec, + 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, +} + +#[derive(Clone, Debug)] +struct PayloadCacheEntry { + bytes: Arc<[u8]>, + last_access: u64, +} + +impl CachedResourceRepository { + /// Creates a cached repository. + #[must_use] + pub fn new(vfs: Arc) -> 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, 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 { + 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 { + 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 { + 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, 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 { + 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 { + 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, 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> { + 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 { + 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, 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 { + 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 { + 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 { + 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 = (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, 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 { + 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 { + 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() + } +} -- cgit v1.2.3