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.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "fparkan-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-corpus = { path = "../../crates/fparkan-corpus" }
|
||||
fparkan-nres = { path = "../../crates/fparkan-nres" }
|
||||
fparkan-prototype = { path = "../../crates/fparkan-prototype" }
|
||||
fparkan-resource = { path = "../../crates/fparkan-resource" }
|
||||
fparkan-rsli = { path = "../../crates/fparkan-rsli" }
|
||||
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
||||
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,346 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||
//! `FParkan` command-line tools.
|
||||
|
||||
use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
|
||||
use fparkan_prototype::{
|
||||
build_prototype_graph_report, extend_graph_report_with_visual_dependencies,
|
||||
};
|
||||
use fparkan_resource::{resource_name, CachedResourceRepository};
|
||||
use fparkan_runtime::{
|
||||
create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest,
|
||||
};
|
||||
use fparkan_vfs::DirectoryVfs;
|
||||
use std::fmt::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let result = run(&args);
|
||||
let code = exit_code(&result);
|
||||
if let Err(err) = result {
|
||||
eprintln!("{err}");
|
||||
}
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
match args {
|
||||
[domain, command, rest @ ..] if domain == "corpus" && command == "discover" => {
|
||||
let rest = strip_format_json(rest)?;
|
||||
let root = parse_root(&rest)?;
|
||||
let manifest =
|
||||
discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
|
||||
let report = report(&root, &manifest);
|
||||
println!("{}", render_report_json(&report));
|
||||
Ok(())
|
||||
}
|
||||
[domain, command, rest @ ..] if domain == "corpus" && command == "validate" => {
|
||||
let rest = strip_format_json(rest)?;
|
||||
let root = parse_root(&rest)?;
|
||||
let manifest =
|
||||
discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
|
||||
let report = report(&root, &manifest);
|
||||
if report.casefold_collisions > 0 {
|
||||
return Err("casefold collisions found".to_string());
|
||||
}
|
||||
println!("{}", render_report_json(&report));
|
||||
Ok(())
|
||||
}
|
||||
[domain, command, rest @ ..] if domain == "archive" && command == "inspect" => {
|
||||
let rest = strip_format_json(rest)?;
|
||||
inspect_archive(&rest)
|
||||
}
|
||||
[domain, command, rest @ ..] if domain == "prototype" && command == "inspect" => {
|
||||
let rest = strip_format_json(rest)?;
|
||||
inspect_prototype(&rest)
|
||||
}
|
||||
[domain, command, rest @ ..] if domain == "mission" && command == "graph" => {
|
||||
let rest = strip_format_json(rest)?;
|
||||
graph_mission(&rest)
|
||||
}
|
||||
_ => Err(usage()),
|
||||
}
|
||||
}
|
||||
|
||||
fn exit_code(result: &Result<(), String>) -> i32 {
|
||||
if result.is_ok() {
|
||||
0
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_format_json(args: &[String]) -> Result<Vec<String>, String> {
|
||||
let mut stripped = Vec::with_capacity(args.len());
|
||||
let mut iter = args.iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
if arg == "--format" {
|
||||
let value = iter
|
||||
.next()
|
||||
.ok_or_else(|| "--format requires a value".to_string())?;
|
||||
if value != "json" {
|
||||
return Err(format!("unsupported output format: {value}"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
stripped.push(arg.clone());
|
||||
}
|
||||
Ok(stripped)
|
||||
}
|
||||
|
||||
fn parse_root(args: &[String]) -> Result<PathBuf, String> {
|
||||
let mut iter = args.iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
if arg == "--root" {
|
||||
return iter
|
||||
.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| "--root requires a path".to_string());
|
||||
}
|
||||
}
|
||||
Err("missing --root".to_string())
|
||||
}
|
||||
|
||||
fn parse_root_alias(args: &[String]) -> Result<PathBuf, String> {
|
||||
parse_option(args, &["--root", "--game-root"])
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| "missing --root".to_string())
|
||||
}
|
||||
|
||||
fn parse_required(args: &[String], names: &[&str], label: &str) -> Result<String, String> {
|
||||
parse_option(args, names).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 inspect_prototype(args: &[String]) -> Result<(), String> {
|
||||
let root = parse_root_alias(args)?;
|
||||
let key = parse_required(args, &["--key"], "--key")?;
|
||||
let vfs = Arc::new(DirectoryVfs::new(root));
|
||||
let repository = CachedResourceRepository::new(vfs.clone());
|
||||
let roots = [resource_name(key.as_bytes())];
|
||||
let (graph, resolved, mut report) =
|
||||
build_prototype_graph_report(&repository, vfs.as_ref(), &roots);
|
||||
extend_graph_report_with_visual_dependencies(&repository, &mut report, &resolved);
|
||||
println!("{}", prototype_inspect_json(&key, &graph, &report));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prototype_inspect_json(
|
||||
key: &str,
|
||||
graph: &fparkan_prototype::PrototypeGraph,
|
||||
report: &fparkan_prototype::PrototypeGraphReport,
|
||||
) -> String {
|
||||
format!(
|
||||
"{{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":{},\"roots\":{},\"prototype_requests\":{},\"resolved\":{},\"unit_references\":{},\"unit_components\":{},\"direct_references\":{},\"wear\":{},\"materials\":{},\"textures\":{},\"lightmaps\":{},\"failures\":{}}}",
|
||||
json_string(key),
|
||||
report.root_count,
|
||||
graph.prototype_requests.len(),
|
||||
report.resolved_count,
|
||||
report.unit_reference_count,
|
||||
report.unit_component_count,
|
||||
report.direct_reference_count,
|
||||
report.wear_resolved_count,
|
||||
report.material_resolved_count,
|
||||
report.texture_resolved_count,
|
||||
report.lightmap_resolved_count,
|
||||
report.failures.len()
|
||||
)
|
||||
}
|
||||
|
||||
fn graph_mission(args: &[String]) -> Result<(), String> {
|
||||
let root = parse_root_alias(args)?;
|
||||
let mission = parse_required(args, &["--mission"], "--mission")?;
|
||||
let services = EngineServices::new(Arc::new(DirectoryVfs::new(root)));
|
||||
let mut engine = create(
|
||||
EngineConfig {
|
||||
mode: EngineMode::Headless,
|
||||
},
|
||||
services,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let loaded = load_mission(
|
||||
&mut engine,
|
||||
MissionRequest {
|
||||
key: mission.clone(),
|
||||
},
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
println!(
|
||||
"{{\"schema_version\":\"fparkan-mission-graph-v1\",\"mission\":{},\"objects\":{},\"paths\":{},\"clans\":{},\"extras\":{},\"roots\":{},\"direct_references\":{},\"unit_references\":{},\"unit_components\":{},\"prototype_requests\":{},\"wear\":{},\"materials\":{},\"textures\":{},\"lightmaps\":{},\"failures\":{}}}",
|
||||
json_string(&mission),
|
||||
loaded.object_count,
|
||||
loaded.path_count,
|
||||
loaded.clan_count,
|
||||
loaded.extra_count,
|
||||
loaded.graph_root_count,
|
||||
loaded.graph_direct_reference_count,
|
||||
loaded.graph_unit_reference_count,
|
||||
loaded.graph_unit_component_count,
|
||||
loaded.graph_resolved_count,
|
||||
loaded.graph_wear_resolved_count,
|
||||
loaded.graph_material_resolved_count,
|
||||
loaded.graph_texture_resolved_count,
|
||||
loaded.graph_lightmap_resolved_count,
|
||||
loaded.graph_failure_count
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inspect_archive(args: &[String]) -> Result<(), String> {
|
||||
let path = parse_archive_path(args)?;
|
||||
let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?;
|
||||
if bytes.starts_with(b"NRes") {
|
||||
let document = fparkan_nres::decode(
|
||||
Arc::from(bytes.into_boxed_slice()),
|
||||
fparkan_nres::ReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
println!(
|
||||
"{}",
|
||||
archive_inspect_json(
|
||||
&path.display().to_string(),
|
||||
"NRes",
|
||||
document.entries().len(),
|
||||
Some(document.lookup_order_valid()),
|
||||
)
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
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())?;
|
||||
println!(
|
||||
"{}",
|
||||
archive_inspect_json(
|
||||
&path.display().to_string(),
|
||||
"RsLi",
|
||||
document.entries().len(),
|
||||
None
|
||||
)
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!("{}: unsupported archive magic", path.display()))
|
||||
}
|
||||
|
||||
fn archive_inspect_json(
|
||||
path: &str,
|
||||
kind: &str,
|
||||
entries: usize,
|
||||
lookup_order_valid: Option<bool>,
|
||||
) -> String {
|
||||
let mut out = format!(
|
||||
"{{\"schema_version\":\"fparkan-archive-inspect-v1\",\"path\":{},\"kind\":{},\"entries\":{}",
|
||||
json_string(path),
|
||||
json_string(kind),
|
||||
entries
|
||||
);
|
||||
if let Some(valid) = lookup_order_valid {
|
||||
let _ = write!(out, ",\"lookup_order_valid\":{valid}");
|
||||
}
|
||||
out.push('}');
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_archive_path(args: &[String]) -> Result<PathBuf, String> {
|
||||
match args {
|
||||
[path] => Ok(PathBuf::from(path)),
|
||||
[flag, path] if flag == "--file" => Ok(PathBuf::from(path)),
|
||||
_ => Err("archive inspect requires <file> or --file <file>".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
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 usage() -> String {
|
||||
"usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph --root <path> --mission <path> [--format json]".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn strings(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| (*value).to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_exit_codes_are_mapped() {
|
||||
assert_eq!(exit_code(&Ok(())), 0);
|
||||
assert_eq!(exit_code(&Err("failure".to_string())), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_json_format_option() {
|
||||
assert_eq!(
|
||||
strip_format_json(&strings(&["--root", "testdata", "--format", "json"])),
|
||||
Ok(strings(&["--root", "testdata"]))
|
||||
);
|
||||
assert_eq!(
|
||||
strip_format_json(&strings(&["--format", "text"])),
|
||||
Err("unsupported output format: text".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_json_has_schema_version() {
|
||||
let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true));
|
||||
|
||||
assert!(json.contains("\"schema_version\":\"fparkan-archive-inspect-v1\""));
|
||||
assert!(json.contains("\"kind\":\"NRes\""));
|
||||
assert!(json.contains("\"lookup_order_valid\":true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prototype_graph_json_has_canonical_field_order() {
|
||||
let mut graph = fparkan_prototype::PrototypeGraph::default();
|
||||
graph
|
||||
.prototype_requests
|
||||
.push(fparkan_prototype::PrototypeKey(resource_name(b"root")));
|
||||
let report = fparkan_prototype::PrototypeGraphReport {
|
||||
root_count: 1,
|
||||
direct_reference_count: 1,
|
||||
resolved_count: 1,
|
||||
..fparkan_prototype::PrototypeGraphReport::default()
|
||||
};
|
||||
|
||||
let json = prototype_inspect_json("root", &graph, &report);
|
||||
|
||||
assert_eq!(
|
||||
json,
|
||||
"{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":\"root\",\"roots\":1,\"prototype_requests\":1,\"resolved\":1,\"unit_references\":0,\"unit_components\":0,\"direct_references\":1,\"wear\":0,\"materials\":0,\"textures\":0,\"lightmaps\":0,\"failures\":0}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "fparkan-game"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-render = { path = "../../crates/fparkan-render" }
|
||||
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
||||
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
||||
fparkan-world = { path = "../../crates/fparkan-world" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,322 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||
//! `FParkan` rendered game composition root.
|
||||
|
||||
use fparkan_render::{
|
||||
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RecordingBackend, RenderBackend,
|
||||
RenderCommand, RenderCommandList, RenderPhase,
|
||||
};
|
||||
use fparkan_runtime::{
|
||||
create, frame, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest,
|
||||
};
|
||||
use fparkan_vfs::DirectoryVfs;
|
||||
use fparkan_world::WorldSnapshot;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn main() {
|
||||
let raw_args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||
let code = match run(&raw_args) {
|
||||
Ok(output) => {
|
||||
println!("{output}");
|
||||
0
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("{err}");
|
||||
2
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<String, String> {
|
||||
let args = Args::parse(args)?;
|
||||
let services = EngineServices::new(Arc::new(DirectoryVfs::new(&args.root)));
|
||||
let mut engine = create(
|
||||
EngineConfig {
|
||||
mode: EngineMode::Rendered,
|
||||
},
|
||||
services,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let loaded = load_mission(
|
||||
&mut engine,
|
||||
MissionRequest {
|
||||
key: args.mission.clone(),
|
||||
},
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let mut backend = RecordingBackend::default();
|
||||
let mut last_draw_count = 0usize;
|
||||
let mut last_tick = 0u64;
|
||||
let mut last_hash = [0u8; 32];
|
||||
for _ in 0..args.frames {
|
||||
let result = frame(&mut engine).map_err(|err| err.to_string())?;
|
||||
last_tick = result.snapshot.tick.0;
|
||||
last_hash = result.snapshot.hash.0;
|
||||
let commands = render_snapshot_commands(&result.snapshot);
|
||||
last_draw_count = commands
|
||||
.commands
|
||||
.iter()
|
||||
.filter(|command| matches!(command, RenderCommand::Draw(_)))
|
||||
.count();
|
||||
backend
|
||||
.execute(&commands)
|
||||
.map_err(|err| format!("render backend: {err}"))?;
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"{{\"mission\":{},\"objects\":{},\"frames\":{},\"tick\":{},\"draws\":{},\"captures\":{},\"last_capture_bytes\":{},\"hash\":{}}}",
|
||||
json_string(&args.mission),
|
||||
loaded.object_count,
|
||||
args.frames,
|
||||
last_tick,
|
||||
last_draw_count,
|
||||
backend.captures().len(),
|
||||
backend.last_capture().map_or(0, <[u8]>::len),
|
||||
json_hash(&last_hash)
|
||||
))
|
||||
}
|
||||
|
||||
fn render_snapshot_commands(snapshot: &WorldSnapshot) -> RenderCommandList {
|
||||
let mut commands = Vec::with_capacity(snapshot.objects.len() + 2);
|
||||
commands.push(RenderCommand::BeginFrame);
|
||||
for (index, handle) in snapshot.objects.iter().enumerate() {
|
||||
let stable_order = u64::from(handle.slot);
|
||||
let draw_id = snapshot
|
||||
.tick
|
||||
.0
|
||||
.wrapping_mul(1_000_003)
|
||||
.wrapping_add(stable_order);
|
||||
commands.push(RenderCommand::Draw(DrawCommand {
|
||||
id: DrawId(draw_id),
|
||||
phase: RenderPhase::Opaque,
|
||||
object_id: None,
|
||||
mesh: GpuMeshId(u64::from(handle.slot) + 1),
|
||||
material: GpuMaterialId(1),
|
||||
transform: identity_transform(index_to_f32(index)),
|
||||
range: IndexRange { start: 0, count: 3 },
|
||||
stable_order,
|
||||
}));
|
||||
}
|
||||
commands.push(RenderCommand::EndFrame);
|
||||
RenderCommandList { commands }
|
||||
}
|
||||
|
||||
fn identity_transform(x: f32) -> [f32; 16] {
|
||||
[
|
||||
1.0, 0.0, 0.0, x, 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 index_to_f32(index: usize) -> f32 {
|
||||
u16::try_from(index).map_or(f32::from(u16::MAX), f32::from)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct Args {
|
||||
root: PathBuf,
|
||||
mission: String,
|
||||
frames: u64,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse(args: &[String]) -> Result<Self, String> {
|
||||
let mut root = None;
|
||||
let mut mission = None;
|
||||
let mut frames = 1;
|
||||
let mut iter = args.iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--root" => {
|
||||
root = Some(
|
||||
iter.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| "--root requires a path".to_string())?,
|
||||
);
|
||||
}
|
||||
"--mission" => {
|
||||
mission = Some(
|
||||
iter.next()
|
||||
.cloned()
|
||||
.ok_or_else(|| "--mission requires a path".to_string())?,
|
||||
);
|
||||
}
|
||||
"--frames" => {
|
||||
frames = iter
|
||||
.next()
|
||||
.ok_or_else(|| "--frames requires a value".to_string())?
|
||||
.parse()
|
||||
.map_err(|_| "--frames must be an integer".to_string())?;
|
||||
}
|
||||
_ => return Err(usage()),
|
||||
}
|
||||
}
|
||||
let root = root.ok_or_else(|| "missing --root".to_string())?;
|
||||
let mission = mission.ok_or_else(|| "missing --mission".to_string())?;
|
||||
if frames == 0 {
|
||||
return Err("--frames must be greater than zero".to_string());
|
||||
}
|
||||
Ok(Self {
|
||||
root,
|
||||
mission,
|
||||
frames,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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() => {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(out, "\\u{:04x}", u32::from(c));
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
fn json_hash(hash: &[u8; 32]) -> String {
|
||||
let mut out = String::from("\"");
|
||||
for byte in hash {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(out, "{byte:02x}");
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
fn usage() -> String {
|
||||
"usage: fparkan-game --root <path> --mission <path> [--frames <n>]".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fparkan_world::{ObjectHandle, StateHash, Tick};
|
||||
use std::path::Path;
|
||||
|
||||
fn strings(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| (*value).to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_required_args() {
|
||||
assert_eq!(
|
||||
Args::parse(&strings(&[
|
||||
"--root",
|
||||
"testdata/IS",
|
||||
"--mission",
|
||||
"MISSIONS/Autodemo.00/data.tma",
|
||||
"--frames",
|
||||
"3",
|
||||
])),
|
||||
Ok(Args {
|
||||
root: PathBuf::from("testdata/IS"),
|
||||
mission: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
||||
frames: 3,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_commands_follow_snapshot_order() -> Result<(), String> {
|
||||
let snapshot = WorldSnapshot {
|
||||
tick: Tick(7),
|
||||
objects: vec![
|
||||
ObjectHandle {
|
||||
generation: 1,
|
||||
slot: 2,
|
||||
},
|
||||
ObjectHandle {
|
||||
generation: 1,
|
||||
slot: 5,
|
||||
},
|
||||
],
|
||||
events: Vec::new(),
|
||||
hash: StateHash([0; 32]),
|
||||
};
|
||||
|
||||
let commands = render_snapshot_commands(&snapshot);
|
||||
|
||||
assert_eq!(commands.commands.len(), 4);
|
||||
assert!(matches!(commands.commands[0], RenderCommand::BeginFrame));
|
||||
assert!(matches!(commands.commands[3], RenderCommand::EndFrame));
|
||||
let RenderCommand::Draw(first) = &commands.commands[1] else {
|
||||
return Err("expected draw".to_string());
|
||||
};
|
||||
assert_eq!(first.mesh, GpuMeshId(3));
|
||||
assert_eq!(first.stable_order, 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_is_and_is2_missions_produce_approved_render_captures() {
|
||||
for case in [
|
||||
RenderCase {
|
||||
root: "IS",
|
||||
mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma",
|
||||
expected: "{\"mission\":\"MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma\",\"objects\":33,\"frames\":1,\"tick\":1,\"draws\":33,\"captures\":1,\"last_capture_bytes\":810,\"hash\":\"8584c4307bc911fc82bf909018662f392f3982bf909018666298bde408fe4242\"}",
|
||||
},
|
||||
RenderCase {
|
||||
root: "IS2",
|
||||
mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
|
||||
expected: "{\"mission\":\"MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma\",\"objects\":10,\"frames\":1,\"tick\":1,\"draws\":10,\"captures\":1,\"last_capture_bytes\":235,\"hash\":\"c52267cb14f699cb73b958e46c99c23ec23e73b958e46c99b3650afbcce56291\"}",
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
run(&render_args(&workspace_root().join("testdata").join(case.root), case.mission)),
|
||||
Ok(case.expected.to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_hash_is_hex() {
|
||||
let mut hash = [0; 32];
|
||||
hash[0] = 0xab;
|
||||
hash[31] = 0xcd;
|
||||
|
||||
assert_eq!(
|
||||
json_hash(&hash),
|
||||
"\"ab000000000000000000000000000000000000000000000000000000000000cd\""
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct RenderCase {
|
||||
root: &'static str,
|
||||
mission: &'static str,
|
||||
expected: &'static str,
|
||||
}
|
||||
|
||||
fn render_args(root: &Path, mission: &str) -> Vec<String> {
|
||||
vec![
|
||||
"--root".to_string(),
|
||||
root.to_str().expect("utf8 root").to_string(),
|
||||
"--mission".to_string(),
|
||||
mission.to_string(),
|
||||
"--frames".to_string(),
|
||||
"1".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.expect("workspace root")
|
||||
.to_path_buf()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "fparkan-headless"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
||||
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
||||
fparkan-world = { path = "../../crates/fparkan-world" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,114 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||
//! `FParkan` headless runtime entrypoint.
|
||||
|
||||
use fparkan_runtime::{
|
||||
create, load_mission, step_headless, EngineConfig, EngineMode, EngineServices, MissionRequest,
|
||||
};
|
||||
use fparkan_vfs::DirectoryVfs;
|
||||
use fparkan_world::InputSnapshot;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn main() {
|
||||
if let Err(err) = run() {
|
||||
eprintln!("{err}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let raw_args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let args = Args::parse(&raw_args)?;
|
||||
let services = if let Some(root) = &args.root {
|
||||
EngineServices::new(Arc::new(DirectoryVfs::new(root)))
|
||||
} else {
|
||||
EngineServices::default()
|
||||
};
|
||||
let mut engine = create(
|
||||
EngineConfig {
|
||||
mode: EngineMode::Headless,
|
||||
},
|
||||
services,
|
||||
)
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
if let Some(mission) = args.mission {
|
||||
let loaded = load_mission(&mut engine, MissionRequest { key: mission })
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
println!(
|
||||
"mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} graph_failures={}",
|
||||
loaded.object_count,
|
||||
loaded.areal_count,
|
||||
loaded.surface_count,
|
||||
loaded.graph_root_count,
|
||||
loaded.graph_unit_component_count,
|
||||
loaded.graph_wear_resolved_count,
|
||||
loaded.graph_material_resolved_count,
|
||||
loaded.graph_texture_resolved_count,
|
||||
loaded.graph_lightmap_resolved_count,
|
||||
loaded.graph_failure_count
|
||||
);
|
||||
}
|
||||
let mut last = None;
|
||||
for _ in 0..args.ticks {
|
||||
last = Some(step_headless(&mut engine, InputSnapshot).map_err(|err| format!("{err}"))?);
|
||||
}
|
||||
if let Some(frame) = last {
|
||||
println!(
|
||||
"tick={} hash={:02x?}",
|
||||
frame.snapshot.tick.0, frame.snapshot.hash.0
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Args {
|
||||
root: Option<PathBuf>,
|
||||
mission: Option<String>,
|
||||
ticks: u64,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse(args: &[String]) -> Result<Self, String> {
|
||||
let mut parsed = Self {
|
||||
root: None,
|
||||
mission: None,
|
||||
ticks: 1,
|
||||
};
|
||||
let mut iter = args.iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--root" => {
|
||||
parsed.root = Some(
|
||||
iter.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| "--root requires a path".to_string())?,
|
||||
);
|
||||
}
|
||||
"--mission" => {
|
||||
parsed.mission = Some(
|
||||
iter.next()
|
||||
.cloned()
|
||||
.ok_or_else(|| "--mission requires a path".to_string())?,
|
||||
);
|
||||
}
|
||||
"--ticks" => {
|
||||
parsed.ticks = iter
|
||||
.next()
|
||||
.ok_or_else(|| "--ticks requires a value".to_string())?
|
||||
.parse()
|
||||
.map_err(|_| "--ticks must be an integer".to_string())?;
|
||||
}
|
||||
_ => return Err(usage()),
|
||||
}
|
||||
}
|
||||
if parsed.mission.is_some() && parsed.root.is_none() {
|
||||
return Err("--mission requires --root".to_string());
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
fn usage() -> String {
|
||||
"usage: fparkan-headless [--root <path> --mission <path>] [--ticks <n>]".to_string()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user