From 598137ed132d95a3e3bf9b95e9e27286cc2186ac Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Thu, 19 Feb 2026 10:51:54 +0000 Subject: [PATCH] =?UTF-8?q?feat(resource-viewer):=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9?= =?UTF-8?q?=20=D1=80=D0=B5=D1=81=D1=83=D1=80=D1=81=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=D1=89=D0=B8?= =?UTF-8?q?=D0=BA=20=D1=81=20=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=D1=8E=20feat(nres):=20?= =?UTF-8?q?=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B8=D1=82=D1=8C=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D1=83=20=D0=B0=D1=80=D1=85?= =?UTF-8?q?=D0=B8=D0=B2=D0=B0=20=D1=81=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=B7=D0=B0=D0=B3=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B8=20=D0=B8=D0=BD=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BE=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=81=D1=8F=D1=85=20feat(rsli):=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D1=83=20=D0=B7=D0=B0=D0=B3=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B1=D0=B8=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D0=BE=D1=82=D0=B5=D0=BA=D0=B8=20=D0=B8=20=D1=83=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D1=88=D0=B8=D1=82=D1=8C=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D1=83=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B5?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.toml | 2 +- apps/resource-viewer/Cargo.toml | 11 + apps/resource-viewer/src/main.rs | 518 +++++++++++++++++++++++++++++++ crates/nres/src/lib.rs | 80 ++++- crates/rsli/src/lib.rs | 86 ++++- crates/rsli/src/parse.rs | 39 ++- 6 files changed, 701 insertions(+), 35 deletions(-) create mode 100644 apps/resource-viewer/Cargo.toml create mode 100644 apps/resource-viewer/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 34c501a..e508408 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/*"] +members = ["crates/*", "apps/resource-viewer"] [profile.release] codegen-units = 1 diff --git a/apps/resource-viewer/Cargo.toml b/apps/resource-viewer/Cargo.toml new file mode 100644 index 0000000..51eef01 --- /dev/null +++ b/apps/resource-viewer/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "resource-viewer" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +iced = "0.13" +rfd = "0.15" +nres = { path = "../../crates/nres" } +rsli = { path = "../../crates/rsli" } diff --git a/apps/resource-viewer/src/main.rs b/apps/resource-viewer/src/main.rs new file mode 100644 index 0000000..508c407 --- /dev/null +++ b/apps/resource-viewer/src/main.rs @@ -0,0 +1,518 @@ +use iced::widget::{button, column, container, horizontal_space, row, scrollable, text}; +use iced::{application, Element, Length, Task, Theme}; +use rfd::FileDialog; +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; + +fn main() -> iced::Result { + application("Parkan Resource Viewer", update, view) + .theme(theme) + .run_with(|| (ViewerApp::default(), Task::none())) +} + +fn theme(_state: &ViewerApp) -> Theme { + Theme::Light +} + +#[derive(Debug, Default)] +struct ViewerApp { + document: Option, + status: String, +} + +#[derive(Debug, Clone)] +enum Message { + OpenRequested, + SelectNode(Selection), +} + +fn update(state: &mut ViewerApp, message: Message) -> Task { + match message { + Message::OpenRequested => { + if let Some(path) = pick_archive_file() { + match load_document(&path) { + Ok(document) => { + state.status = + format!("Loaded {} as {}", path.display(), document.format.label()); + state.document = Some(document); + } + Err(err) => { + state.status = err; + } + } + } + } + Message::SelectNode(selection) => { + if let Some(document) = state.document.as_mut() { + document.selected = selection; + } + } + } + + Task::none() +} + +fn view(state: &ViewerApp) -> Element<'_, Message> { + let top_bar = row![ + button("Open archive").on_press(Message::OpenRequested), + text(status_text(state)).size(14) + ] + .spacing(12); + + let content = if let Some(document) = &state.document { + view_document(document) + } else { + container(text("Open an .nres/.rsli/.lib archive to start.").size(16)) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() + }; + + container(column![top_bar, content].spacing(12).padding(12)) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn status_text(state: &ViewerApp) -> String { + if state.status.is_empty() { + String::from("Ready") + } else { + state.status.clone() + } +} + +fn view_document(document: &DocumentModel) -> Element<'_, Message> { + let mut tree = column![text("Archive tree").size(18)].spacing(6); + for item in &document.tree_rows { + let indent = horizontal_space().width(Length::Fixed(f32::from(item.depth) * 16.0)); + + let line = row![indent, text(&item.label).size(14)].spacing(6); + if let Some(selection) = item.selection { + let mut node_button = button(line) + .width(Length::Fill) + .on_press(Message::SelectNode(selection)); + + if selection == document.selected { + node_button = node_button.style(button::primary); + } + + tree = tree.push(node_button); + } else { + tree = tree.push(line); + } + } + + let (panel_title, fields) = selected_fields(document); + let mut fields_column = column![text(panel_title).size(18)].spacing(8); + + for field in fields { + fields_column = fields_column.push( + row![ + text(&field.key).size(14).width(Length::Fixed(220.0)), + text(&field.value).size(14).width(Length::Fill) + ] + .spacing(12), + ); + } + + let left = container(scrollable(tree)) + .width(Length::FillPortion(2)) + .height(Length::Fill); + + let right = container(scrollable(fields_column)) + .width(Length::FillPortion(5)) + .height(Length::Fill); + + row![left, right].spacing(12).height(Length::Fill).into() +} + +fn selected_fields(document: &DocumentModel) -> (String, &[FieldRow]) { + match document.selected { + Selection::Archive => ( + format!( + "{} fields ({})", + document.format.label(), + document.path.display() + ), + &document.archive_fields, + ), + Selection::Entry(index) => { + if let Some(entry) = document.entries.get(index) { + (entry.panel_title.clone(), &entry.fields) + } else { + (String::from("Entry"), &[]) + } + } + } +} + +fn pick_archive_file() -> Option { + FileDialog::new() + .set_title("Open Parkan archive") + .pick_file() +} + +fn load_document(path: &Path) -> Result { + let bytes = + fs::read(path).map_err(|err| format!("Failed to read {}: {err}", path.display()))?; + let Some(format) = detect_archive_format(&bytes) else { + return Err(format!( + "{} is not recognized as NRes/RsLi (unsupported magic).", + path.display() + )); + }; + + match format { + ArchiveFormat::Nres => load_nres_document(path), + ArchiveFormat::Rsli => load_rsli_document(path), + } +} + +fn detect_archive_format(bytes: &[u8]) -> Option { + if bytes.len() >= 4 && &bytes[0..4] == b"NRes" { + return Some(ArchiveFormat::Nres); + } + + if bytes.len() >= 2 && &bytes[0..2] == b"NL" { + return Some(ArchiveFormat::Rsli); + } + + None +} + +fn load_nres_document(path: &Path) -> Result { + let archive = nres::Archive::open_path(path) + .map_err(|err| format!("NRes open failed for {}: {err}", path.display()))?; + + let info = archive.info(); + let mut archive_fields = vec![ + FieldRow::new("format", "NRes"), + FieldRow::new("file_size", info.file_size.to_string()), + FieldRow::new("raw_mode", info.raw_mode.to_string()), + ]; + + if let Some(header) = &info.header { + archive_fields.push(FieldRow::new( + "magic", + String::from_utf8_lossy(&header.magic).into_owned(), + )); + archive_fields.push(FieldRow::new("version", format_u32_dec_hex(header.version))); + archive_fields.push(FieldRow::new("entry_count", header.entry_count.to_string())); + archive_fields.push(FieldRow::new( + "total_size", + format!("{} (0x{:08X})", header.total_size, header.total_size), + )); + archive_fields.push(FieldRow::new( + "directory_offset", + header.directory_offset.to_string(), + )); + archive_fields.push(FieldRow::new( + "directory_size", + header.directory_size.to_string(), + )); + } + + let mut entries = Vec::new(); + for entry in archive.entries_inspect() { + let meta = entry.meta; + let mut fields = vec![ + FieldRow::new("id", entry.id.0.to_string()), + FieldRow::new("name", meta.name.clone()), + FieldRow::new("type_id", format_u32_dec_hex(meta.kind)), + FieldRow::new("attr1", format_u32_dec_hex(meta.attr1)), + FieldRow::new("attr2", format_u32_dec_hex(meta.attr2)), + FieldRow::new("attr3", format_u32_dec_hex(meta.attr3)), + FieldRow::new("data_offset", meta.data_offset.to_string()), + FieldRow::new("data_size", meta.data_size.to_string()), + FieldRow::new("sort_index", meta.sort_index.to_string()), + FieldRow::new("name_raw_hex", bytes_as_hex(entry.name_raw)), + FieldRow::new("name_raw_ascii", bytes_as_ascii(entry.name_raw)), + ]; + + fields.push(FieldRow::new("find_key", meta.name.to_ascii_lowercase())); + + entries.push(EntryView { + full_name: meta.name.clone(), + panel_title: format!("NRes entry #{}: {}", entry.id.0, meta.name), + fields, + }); + } + + let tree_rows = build_tree_rows(&entries); + + Ok(DocumentModel { + path: path.to_path_buf(), + format: ArchiveFormat::Nres, + archive_fields, + entries, + tree_rows, + selected: Selection::Archive, + }) +} + +fn load_rsli_document(path: &Path) -> Result { + let library = rsli::Library::open_path(path) + .map_err(|err| format!("RsLi open failed for {}: {err}", path.display()))?; + + let header = library.header(); + let mut archive_fields = vec![ + FieldRow::new("format", "RsLi"), + FieldRow::new("magic", String::from_utf8_lossy(&header.magic).into_owned()), + FieldRow::new( + "reserved", + format!("{} (0x{:02X})", header.reserved, header.reserved), + ), + FieldRow::new( + "version", + format!("{} (0x{:02X})", header.version, header.version), + ), + FieldRow::new("entry_count", header.entry_count.to_string()), + FieldRow::new("presorted_flag", format!("0x{:04X}", header.presorted_flag)), + FieldRow::new("xor_seed", format!("0x{:08X}", header.xor_seed)), + FieldRow::new("header_raw_hex", bytes_as_hex(&header.raw)), + ]; + + if let Some(ao) = library.ao_trailer() { + archive_fields.push(FieldRow::new("ao_trailer", "present")); + archive_fields.push(FieldRow::new("ao_overlay", ao.overlay.to_string())); + archive_fields.push(FieldRow::new("ao_raw_hex", bytes_as_hex(&ao.raw))); + } else { + archive_fields.push(FieldRow::new("ao_trailer", "absent")); + } + + let mut entries = Vec::new(); + for entry in library.entries_inspect() { + let meta = entry.meta; + let method_raw = (meta.flags as u16 as u32) & 0x1E0; + + let fields = vec![ + FieldRow::new("id", entry.id.0.to_string()), + FieldRow::new("name", meta.name.clone()), + FieldRow::new( + "flags", + format!("{} (0x{:04X})", meta.flags, meta.flags as u16), + ), + FieldRow::new("method", format!("{:?}", meta.method)), + FieldRow::new("method_raw", format!("0x{:03X}", method_raw)), + FieldRow::new("packed_size", meta.packed_size.to_string()), + FieldRow::new("unpacked_size", meta.unpacked_size.to_string()), + FieldRow::new("data_offset_effective", meta.data_offset.to_string()), + FieldRow::new("data_offset_raw", entry.data_offset_raw.to_string()), + FieldRow::new("sort_to_original", entry.sort_to_original.to_string()), + FieldRow::new("name_raw_hex", bytes_as_hex(entry.name_raw)), + FieldRow::new("name_raw_ascii", bytes_as_ascii(entry.name_raw)), + FieldRow::new("service_tail_hex", bytes_as_hex(entry.service_tail)), + FieldRow::new("service_tail_ascii", bytes_as_ascii(entry.service_tail)), + ]; + + entries.push(EntryView { + full_name: meta.name.clone(), + panel_title: format!("RsLi entry #{}: {}", entry.id.0, meta.name), + fields, + }); + } + + let tree_rows = build_tree_rows(&entries); + + Ok(DocumentModel { + path: path.to_path_buf(), + format: ArchiveFormat::Rsli, + archive_fields, + entries, + tree_rows, + selected: Selection::Archive, + }) +} + +fn build_tree_rows(entries: &[EntryView]) -> Vec { + let mut root = FolderNode::default(); + for (index, entry) in entries.iter().enumerate() { + insert_tree_path(&mut root, &entry.full_name, index); + } + + let mut rows = vec![TreeRow { + depth: 0, + label: String::from("[Archive fields]"), + selection: Some(Selection::Archive), + }]; + + flatten_tree(&root, 0, &mut rows); + rows +} + +fn insert_tree_path(root: &mut FolderNode, full_name: &str, entry_index: usize) { + let mut parts: Vec<&str> = full_name + .split(['/', '\\']) + .filter(|part| !part.is_empty()) + .collect(); + + if parts.is_empty() { + parts.push(full_name); + } + + if parts.len() == 1 { + root.files.push((parts[0].to_string(), entry_index)); + return; + } + + let file_name = parts.pop().unwrap_or(full_name); + let mut node = root; + for part in parts { + node = node.folders.entry(part.to_string()).or_default(); + } + + node.files.push((file_name.to_string(), entry_index)); +} + +fn flatten_tree(node: &FolderNode, depth: u16, out: &mut Vec) { + for (folder_name, folder_node) in &node.folders { + out.push(TreeRow { + depth, + label: format!("{folder_name}/"), + selection: None, + }); + flatten_tree(folder_node, depth.saturating_add(1), out); + } + + let mut files = node.files.clone(); + files.sort_by(|left, right| left.0.cmp(&right.0)); + + for (name, index) in files { + out.push(TreeRow { + depth, + label: name, + selection: Some(Selection::Entry(index)), + }); + } +} + +fn bytes_as_hex(bytes: &[u8]) -> String { + let mut out = String::new(); + for (index, byte) in bytes.iter().enumerate() { + if index > 0 { + out.push(' '); + } + let _ = write!(&mut out, "{byte:02X}"); + } + out +} + +fn bytes_as_ascii(bytes: &[u8]) -> String { + bytes + .iter() + .map(|byte| { + if byte.is_ascii_graphic() || *byte == b' ' { + char::from(*byte) + } else { + '.' + } + }) + .collect() +} + +fn format_u32_dec_hex(value: u32) -> String { + format!("{} (0x{:08X})", value, value) +} + +#[derive(Debug, Clone)] +struct DocumentModel { + path: PathBuf, + format: ArchiveFormat, + archive_fields: Vec, + entries: Vec, + tree_rows: Vec, + selected: Selection, +} + +#[derive(Debug, Clone, Copy)] +enum ArchiveFormat { + Nres, + Rsli, +} + +impl ArchiveFormat { + fn label(self) -> &'static str { + match self { + Self::Nres => "NRes", + Self::Rsli => "RsLi", + } + } +} + +#[derive(Debug, Clone)] +struct EntryView { + full_name: String, + panel_title: String, + fields: Vec, +} + +#[derive(Debug, Clone)] +struct FieldRow { + key: String, + value: String, +} + +impl FieldRow { + fn new(key: impl Into, value: impl Into) -> Self { + Self { + key: key.into(), + value: value.into(), + } + } +} + +#[derive(Debug, Clone)] +struct TreeRow { + depth: u16, + label: String, + selection: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Selection { + Archive, + Entry(usize), +} + +#[derive(Default, Debug)] +struct FolderNode { + folders: BTreeMap, + files: Vec<(String, usize)>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tree_builds_nested_paths() { + let entries = vec![ + EntryView { + full_name: String::from("textures/ui/hud.texm"), + panel_title: String::new(), + fields: vec![], + }, + EntryView { + full_name: String::from("textures/world/ground.texm"), + panel_title: String::new(), + fields: vec![], + }, + EntryView { + full_name: String::from("root_file.msh"), + panel_title: String::new(), + fields: vec![], + }, + ]; + + let rows = build_tree_rows(&entries); + assert!(rows.iter().any(|row| row.label == "textures/")); + assert!(rows.iter().any(|row| row.label == "ui/")); + assert!(rows.iter().any(|row| row.label == "hud.texm")); + assert!(rows.iter().any(|row| row.label == "root_file.msh")); + } +} diff --git a/crates/nres/src/lib.rs b/crates/nres/src/lib.rs index 69cb814..571b395 100644 --- a/crates/nres/src/lib.rs +++ b/crates/nres/src/lib.rs @@ -26,10 +26,28 @@ pub enum OpenMode { ReadWrite, } +#[derive(Clone, Debug)] +pub struct ArchiveHeader { + pub magic: [u8; 4], + pub version: u32, + pub entry_count: u32, + pub total_size: u32, + pub directory_offset: u64, + pub directory_size: u64, +} + +#[derive(Clone, Debug)] +pub struct ArchiveInfo { + pub raw_mode: bool, + pub file_size: u64, + pub header: Option, +} + #[derive(Debug)] pub struct Archive { bytes: Arc<[u8]>, entries: Vec, + info: ArchiveInfo, raw_mode: bool, } @@ -54,6 +72,13 @@ pub struct EntryRef<'a> { pub meta: &'a EntryMeta, } +#[derive(Copy, Clone, Debug)] +pub struct EntryInspect<'a> { + pub id: EntryId, + pub meta: &'a EntryMeta, + pub name_raw: &'a [u8; 36], +} + #[derive(Clone, Debug)] struct EntryRecord { meta: EntryMeta, @@ -76,17 +101,27 @@ impl Archive { } pub fn open_bytes(bytes: Arc<[u8]>, opts: OpenOptions) -> Result { - let (entries, _) = parse_archive(&bytes, opts.raw_mode)?; + let file_size = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?; + let (entries, header) = parse_archive(&bytes, opts.raw_mode)?; if opts.prefetch_pages { prefetch_pages(&bytes); } Ok(Self { bytes, entries, + info: ArchiveInfo { + raw_mode: opts.raw_mode, + file_size, + header, + }, raw_mode: opts.raw_mode, }) } + pub fn info(&self) -> &ArchiveInfo { + &self.info + } + pub fn entry_count(&self) -> usize { self.entries.len() } @@ -101,6 +136,17 @@ impl Archive { }) } + pub fn entries_inspect(&self) -> impl Iterator> { + self.entries.iter().enumerate().filter_map(|(idx, entry)| { + let id = u32::try_from(idx).ok()?; + Some(EntryInspect { + id: EntryId(id), + meta: &entry.meta, + name_raw: &entry.name_raw, + }) + }) + } + pub fn find(&self, name: &str) -> Option { if self.entries.is_empty() { return None; @@ -153,6 +199,16 @@ impl Archive { }) } + pub fn inspect(&self, id: EntryId) -> Option> { + let idx = usize::try_from(id.0).ok()?; + let entry = self.entries.get(idx)?; + Some(EntryInspect { + id, + meta: &entry.meta, + name_raw: &entry.name_raw, + }) + } + pub fn read(&self, id: EntryId) -> Result> { let range = self.entry_range(id)?; Ok(ResourceData::Borrowed(&self.bytes[range])) @@ -377,7 +433,10 @@ impl Editor { } } -fn parse_archive(bytes: &[u8], raw_mode: bool) -> Result<(Vec, u64)> { +fn parse_archive( + bytes: &[u8], + raw_mode: bool, +) -> Result<(Vec, Option)> { if raw_mode { let data_size = u32::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?; let entry = EntryRecord { @@ -398,10 +457,7 @@ fn parse_archive(bytes: &[u8], raw_mode: bool) -> Result<(Vec, u64) name }, }; - return Ok(( - vec![entry], - u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?, - )); + return Ok((vec![entry], None)); } if bytes.len() < 16 { @@ -526,7 +582,17 @@ fn parse_archive(bytes: &[u8], raw_mode: bool) -> Result<(Vec, u64) }); } - Ok((entries, directory_offset)) + Ok(( + entries, + Some(ArchiveHeader { + magic: *b"NRes", + version, + entry_count: u32::try_from(entry_count).map_err(|_| Error::IntegerOverflow)?, + total_size, + directory_offset, + directory_size: directory_len, + }), + )) } fn checked_range(offset: u64, size: u32, bytes_len: usize) -> Result> { diff --git a/crates/rsli/src/lib.rs b/crates/rsli/src/lib.rs index d38abe8..1ce3b1f 100644 --- a/crates/rsli/src/lib.rs +++ b/crates/rsli/src/lib.rs @@ -30,20 +30,33 @@ impl Default for OpenOptions { } } +#[derive(Clone, Debug)] +pub struct LibraryHeader { + pub raw: [u8; 32], + pub magic: [u8; 2], + pub reserved: u8, + pub version: u8, + pub entry_count: i16, + pub presorted_flag: u16, + pub xor_seed: u32, +} + +#[derive(Clone, Debug)] +pub struct AoTrailer { + pub raw: [u8; 6], + pub overlay: u32, +} + #[derive(Debug)] pub struct Library { bytes: Arc<[u8]>, entries: Vec, - #[cfg(test)] - pub(crate) header_raw: [u8; 32], + header: LibraryHeader, + ao_trailer: Option, #[cfg(test)] pub(crate) table_plain_original: Vec, #[cfg(test)] - pub(crate) xor_seed: u32, - #[cfg(test)] pub(crate) source_size: usize, - #[cfg(test)] - pub(crate) trailer_raw: Option<[u8; 6]>, } #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] @@ -77,6 +90,16 @@ pub struct EntryRef<'a> { pub meta: &'a EntryMeta, } +#[derive(Copy, Clone, Debug)] +pub struct EntryInspect<'a> { + pub id: EntryId, + pub meta: &'a EntryMeta, + pub name_raw: &'a [u8; 12], + pub service_tail: &'a [u8; 4], + pub sort_to_original: i16, + pub data_offset_raw: u32, +} + pub struct PackedResource { pub meta: EntryMeta, pub packed: Vec, @@ -86,9 +109,9 @@ pub struct PackedResource { pub(crate) struct EntryRecord { pub(crate) meta: EntryMeta, pub(crate) name_raw: [u8; 12], + pub(crate) service_tail: [u8; 4], pub(crate) sort_to_original: i16, pub(crate) key16: u16, - #[cfg(test)] pub(crate) data_offset_raw: u32, pub(crate) packed_size_declared: u32, pub(crate) packed_size_available: usize, @@ -106,6 +129,14 @@ impl Library { parse_library(arc, opts) } + pub fn header(&self) -> &LibraryHeader { + &self.header + } + + pub fn ao_trailer(&self) -> Option<&AoTrailer> { + self.ao_trailer.as_ref() + } + pub fn entry_count(&self) -> usize { self.entries.len() } @@ -120,6 +151,20 @@ impl Library { }) } + pub fn entries_inspect(&self) -> impl Iterator> { + self.entries.iter().enumerate().filter_map(|(idx, entry)| { + let id = u32::try_from(idx).ok()?; + Some(EntryInspect { + id: EntryId(id), + meta: &entry.meta, + name_raw: &entry.name_raw, + service_tail: &entry.service_tail, + sort_to_original: entry.sort_to_original, + data_offset_raw: entry.data_offset_raw, + }) + }) + } + pub fn find(&self, name: &str) -> Option { if self.entries.is_empty() { return None; @@ -187,6 +232,19 @@ impl Library { }) } + pub fn inspect(&self, id: EntryId) -> Option> { + let idx = usize::try_from(id.0).ok()?; + let entry = self.entries.get(idx)?; + Some(EntryInspect { + id, + meta: &entry.meta, + name_raw: &entry.name_raw, + service_tail: &entry.service_tail, + sort_to_original: entry.sort_to_original, + data_offset_raw: entry.data_offset_raw, + }) + } + pub fn load(&self, id: EntryId) -> Result> { let entry = self.entry_by_id(id)?; let packed = self.packed_slice(id, entry)?; @@ -284,7 +342,7 @@ impl Library { #[cfg(test)] pub(crate) fn rebuild_from_parsed_metadata(&self) -> Result> { - let trailer_len = usize::from(self.trailer_raw.is_some()) * 6; + let trailer_len = usize::from(self.ao_trailer.is_some()) * 6; let pre_trailer_size = self .source_size .checked_sub(trailer_len) @@ -304,9 +362,11 @@ impl Library { } let mut out = vec![0u8; pre_trailer_size]; - out[0..32].copy_from_slice(&self.header_raw); - let encrypted_table = - xor_stream(&self.table_plain_original, (self.xor_seed & 0xFFFF) as u16); + out[0..32].copy_from_slice(&self.header.raw); + let encrypted_table = xor_stream( + &self.table_plain_original, + (self.header.xor_seed & 0xFFFF) as u16, + ); out[32..table_end].copy_from_slice(&encrypted_table); let mut occupied = vec![false; pre_trailer_size]; @@ -337,8 +397,8 @@ impl Library { } } - if let Some(trailer) = self.trailer_raw { - out.extend_from_slice(&trailer); + if let Some(trailer) = &self.ao_trailer { + out.extend_from_slice(&trailer.raw); } Ok(out) } diff --git a/crates/rsli/src/parse.rs b/crates/rsli/src/parse.rs index db593e2..d3afcd9 100644 --- a/crates/rsli/src/parse.rs +++ b/crates/rsli/src/parse.rs @@ -1,6 +1,8 @@ use crate::compress::xor::xor_stream; use crate::error::Error; -use crate::{EntryMeta, EntryRecord, Library, OpenOptions, PackMethod, Result}; +use crate::{ + AoTrailer, EntryMeta, EntryRecord, Library, LibraryHeader, OpenOptions, PackMethod, Result, +}; use std::cmp::Ordering; use std::sync::Arc; @@ -16,13 +18,17 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result { let mut header_raw = [0u8; 32]; header_raw.copy_from_slice(&bytes[0..32]); - if &bytes[0..2] != b"NL" { + let mut magic = [0u8; 2]; + magic.copy_from_slice(&bytes[0..2]); + if &magic != b"NL" { let mut got = [0u8; 2]; got.copy_from_slice(&bytes[0..2]); return Err(Error::InvalidMagic { got }); } - if bytes[3] != 0x01 { - return Err(Error::UnsupportedVersion { got: bytes[3] }); + let reserved = bytes[2]; + let version = bytes[3]; + if version != 0x01 { + return Err(Error::UnsupportedVersion { got: version }); } let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]); @@ -36,7 +42,17 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result { return Err(Error::TooManyEntries { got: count }); } + let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]); let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); + let header = LibraryHeader { + raw: header_raw, + magic, + reserved, + version, + entry_count, + presorted_flag, + xor_seed, + }; let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?; let table_offset = 32usize; @@ -58,8 +74,6 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result { } let (overlay, trailer_raw) = parse_ao_trailer(&bytes, opts.allow_ao_trailer)?; - #[cfg(not(test))] - let _ = trailer_raw; let mut entries = Vec::with_capacity(count); for idx in 0..count { @@ -67,6 +81,8 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result { let mut name_raw = [0u8; 12]; name_raw.copy_from_slice(&row[0..12]); + let mut service_tail = [0u8; 4]; + service_tail.copy_from_slice(&row[12..16]); let flags_signed = i16::from_le_bytes([row[16], row[17]]); let sort_to_original = i16::from_le_bytes([row[18], row[19]]); @@ -137,9 +153,9 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result { unpacked_size, }, name_raw, + service_tail, sort_to_original, key16: sort_to_original as u16, - #[cfg(test)] data_offset_raw, packed_size_declared, packed_size_available, @@ -147,7 +163,6 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result { }); } - let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]); if presorted_flag == 0xABBA { let mut seen = vec![false; count]; for entry in &entries { @@ -196,16 +211,12 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result { Ok(Library { bytes, entries, - #[cfg(test)] - header_raw, + header, + ao_trailer: trailer_raw.map(|raw| AoTrailer { raw, overlay }), #[cfg(test)] table_plain_original, #[cfg(test)] - xor_seed, - #[cfg(test)] source_size, - #[cfg(test)] - trailer_raw, }) }