aboutsummaryrefslogtreecommitdiff
path: root/apps/resource-viewer/src/main.rs
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-02-19 13:51:54 +0300
committerValentin Popov <valentin@popov.link>2026-02-19 13:51:54 +0300
commit598137ed132d95a3e3bf9b95e9e27286cc2186ac (patch)
treed237acbbde569fc9e4143af4945fc6d0d6cbc2d4 /apps/resource-viewer/src/main.rs
parentcb0ca2f2f0775e62365e873a2dcce5b93082ac9a (diff)
downloadfparkan-598137ed132d95a3e3bf9b95e9e27286cc2186ac.tar.xz
fparkan-598137ed132d95a3e3bf9b95e9e27286cc2186ac.zip
feat(resource-viewer): добавить новый ресурсный просмотрщик с базовой функциональностью
feat(nres): улучшить структуру архива с добавлением заголовка и информации о записях feat(rsli): добавить поддержку заголовка библиотеки и улучшить обработку записей
Diffstat (limited to 'apps/resource-viewer/src/main.rs')
-rw-r--r--apps/resource-viewer/src/main.rs518
1 files changed, 518 insertions, 0 deletions
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<DocumentModel>,
+ status: String,
+}
+
+#[derive(Debug, Clone)]
+enum Message {
+ OpenRequested,
+ SelectNode(Selection),
+}
+
+fn update(state: &mut ViewerApp, message: Message) -> Task<Message> {
+ 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<PathBuf> {
+ FileDialog::new()
+ .set_title("Open Parkan archive")
+ .pick_file()
+}
+
+fn load_document(path: &Path) -> Result<DocumentModel, String> {
+ 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<ArchiveFormat> {
+ 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<DocumentModel, String> {
+ 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<DocumentModel, String> {
+ 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<TreeRow> {
+ 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<TreeRow>) {
+ 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<FieldRow>,
+ entries: Vec<EntryView>,
+ tree_rows: Vec<TreeRow>,
+ 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<FieldRow>,
+}
+
+#[derive(Debug, Clone)]
+struct FieldRow {
+ key: String,
+ value: String,
+}
+
+impl FieldRow {
+ fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
+ Self {
+ key: key.into(),
+ value: value.into(),
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+struct TreeRow {
+ depth: u16,
+ label: String,
+ selection: Option<Selection>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Selection {
+ Archive,
+ Entry(usize),
+}
+
+#[derive(Default, Debug)]
+struct FolderNode {
+ folders: BTreeMap<String, FolderNode>,
+ 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"));
+ }
+}