aboutsummaryrefslogtreecommitdiff
path: root/apps/fparkan-viewer
diff options
context:
space:
mode:
Diffstat (limited to 'apps/fparkan-viewer')
-rw-r--r--apps/fparkan-viewer/Cargo.toml19
-rw-r--r--apps/fparkan-viewer/src/main.rs353
2 files changed, 372 insertions, 0 deletions
diff --git a/apps/fparkan-viewer/Cargo.toml b/apps/fparkan-viewer/Cargo.toml
new file mode 100644
index 0000000..4219e8a
--- /dev/null
+++ b/apps/fparkan-viewer/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "fparkan-viewer"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+fparkan-msh = { path = "../../crates/fparkan-msh" }
+fparkan-nres = { path = "../../crates/fparkan-nres" }
+fparkan-resource = { path = "../../crates/fparkan-resource" }
+fparkan-render = { path = "../../crates/fparkan-render" }
+fparkan-rsli = { path = "../../crates/fparkan-rsli" }
+fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format" }
+fparkan-texm = { path = "../../crates/fparkan-texm" }
+fparkan-vfs = { path = "../../crates/fparkan-vfs" }
+
+[lints]
+workspace = true
diff --git a/apps/fparkan-viewer/src/main.rs b/apps/fparkan-viewer/src/main.rs
new file mode 100644
index 0000000..1720cd7
--- /dev/null
+++ b/apps/fparkan-viewer/src/main.rs
@@ -0,0 +1,353 @@
+#![forbid(unsafe_code)]
+#![allow(clippy::print_stderr, clippy::print_stdout)]
+//! `FParkan` asset viewer composition root.
+
+use fparkan_msh::{decode_msh, validate_msh};
+use fparkan_nres::{decode as decode_nres, ReadProfile as NresReadProfile};
+use fparkan_render::{
+ build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderPhase,
+ RenderProfile, RenderSnapshot, RenderSnapshotDraw,
+};
+use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
+use fparkan_terrain_format::{decode_land_map, decode_land_msh};
+use fparkan_texm::decode_texm;
+use fparkan_vfs::DirectoryVfs;
+use std::fmt::Write;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+fn main() {
+ let args = std::env::args().skip(1).collect::<Vec<_>>();
+ let code = match run(&args) {
+ Ok(json) => {
+ println!("{json}");
+ 0
+ }
+ Err(err) => {
+ eprintln!("{err}");
+ 2
+ }
+ };
+ std::process::exit(code);
+}
+
+fn run(args: &[String]) -> Result<String, String> {
+ match args {
+ [domain, rest @ ..] if domain == "archive" => inspect_archive(rest),
+ [domain, rest @ ..] if domain == "model" => inspect_model(rest),
+ [domain, rest @ ..] if domain == "texture" => inspect_texture(rest),
+ [domain, rest @ ..] if domain == "map" => inspect_map(rest),
+ _ => Err(usage()),
+ }
+}
+
+fn inspect_archive(args: &[String]) -> Result<String, String> {
+ let file = parse_file(args)?;
+ let limit = parse_limit(args)?;
+ let bytes = std::fs::read(&file).map_err(|err| format!("{}: {err}", file.display()))?;
+ if bytes.starts_with(b"NRes") {
+ let document = decode_nres(
+ Arc::from(bytes.into_boxed_slice()),
+ NresReadProfile::Compatible,
+ )
+ .map_err(|err| err.to_string())?;
+ let sample = render_nres_entries(&document, limit);
+ return Ok(format!(
+ "{{\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}",
+ json_string(&file.display().to_string()),
+ document.entries().len(),
+ document.lookup_order_valid(),
+ sample
+ ));
+ }
+ if bytes.get(0..4) == Some(b"NL\0\x01") {
+ let document = fparkan_rsli::decode(
+ Arc::from(bytes.into_boxed_slice()),
+ fparkan_rsli::ReadProfile::Compatible,
+ )
+ .map_err(|err| err.to_string())?;
+ return Ok(format!(
+ "{{\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}",
+ json_string(&file.display().to_string()),
+ document.entries().len()
+ ));
+ }
+ Err(format!("{}: unsupported archive magic", file.display()))
+}
+
+fn inspect_model(args: &[String]) -> Result<String, String> {
+ if let Some(fixture) = parse_option(args, &["--fixture"]) {
+ return ViewerModelService::inspect_synthetic_model(&fixture);
+ }
+
+ let query = parse_resource_query(args)?;
+ let bytes = read_resource(&query)?;
+ let nested = decode_nres(bytes, NresReadProfile::Compatible).map_err(|err| err.to_string())?;
+ let document = decode_msh(&nested).map_err(|err| err.to_string())?;
+ let model = validate_msh(&document).map_err(|err| err.to_string())?;
+
+ Ok(format!(
+ "{{\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}",
+ json_string(&query.archive),
+ json_string(&query.name),
+ document.streams().len(),
+ model.node_count,
+ model.slots.len(),
+ model.positions.len(),
+ model.indices.len(),
+ model.batches.len()
+ ))
+}
+
+#[derive(Clone, Debug)]
+struct ViewerModelService;
+
+impl ViewerModelService {
+ fn inspect_synthetic_model(fixture: &str) -> Result<String, String> {
+ if fixture != "synthetic/model-basic" {
+ return Err(format!("unknown model fixture: {fixture}"));
+ }
+
+ let snapshot = RenderSnapshot {
+ camera: CameraSnapshot::default(),
+ draws: vec![RenderSnapshotDraw {
+ id: DrawId(1),
+ phase: RenderPhase::Opaque,
+ object_id: None,
+ mesh: GpuMeshId(1),
+ material_slots: vec![GpuMaterialId(7)],
+ material_index: 0,
+ transform: identity_transform(),
+ range: IndexRange { start: 0, count: 3 },
+ stable_order: 0,
+ }],
+ };
+ let commands = build_commands(&snapshot, RenderProfile::default())
+ .map_err(|err| format!("render command generation: {err}"))?;
+ let draw_commands = commands
+ .commands
+ .iter()
+ .filter(|command| matches!(command, fparkan_render::RenderCommand::Draw(_)))
+ .count();
+
+ Ok(format!(
+ "{{\"kind\":\"model\",\"fixture\":{},\"service\":\"synthetic-model\",\"draw_commands\":{draw_commands}}}",
+ json_string(fixture)
+ ))
+ }
+}
+
+fn inspect_texture(args: &[String]) -> Result<String, String> {
+ let query = parse_resource_query(args)?;
+ let document = decode_texm(read_resource(&query)?).map_err(|err| err.to_string())?;
+
+ Ok(format!(
+ "{{\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}",
+ json_string(&query.archive),
+ json_string(&query.name),
+ document.width(),
+ document.height(),
+ json_string(&format!("{:?}", document.format())),
+ document.mip_count(),
+ document.page_rects().len()
+ ))
+}
+
+fn inspect_map(args: &[String]) -> Result<String, String> {
+ let file = parse_file(args)?;
+ let kind = parse_option(args, &["--kind"]).ok_or_else(|| "missing --kind".to_string())?;
+ let bytes = std::fs::read(&file).map_err(|err| format!("{}: {err}", file.display()))?;
+ let nres = decode_nres(
+ Arc::from(bytes.into_boxed_slice()),
+ NresReadProfile::Compatible,
+ )
+ .map_err(|err| err.to_string())?;
+
+ match kind.as_str() {
+ "land-msh" => {
+ let land = decode_land_msh(&nres).map_err(|err| err.to_string())?;
+ Ok(format!(
+ "{{\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}",
+ json_string(&file.display().to_string()),
+ land.streams.len(),
+ land.positions.len(),
+ land.faces.len(),
+ land.slots.slots_raw.len()
+ ))
+ }
+ "land-map" => {
+ let land = decode_land_map(&nres).map_err(|err| err.to_string())?;
+ Ok(format!(
+ "{{\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}",
+ json_string(&file.display().to_string()),
+ land.areals.len(),
+ land.areal_count,
+ land.grid.cells_x,
+ land.grid.cells_y
+ ))
+ }
+ _ => Err(format!("unknown map kind: {kind}")),
+ }
+}
+
+struct ResourceQuery {
+ root: PathBuf,
+ archive: String,
+ name: String,
+}
+
+fn parse_resource_query(args: &[String]) -> Result<ResourceQuery, String> {
+ Ok(ResourceQuery {
+ root: parse_path_option(args, &["--root", "--game-root"], "--root")?,
+ archive: parse_option(args, &["--archive"])
+ .ok_or_else(|| "missing --archive".to_string())?,
+ name: parse_option(args, &["--name"]).ok_or_else(|| "missing --name".to_string())?,
+ })
+}
+
+fn read_resource(query: &ResourceQuery) -> Result<Arc<[u8]>, String> {
+ let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(&query.root)));
+ let archive = repository
+ .open_archive(&archive_path(query.archive.as_bytes()).map_err(|err| err.to_string())?)
+ .map_err(|err| err.to_string())?;
+ let entry = repository
+ .find(archive, &resource_name(query.name.as_bytes()))
+ .map_err(|err| err.to_string())?
+ .ok_or_else(|| format!("resource not found: {}/{}", query.archive, query.name))?;
+ let bytes = repository.read(entry).map_err(|err| err.to_string())?;
+ Ok(Arc::from(bytes.into_owned()))
+}
+
+fn parse_file(args: &[String]) -> Result<PathBuf, String> {
+ parse_path_option(args, &["--file"], "--file")
+}
+
+fn parse_limit(args: &[String]) -> Result<usize, String> {
+ parse_option(args, &["--limit"])
+ .map(|value| {
+ value
+ .parse::<usize>()
+ .map_err(|_| format!("invalid --limit: {value}"))
+ })
+ .transpose()
+ .map(|value| value.unwrap_or(0))
+}
+
+fn render_nres_entries(document: &fparkan_nres::NresDocument, limit: usize) -> String {
+ let mut out = String::new();
+ for (index, entry) in document.entries().iter().take(limit).enumerate() {
+ if index > 0 {
+ out.push(',');
+ }
+ let name = String::from_utf8_lossy(entry.name_bytes());
+ let _ = write!(
+ out,
+ "{{\"name\":{},\"type\":{},\"size\":{}}}",
+ json_string(&name),
+ entry.meta().type_id,
+ entry.meta().data_size
+ );
+ }
+ out
+}
+
+fn parse_path_option(args: &[String], names: &[&str], label: &str) -> Result<PathBuf, String> {
+ parse_option(args, names)
+ .map(PathBuf::from)
+ .ok_or_else(|| format!("missing {label}"))
+}
+
+fn parse_option(args: &[String], names: &[&str]) -> Option<String> {
+ let mut iter = args.iter();
+ while let Some(arg) = iter.next() {
+ if names.iter().any(|name| arg == name) {
+ return iter.next().cloned();
+ }
+ }
+ None
+}
+
+fn json_string(value: &str) -> String {
+ let mut out = String::with_capacity(value.len() + 2);
+ out.push('"');
+ for ch in value.chars() {
+ match ch {
+ '"' => out.push_str("\\\""),
+ '\\' => out.push_str("\\\\"),
+ '\n' => out.push_str("\\n"),
+ '\r' => out.push_str("\\r"),
+ '\t' => out.push_str("\\t"),
+ c if c.is_control() => {
+ let _ = write!(out, "\\u{:04x}", u32::from(c));
+ }
+ c => out.push(c),
+ }
+ }
+ out.push('"');
+ out
+}
+
+fn identity_transform() -> [f32; 16] {
+ [
+ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
+ ]
+}
+
+fn usage() -> String {
+ "usage: fparkan-viewer archive --file <archive> [--limit N] | model --root <game-root> --archive <archive> --name <msh> | model --fixture synthetic/model-basic | texture --root <game-root> --archive <archive> --name <texm> | map --file <Land.msh|Land.map> --kind land-msh|land-map".to_string()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn strings(values: &[&str]) -> Vec<String> {
+ values.iter().map(|value| (*value).to_string()).collect()
+ }
+
+ #[test]
+ fn parses_resource_query() -> Result<(), String> {
+ let query = parse_resource_query(&strings(&[
+ "--root",
+ "testdata/IS",
+ "--archive",
+ "textures.lib",
+ "--name",
+ "grass.tex",
+ ]))?;
+
+ assert_eq!(query.root, PathBuf::from("testdata/IS"));
+ assert_eq!(query.archive, "textures.lib");
+ assert_eq!(query.name, "grass.tex");
+ Ok(())
+ }
+
+ #[test]
+ fn json_string_escapes_controls() {
+ assert_eq!(json_string("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\"");
+ }
+
+ #[test]
+ fn usage_rejects_empty_args() {
+ assert_eq!(run(&[]), Err(usage()));
+ }
+
+ #[test]
+ fn parses_limit() {
+ assert_eq!(parse_limit(&strings(&["--limit", "2"])), Ok(2));
+ assert_eq!(parse_limit(&[]), Ok(0));
+ assert_eq!(
+ parse_limit(&strings(&["--limit", "x"])),
+ Err("invalid --limit: x".to_string())
+ );
+ }
+
+ #[test]
+ fn model_fixture_uses_viewer_service_and_render_commands() -> Result<(), String> {
+ assert_eq!(
+ run(&strings(&["model", "--fixture", "synthetic/model-basic"]))?,
+ "{\"kind\":\"model\",\"fixture\":\"synthetic/model-basic\",\"service\":\"synthetic-model\",\"draw_commands\":1}"
+ );
+ Ok(())
+ }
+}