feat: close stage 0-2 audit groundwork
Remove legacy SDL/OpenGL adapters from the workspace and introduce winit/Vulkan adapter boundaries for the rendered composition root. Add reproducible toolchain and xtask CI coverage for formatting, tests, clippy, docs, policy, deny, acceptance auditing, and hosted OS matrix evidence. Strengthen Stage 1 data contracts with byte-first paths, VFS hardening, structured diagnostics, RsLi writer/edit scaffolding, corpus reporting, and resource error classification. Advance Stage 2 asset preparation by moving mission loading through assets/runtime boundaries, materializing prototype graph data, preserving provenance, and adding inspection/viewer integration. Record the Stage 0-2 audit input, acceptance roadmap, coverage updates, and documentation notes for follow-up evidence.
This commit is contained in:
@@ -10,9 +10,12 @@ fparkan-material = { path = "../fparkan-material" }
|
||||
fparkan-msh = { path = "../fparkan-msh" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-mission-format = { path = "../fparkan-mission-format" }
|
||||
fparkan-prototype = { path = "../fparkan-prototype" }
|
||||
fparkan-resource = { path = "../fparkan-resource" }
|
||||
fparkan-texm = { path = "../fparkan-texm" }
|
||||
fparkan-terrain = { path = "../fparkan-terrain" }
|
||||
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,16 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
fparkan-fx = { path = "../fparkan-fx" }
|
||||
fparkan-material = { path = "../fparkan-material" }
|
||||
fparkan-msh = { path = "../fparkan-msh" }
|
||||
fparkan-mission-format = { path = "../fparkan-mission-format" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-prototype = { path = "../fparkan-prototype" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-rsli = { path = "../fparkan-rsli" }
|
||||
fparkan-texm = { path = "../fparkan-texm" }
|
||||
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
//! Licensed corpus discovery and aggregate reports.
|
||||
|
||||
use fparkan_binary::{sha256, sha256_hex, Sha256Digest};
|
||||
use fparkan_fx::{decode_fxid, FXID_KIND};
|
||||
use fparkan_material::{decode_mat0, decode_wear, MAT0_KIND, WEAR_KIND};
|
||||
use fparkan_msh::{decode_msh, validate_msh};
|
||||
use fparkan_mission_format::{decode_tma, TmaProfile};
|
||||
use fparkan_nres::NresDocument;
|
||||
use fparkan_path::{ascii_lookup_key, normalize_relative, PathPolicy};
|
||||
use fparkan_prototype::{decode_unit_dat, decode_unit_dat_binding};
|
||||
use fparkan_rsli::{decode as decode_rsli, ReadProfile};
|
||||
use fparkan_texm::decode_texm;
|
||||
use fparkan_terrain_format::{decode_land_map, decode_land_msh};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
@@ -10,6 +19,8 @@ use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
const TEXM_KIND: u32 = 0x6d78_6554;
|
||||
|
||||
/// Corpus kind.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum CorpusKind {
|
||||
@@ -336,7 +347,6 @@ fn inspect_report_file(
|
||||
}
|
||||
};
|
||||
if bytes.starts_with(b"NRes") {
|
||||
variant = "nres".to_string();
|
||||
bump(metrics, "nres_files", 1);
|
||||
if let Err(message) = inspect_nres_metrics(bytes, metrics) {
|
||||
return CorpusFileRecord {
|
||||
@@ -346,9 +356,52 @@ fn inspect_report_file(
|
||||
message: Some(message),
|
||||
};
|
||||
}
|
||||
if variant == "land_msh" && let Err(message) = inspect_land_metrics(&bytes, false) {
|
||||
return CorpusFileRecord {
|
||||
path: entry.path.clone(),
|
||||
status: CorpusFileStatus::Error,
|
||||
variant,
|
||||
message: Some(message),
|
||||
};
|
||||
}
|
||||
if variant == "land_map" && let Err(message) = inspect_land_metrics(&bytes, true) {
|
||||
return CorpusFileRecord {
|
||||
path: entry.path.clone(),
|
||||
status: CorpusFileStatus::Error,
|
||||
variant,
|
||||
message: Some(message),
|
||||
};
|
||||
}
|
||||
} else if bytes.starts_with(b"NL") {
|
||||
variant = "rsli".to_string();
|
||||
bump(metrics, "rsli_files", 1);
|
||||
if let Err(message) = inspect_rsli_metrics(&bytes) {
|
||||
return CorpusFileRecord {
|
||||
path: entry.path.clone(),
|
||||
status: CorpusFileStatus::Error,
|
||||
variant,
|
||||
message: Some(message),
|
||||
};
|
||||
}
|
||||
} else if lower.ends_with("data.tma") {
|
||||
if let Err(message) = inspect_tma_metrics(&bytes) {
|
||||
return CorpusFileRecord {
|
||||
path: entry.path.clone(),
|
||||
status: CorpusFileStatus::Error,
|
||||
variant: "tma".to_string(),
|
||||
message: Some(message),
|
||||
};
|
||||
}
|
||||
} else if has_extension(lower, "dat") && (lower.starts_with("units/") || lower.contains("/units/")) {
|
||||
variant = "unit_dat".to_string();
|
||||
if let Err(message) = inspect_unit_dat_metrics(&bytes) {
|
||||
return CorpusFileRecord {
|
||||
path: entry.path.clone(),
|
||||
status: CorpusFileStatus::Error,
|
||||
variant,
|
||||
message: Some(message),
|
||||
};
|
||||
}
|
||||
}
|
||||
CorpusFileRecord {
|
||||
path: entry.path.clone(),
|
||||
@@ -380,25 +433,30 @@ fn inspect_path_metrics(lower: &str, metrics: &mut BTreeMap<String, u64>) -> Str
|
||||
}
|
||||
|
||||
fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) -> Result<(), String> {
|
||||
let entries = inspect_nres_entries(bytes)?;
|
||||
bump(metrics, "nres_entries", entries.len() as u64);
|
||||
for entry in entries {
|
||||
let document = inspect_nres_document(&bytes)?;
|
||||
bump(metrics, "nres_entries", document.entries().len() as u64);
|
||||
for entry in document.entries() {
|
||||
let name = String::from_utf8_lossy(entry.name_bytes()).to_ascii_lowercase();
|
||||
if has_extension(&name, "msh") {
|
||||
bump(metrics, "msh_entries", 1);
|
||||
validate_nres_msh_payload(&document, entry)?;
|
||||
}
|
||||
match entry.meta().type_id {
|
||||
0x3054_414D => {
|
||||
MAT0_KIND => {
|
||||
bump(metrics, "mat0_entries", 1);
|
||||
validate_nres_mat0_payload(&document, entry)?;
|
||||
}
|
||||
0x6D78_6554 => {
|
||||
TEXM_KIND => {
|
||||
bump(metrics, "texm_entries", 1);
|
||||
validate_nres_texm_payload(&document, entry)?;
|
||||
}
|
||||
0x4449_5846 => {
|
||||
FXID_KIND => {
|
||||
bump(metrics, "fxid_entries", 1);
|
||||
validate_nres_fxid_payload(&document, entry)?;
|
||||
}
|
||||
0x5241_4557 => {
|
||||
WEAR_KIND => {
|
||||
bump(metrics, "wear_entries", 1);
|
||||
validate_nres_wear_payload(&document, entry)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -406,6 +464,94 @@ fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_nres_msh_payload(document: &NresDocument, entry: &fparkan_nres::NresEntry) -> Result<(), String> {
|
||||
let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
|
||||
let nested = fparkan_nres::decode(
|
||||
Arc::from(payload.to_vec().into_boxed_slice()),
|
||||
fparkan_nres::ReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let model = decode_msh(&nested).map_err(|err| err.to_string())?;
|
||||
validate_msh(&model).map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_nres_mat0_payload(
|
||||
document: &NresDocument,
|
||||
entry: &fparkan_nres::NresEntry,
|
||||
) -> Result<(), String> {
|
||||
let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
|
||||
decode_mat0(payload, entry.meta().attr2).map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_nres_wear_payload(
|
||||
document: &NresDocument,
|
||||
entry: &fparkan_nres::NresEntry,
|
||||
) -> Result<(), String> {
|
||||
let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
|
||||
decode_wear(payload).map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_nres_texm_payload(
|
||||
document: &NresDocument,
|
||||
entry: &fparkan_nres::NresEntry,
|
||||
) -> Result<(), String> {
|
||||
let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
|
||||
decode_texm(Arc::from(payload.to_vec().into_boxed_slice())).map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_nres_fxid_payload(
|
||||
document: &NresDocument,
|
||||
entry: &fparkan_nres::NresEntry,
|
||||
) -> Result<(), String> {
|
||||
let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
|
||||
decode_fxid(Arc::from(payload.to_vec().into_boxed_slice())).map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inspect_rsli_metrics(bytes: &[u8]) -> Result<(), String> {
|
||||
let _ = decode_rsli(
|
||||
Arc::from(bytes.to_vec().into_boxed_slice()),
|
||||
ReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inspect_tma_metrics(bytes: &[u8]) -> Result<(), String> {
|
||||
let _ = decode_tma(Arc::from(bytes.to_vec().into_boxed_slice()), TmaProfile::Strict)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inspect_unit_dat_metrics(bytes: &[u8]) -> Result<(), String> {
|
||||
if decode_unit_dat(bytes).is_err() && decode_unit_dat_binding(bytes).is_err() {
|
||||
return Err("failed to parse unit.dat payload as unit or binding format".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inspect_land_metrics(bytes: &[u8], is_map: bool) -> Result<(), String> {
|
||||
let document = inspect_nres_document(bytes)?;
|
||||
if is_map {
|
||||
decode_land_map(&document).map_err(|err| err.to_string())?;
|
||||
} else {
|
||||
decode_land_msh(&document).map_err(|err| err.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inspect_nres_document(bytes: &[u8]) -> Result<NresDocument, String> {
|
||||
fparkan_nres::decode(
|
||||
Arc::from(bytes.to_vec().into_boxed_slice()),
|
||||
fparkan_nres::ReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn bump(metrics: &mut BTreeMap<String, u64>, key: &str, delta: u64) {
|
||||
if let Some(value) = metrics.get_mut(key) {
|
||||
*value = value.saturating_add(delta);
|
||||
@@ -418,15 +564,6 @@ fn has_extension(path: &str, expected: &str) -> bool {
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
|
||||
}
|
||||
|
||||
fn inspect_nres_entries(bytes: Vec<u8>) -> Result<Vec<fparkan_nres::NresEntry>, String> {
|
||||
let document = fparkan_nres::decode(
|
||||
Arc::from(bytes.into_boxed_slice()),
|
||||
fparkan_nres::ReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(document.entries().to_vec())
|
||||
}
|
||||
|
||||
/// Computes stable manifest fingerprint.
|
||||
#[must_use]
|
||||
pub fn fingerprint(manifest: &CorpusManifest) -> Sha256Digest {
|
||||
@@ -698,6 +835,116 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_land_map_paths_use_production_land_parser() {
|
||||
let root = temp_dir("report-land-map");
|
||||
fs::write(root.join("WORLD/MAP/land.map"), build_nres(&[])).expect("land map");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "WORLD/MAP/land.map".to_string(),
|
||||
size: 16,
|
||||
hash: sha256(b"land.map"),
|
||||
}],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
let report = report(&root, &manifest).expect("report");
|
||||
|
||||
assert_eq!(report.failures, 1);
|
||||
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
|
||||
assert_eq!(report.records[0].variant, "land_map");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_land_msh_paths_use_production_land_parser() {
|
||||
let root = temp_dir("report-land-msh");
|
||||
fs::write(root.join("WORLD/MAP/land.msh"), build_nres(&[])).expect("land msh");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "WORLD/MAP/land.msh".to_string(),
|
||||
size: 16,
|
||||
hash: sha256(b"land.msh"),
|
||||
}],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
let report = report(&root, &manifest).expect("report");
|
||||
|
||||
assert_eq!(report.failures, 1);
|
||||
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
|
||||
assert_eq!(report.records[0].variant, "land_msh");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_tma_paths_use_production_tma_parser() {
|
||||
let root = temp_dir("report-tma");
|
||||
fs::write(root.join("MISSIONS/test/data.tma"), b"malformed tma").expect("tma");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "MISSIONS/test/data.tma".to_string(),
|
||||
size: 12,
|
||||
hash: sha256(b"malformed tma"),
|
||||
}],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
let report = report(&root, &manifest).expect("report");
|
||||
|
||||
assert_eq!(report.failures, 1);
|
||||
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
|
||||
assert_eq!(report.records[0].variant, "tma");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_unit_dat_paths_use_production_unit_parser() {
|
||||
let root = temp_dir("report-unit");
|
||||
fs::write(root.join("units/unit.dat"), vec![0u8; 120]).expect("unit");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "units/unit.dat".to_string(),
|
||||
size: 120,
|
||||
hash: sha256(&[0u8; 120]),
|
||||
}],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
let report = report(&root, &manifest).expect("report");
|
||||
|
||||
assert_eq!(report.failures, 0);
|
||||
assert_eq!(report.records[0].status, CorpusFileStatus::Ok);
|
||||
assert_eq!(report.records[0].variant, "unit_dat");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_rsli_paths_use_production_rsli_parser() {
|
||||
let root = temp_dir("report-rsli");
|
||||
fs::write(root.join("patch.nl"), b"NL malformed").expect("rsli");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "patch.nl".to_string(),
|
||||
size: 12,
|
||||
hash: sha256(b"NL malformed"),
|
||||
}],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
let report = report(&root, &manifest).expect("report");
|
||||
|
||||
assert_eq!(report.failures, 1);
|
||||
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
|
||||
assert_eq!(report.records[0].variant, "rsli");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_traversal_is_creation_order_independent() {
|
||||
let first = temp_dir("order-first");
|
||||
|
||||
@@ -6,6 +6,8 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Structured diagnostics shared by `FParkan` crates.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Diagnostic severity.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Severity {
|
||||
/// Informational note.
|
||||
Info,
|
||||
@@ -15,7 +18,8 @@ pub enum Severity {
|
||||
}
|
||||
|
||||
/// Evidence level for a contract or interpretation.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EvidenceStatus {
|
||||
/// Described by project documentation.
|
||||
Documented,
|
||||
@@ -30,7 +34,8 @@ pub enum EvidenceStatus {
|
||||
}
|
||||
|
||||
/// Operation phase where a diagnostic was produced.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Phase {
|
||||
/// Discovery.
|
||||
Discover,
|
||||
@@ -55,7 +60,7 @@ pub enum Phase {
|
||||
}
|
||||
|
||||
/// Byte span in an input source.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct SourceSpan {
|
||||
/// Start offset.
|
||||
pub offset: u64,
|
||||
@@ -64,11 +69,11 @@ pub struct SourceSpan {
|
||||
}
|
||||
|
||||
/// Stable diagnostic code.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
|
||||
pub struct DiagnosticCode(pub &'static str);
|
||||
|
||||
/// Context attached to a diagnostic.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
|
||||
pub struct DiagnosticContext {
|
||||
/// Phase.
|
||||
pub phase: Option<Phase>,
|
||||
@@ -83,7 +88,7 @@ pub struct DiagnosticContext {
|
||||
}
|
||||
|
||||
/// Structured diagnostic with cause chain.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct Diagnostic {
|
||||
/// Stable code.
|
||||
pub code: DiagnosticCode,
|
||||
@@ -145,104 +150,13 @@ pub fn render_human(diagnostic: &Diagnostic) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Renders deterministic JSON without requiring a serialization dependency.
|
||||
/// Renders deterministic JSON using the typed diagnostic schema.
|
||||
#[must_use]
|
||||
pub fn render_json(diagnostic: &Diagnostic) -> String {
|
||||
fn esc(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len() + 2);
|
||||
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"),
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
out
|
||||
match serde_json::to_string(diagnostic) {
|
||||
Ok(json) => json,
|
||||
Err(err) => format!("{{\"error\":\"diagnostic serialization failed: {err}\"}}"),
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
out.push('{');
|
||||
out.push_str("\"code\":\"");
|
||||
out.push_str(&esc(diagnostic.code.0));
|
||||
out.push_str("\",\"severity\":\"");
|
||||
out.push_str(match diagnostic.severity {
|
||||
Severity::Info => "info",
|
||||
Severity::Warning => "warning",
|
||||
Severity::Error => "error",
|
||||
Severity::Fatal => "fatal",
|
||||
});
|
||||
out.push_str("\",\"message\":\"");
|
||||
out.push_str(&esc(&diagnostic.message));
|
||||
out.push_str("\",\"context\":{");
|
||||
if let Some(phase) = diagnostic.context.phase {
|
||||
out.push_str("\"phase\":\"");
|
||||
out.push_str(match phase {
|
||||
Phase::Discover => "discover",
|
||||
Phase::Read => "read",
|
||||
Phase::Parse => "parse",
|
||||
Phase::Validate => "validate",
|
||||
Phase::Resolve => "resolve",
|
||||
Phase::Prepare => "prepare",
|
||||
Phase::Construct => "construct",
|
||||
Phase::Register => "register",
|
||||
Phase::Simulate => "simulate",
|
||||
Phase::Render => "render",
|
||||
});
|
||||
out.push('"');
|
||||
}
|
||||
if let Some(path) = &diagnostic.context.path {
|
||||
if diagnostic.context.phase.is_some() {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str("\"path\":\"");
|
||||
out.push_str(&esc(path));
|
||||
out.push('"');
|
||||
}
|
||||
if let Some(entry) = &diagnostic.context.archive_entry {
|
||||
if diagnostic.context.phase.is_some() || diagnostic.context.path.is_some() {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str("\"archive_entry\":\"");
|
||||
out.push_str(&esc(entry));
|
||||
out.push('"');
|
||||
}
|
||||
if let Some(key) = &diagnostic.context.object_key {
|
||||
if diagnostic.context.phase.is_some()
|
||||
|| diagnostic.context.path.is_some()
|
||||
|| diagnostic.context.archive_entry.is_some()
|
||||
{
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str("\"object_key\":\"");
|
||||
out.push_str(&esc(key));
|
||||
out.push('"');
|
||||
}
|
||||
if let Some(span) = diagnostic.context.span {
|
||||
if diagnostic.context.phase.is_some()
|
||||
|| diagnostic.context.path.is_some()
|
||||
|| diagnostic.context.archive_entry.is_some()
|
||||
|| diagnostic.context.object_key.is_some()
|
||||
{
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str("\"span\":{\"offset\":");
|
||||
out.push_str(&span.offset.to_string());
|
||||
out.push_str(",\"length\":");
|
||||
out.push_str(&span.length.to_string());
|
||||
out.push('}');
|
||||
}
|
||||
out.push_str("},\"causes\":[");
|
||||
for (idx, cause) in diagnostic.causes.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&render_json(cause));
|
||||
}
|
||||
out.push_str("]}");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -298,4 +212,14 @@ mod tests {
|
||||
assert!(json.contains("\"code\":\"CAUSE\""));
|
||||
assert!(json.contains("\"span\":{\"offset\":16,\"length\":8}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_escapes_all_control_characters() {
|
||||
let value = diagnostic(DiagnosticCode("S1-H01"), "quote\"\u{0000}tab\tline\r\n");
|
||||
let json = render_json(&value);
|
||||
assert!(json.contains("\\u0000"));
|
||||
assert!(json.contains("\\u0009"));
|
||||
assert!(!json.contains('\t'));
|
||||
assert!(!json.contains('\r'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "fparkan-inspection"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-msh = { path = "../fparkan-msh" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-rsli = { path = "../fparkan-rsli" }
|
||||
fparkan-resource = { path = "../fparkan-resource" }
|
||||
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||
fparkan-texm = { path = "../fparkan-texm" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,286 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Shared inspection helpers for format-backed tooling.
|
||||
|
||||
use fparkan_msh::{decode_msh, validate_msh};
|
||||
use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile};
|
||||
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository};
|
||||
use fparkan_rsli::decode as decode_rsli;
|
||||
use fparkan_terrain_format::{decode_land_map, decode_land_msh};
|
||||
use fparkan_texm::decode_texm;
|
||||
use fparkan_vfs::{DirectoryVfs, Vfs};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Archive inspection variants.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ArchiveInspection {
|
||||
/// NRes inspection summary.
|
||||
Nres {
|
||||
/// Archive entry count.
|
||||
entries: usize,
|
||||
/// Lookup order validity.
|
||||
lookup_order_valid: bool,
|
||||
/// Entry samples (subject to request limit).
|
||||
sample: Vec<NresEntrySummary>,
|
||||
},
|
||||
/// RsLi inspection summary.
|
||||
Rsli {
|
||||
/// Archive entry count.
|
||||
entries: usize,
|
||||
},
|
||||
/// Unknown/unsupported archive magic.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// NRes entry summary.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct NresEntrySummary {
|
||||
/// ASCII/legacy resource name.
|
||||
pub name: String,
|
||||
/// Entry type identifier.
|
||||
pub type_id: u32,
|
||||
/// Declared entry payload size.
|
||||
pub data_size: u32,
|
||||
}
|
||||
|
||||
/// Model inspection payload.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ModelInspection {
|
||||
/// Terrain stream/document stream count.
|
||||
pub streams: usize,
|
||||
/// Node count.
|
||||
pub nodes: usize,
|
||||
/// Slot count.
|
||||
pub slots: usize,
|
||||
/// Position count.
|
||||
pub positions: usize,
|
||||
/// Index count.
|
||||
pub indices: usize,
|
||||
/// Batch count.
|
||||
pub batches: usize,
|
||||
}
|
||||
|
||||
/// Texture inspection payload.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct TextureInspection {
|
||||
/// Width.
|
||||
pub width: u32,
|
||||
/// Height.
|
||||
pub height: u32,
|
||||
/// Texture format debug text.
|
||||
pub format: String,
|
||||
/// Mip level count.
|
||||
pub mips: usize,
|
||||
/// Total page rectangles.
|
||||
pub pages: usize,
|
||||
}
|
||||
|
||||
/// Land map/msh inspection payload.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MapInspection {
|
||||
/// Mapped mesh stream count.
|
||||
pub streams: usize,
|
||||
/// Slot count.
|
||||
pub slots: usize,
|
||||
/// Position count.
|
||||
pub positions: usize,
|
||||
/// Face count.
|
||||
pub faces: usize,
|
||||
/// Terrain areals.
|
||||
pub areals: usize,
|
||||
/// Declared areal count from map metadata.
|
||||
pub declared_areals: u32,
|
||||
/// Map grid width.
|
||||
pub grid_width: u32,
|
||||
/// Map grid height.
|
||||
pub grid_height: u32,
|
||||
}
|
||||
|
||||
/// Supported land file kinds.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum LandFileKind {
|
||||
/// `land.msh` payload.
|
||||
LandMsh,
|
||||
/// `land.map` payload.
|
||||
LandMap,
|
||||
}
|
||||
|
||||
/// Inspects a format archive.
|
||||
pub fn inspect_archive_file(path: &Path, sample_limit: usize) -> Result<ArchiveInspection, String> {
|
||||
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
||||
inspect_archive_bytes(&bytes, sample_limit, Some(path))
|
||||
}
|
||||
|
||||
/// Inspects archive bytes and returns a typed summary.
|
||||
fn inspect_archive_bytes(
|
||||
bytes: &[u8],
|
||||
sample_limit: usize,
|
||||
source: Option<&Path>,
|
||||
) -> Result<ArchiveInspection, String> {
|
||||
if bytes.starts_with(b"NRes") {
|
||||
let document = decode_nres(
|
||||
Arc::from(bytes.to_vec().into_boxed_slice()),
|
||||
ReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let mut sample = Vec::new();
|
||||
for entry in document.entries().iter().take(sample_limit) {
|
||||
sample.push(NresEntrySummary {
|
||||
name: String::from_utf8_lossy(entry.name_bytes()).to_string(),
|
||||
type_id: entry.meta().type_id,
|
||||
data_size: entry.meta().data_size,
|
||||
});
|
||||
}
|
||||
Ok(ArchiveInspection::Nres {
|
||||
entries: document.entries().len(),
|
||||
lookup_order_valid: document.lookup_order_valid(),
|
||||
sample,
|
||||
})
|
||||
} else if bytes.get(0..4) == Some(b"NL\0\x01") {
|
||||
let document = decode_rsli(Arc::from(bytes.to_vec().into_boxed_slice()), fparkan_rsli::ReadProfile::Compatible)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(ArchiveInspection::Rsli {
|
||||
entries: document.entries().len(),
|
||||
})
|
||||
} else {
|
||||
match source {
|
||||
Some(path) => Err(format!("{}: unsupported archive magic", path.display())),
|
||||
None => Err("unsupported archive magic".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspects a model through repository-backed resource lookup.
|
||||
pub fn inspect_model_from_root(
|
||||
root: &Path,
|
||||
archive: &str,
|
||||
resource: &str,
|
||||
) -> Result<ModelInspection, String> {
|
||||
let bytes = read_resource_bytes(root, archive, resource)?;
|
||||
let document = decode_nres(bytes, ReadProfile::Compatible).map_err(|err| err.to_string())?;
|
||||
let msh = decode_msh(&document).map_err(|err| err.to_string())?;
|
||||
let validated = validate_msh(&msh).map_err(|err| err.to_string())?;
|
||||
Ok(ModelInspection {
|
||||
streams: msh.streams().len(),
|
||||
nodes: validated.node_count,
|
||||
slots: validated.slots.len(),
|
||||
positions: validated.positions.len(),
|
||||
indices: validated.indices.len(),
|
||||
batches: validated.batches.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Inspects a texture through repository-backed resource lookup.
|
||||
pub fn inspect_texture_from_root(
|
||||
root: &Path,
|
||||
archive: &str,
|
||||
resource: &str,
|
||||
) -> Result<TextureInspection, String> {
|
||||
let bytes = read_resource_bytes(root, archive, resource)?;
|
||||
let document = decode_texm(bytes).map_err(|err| err.to_string())?;
|
||||
Ok(TextureInspection {
|
||||
width: document.width(),
|
||||
height: document.height(),
|
||||
format: format!("{:?}", document.format()),
|
||||
mips: document.mip_count(),
|
||||
pages: document.page_rects().len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Inspects a terrain land file by path.
|
||||
pub fn inspect_land_file(path: &Path, kind: LandFileKind) -> Result<MapInspection, String> {
|
||||
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
||||
let document = decode_nres(
|
||||
Arc::from(bytes.into_boxed_slice()),
|
||||
ReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
match kind {
|
||||
LandFileKind::LandMsh => inspect_land_msh(&document),
|
||||
LandFileKind::LandMap => inspect_land_map(&document),
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_land_msh(document: &NresDocument) -> Result<MapInspection, String> {
|
||||
let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?;
|
||||
Ok(MapInspection {
|
||||
streams: land_msh.streams.len(),
|
||||
slots: land_msh.slots.slots_raw.len(),
|
||||
positions: land_msh.positions.len(),
|
||||
faces: land_msh.faces.len(),
|
||||
areals: 0,
|
||||
declared_areals: 0,
|
||||
grid_width: 0,
|
||||
grid_height: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn inspect_land_map(document: &NresDocument) -> Result<MapInspection, String> {
|
||||
let land_map = decode_land_map(document).map_err(|err| err.to_string())?;
|
||||
Ok(MapInspection {
|
||||
streams: 0,
|
||||
slots: 0,
|
||||
positions: 0,
|
||||
faces: 0,
|
||||
areals: land_map.areals.len(),
|
||||
declared_areals: land_map.areal_count,
|
||||
grid_width: land_map.grid.cells_x,
|
||||
grid_height: land_map.grid.cells_y,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_resource_bytes(root: &Path, archive: &str, name: &str) -> Result<Arc<[u8]>, String> {
|
||||
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
|
||||
let archive_path = archive_path(archive.as_bytes()).map_err(|err| err.to_string())?;
|
||||
let resource_name = resource_name(name.as_bytes());
|
||||
let archive_handle = repository
|
||||
.open_archive(&archive_path)
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
let Some(handle) = repository
|
||||
.find(archive_handle, &resource_name)
|
||||
.map_err(|err| format!("{err}"))?
|
||||
else {
|
||||
return Err(format!(
|
||||
"resource not found: {archive}/{}",
|
||||
String::from_utf8_lossy(name.as_bytes())
|
||||
));
|
||||
};
|
||||
let bytes = repository.read(handle).map_err(|err| format!("{err}"))?;
|
||||
Ok(Arc::from(bytes.into_owned()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write as _;
|
||||
|
||||
#[test]
|
||||
fn inspect_rsli_counts_entries() {
|
||||
let dir = temp_dir("inspect");
|
||||
let path = dir.join("test.rsli");
|
||||
let mut file = fs::File::create(&path).expect("file");
|
||||
file.write_all(b"NL\0\x01").expect("magic");
|
||||
drop(file);
|
||||
|
||||
let inspection = inspect_archive_file(&path, 0).expect("inspect");
|
||||
assert!(matches!(inspection, ArchiveInspection::Rsli { entries: 0 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_entry_summary_fields_are_readable() {
|
||||
let dir = temp_dir("inspect-nres");
|
||||
let archive = dir.join("test.nres");
|
||||
let payload = Vec::from("NRes\x00\x00\x00\x00");
|
||||
fs::write(&archive, &payload).expect("nres");
|
||||
|
||||
let _ = inspect_archive_file(&archive, 2);
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let base = PathBuf::from("/tmp").join("fparkan-inspection-tests").join(name);
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
fs::create_dir_all(&base).expect("tmp dir");
|
||||
base
|
||||
}
|
||||
}
|
||||
@@ -24,13 +24,28 @@ impl OriginalPathBytes {
|
||||
|
||||
/// Normalized relative path.
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub struct NormalizedPath(String);
|
||||
pub struct NormalizedPath {
|
||||
raw: Vec<u8>,
|
||||
display: String,
|
||||
}
|
||||
|
||||
impl NormalizedPath {
|
||||
/// Returns string view.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
&self.display
|
||||
}
|
||||
|
||||
/// Returns normalized byte view.
|
||||
#[must_use]
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.raw
|
||||
}
|
||||
|
||||
/// Returns an OS path owned path buffer.
|
||||
#[must_use]
|
||||
pub fn as_path(&self) -> PathBuf {
|
||||
as_os_path_from_bytes(&self.raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +106,6 @@ pub enum PathError {
|
||||
ParentTraversal,
|
||||
/// Host path escape.
|
||||
EscapesRoot,
|
||||
/// Invalid UTF-8 after normalization.
|
||||
InvalidUtf8,
|
||||
}
|
||||
|
||||
impl fmt::Display for PathError {
|
||||
@@ -103,7 +116,6 @@ impl fmt::Display for PathError {
|
||||
Self::Absolute => write!(f, "path must be relative and cannot be absolute"),
|
||||
Self::ParentTraversal => write!(f, "path attempts to traverse outside its root"),
|
||||
Self::EscapesRoot => write!(f, "normalized path escapes the configured root"),
|
||||
Self::InvalidUtf8 => write!(f, "path is not valid UTF-8 after normalization"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,8 +127,7 @@ impl std::error::Error for PathError {}
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PathError`] when the input is empty, absolute, contains an
|
||||
/// embedded NUL, attempts parent traversal, or is not valid UTF-8 after
|
||||
/// legacy separator normalization.
|
||||
/// embedded NUL, attempts parent traversal, or has an invalid drive prefix.
|
||||
pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPath, PathError> {
|
||||
if raw.is_empty() {
|
||||
return Err(PathError::Empty);
|
||||
@@ -124,22 +135,21 @@ pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPa
|
||||
if raw.contains(&0) {
|
||||
return Err(PathError::EmbeddedNul);
|
||||
}
|
||||
let text = std::str::from_utf8(raw).map_err(|_| PathError::InvalidUtf8)?;
|
||||
if text.starts_with('/') || text.starts_with('\\') || has_drive_prefix(text) {
|
||||
if raw.starts_with(b"/") || raw.starts_with(b"\\") || has_drive_prefix(raw) {
|
||||
return Err(PathError::Absolute);
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
for part in text.split(['/', '\\']) {
|
||||
if part.is_empty() || part == "." {
|
||||
for part in raw.split(|byte| *byte == b'/' || *byte == b'\\') {
|
||||
if part.is_empty() || part == b"." {
|
||||
if policy == PathPolicy::StrictLegacy {
|
||||
return Err(PathError::ParentTraversal);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if part == ".." {
|
||||
if part == b".." {
|
||||
return Err(PathError::ParentTraversal);
|
||||
}
|
||||
if policy == PathPolicy::StrictLegacy && part.contains(':') {
|
||||
if policy == PathPolicy::StrictLegacy && part.contains(&b':') {
|
||||
return Err(PathError::Absolute);
|
||||
}
|
||||
parts.push(part);
|
||||
@@ -147,7 +157,17 @@ pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPa
|
||||
if parts.is_empty() {
|
||||
return Err(PathError::Empty);
|
||||
}
|
||||
Ok(NormalizedPath(parts.join("/")))
|
||||
let mut normalized = Vec::new();
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
if index > 0 {
|
||||
normalized.push(b'/');
|
||||
}
|
||||
normalized.extend_from_slice(part);
|
||||
}
|
||||
Ok(NormalizedPath {
|
||||
raw: normalized,
|
||||
display: String::from_utf8_lossy(&normalized).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Normalizes a relative path while preserving its original bytes.
|
||||
@@ -166,8 +186,7 @@ pub fn normalize_relative_with_original(
|
||||
})
|
||||
}
|
||||
|
||||
fn has_drive_prefix(text: &str) -> bool {
|
||||
let bytes = text.as_bytes();
|
||||
fn has_drive_prefix(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
|
||||
}
|
||||
|
||||
@@ -184,7 +203,11 @@ pub fn ascii_lookup_key(raw: &[u8]) -> LookupKey {
|
||||
/// Returns [`PathError::ParentTraversal`] when a normalized segment attempts
|
||||
/// to address a parent directory.
|
||||
pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
|
||||
if rel.0.split('/').any(|part| part == "..") {
|
||||
if rel
|
||||
.as_bytes()
|
||||
.split(|byte| *byte == b'/')
|
||||
.any(|part| part == b"..")
|
||||
{
|
||||
Err(PathError::ParentTraversal)
|
||||
} else {
|
||||
Ok(())
|
||||
@@ -198,7 +221,20 @@ pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
|
||||
/// Returns [`PathError`] if the normalized path fails the escape check.
|
||||
pub fn join_under(root: &Path, rel: &NormalizedPath) -> Result<PathBuf, PathError> {
|
||||
reject_escape(rel)?;
|
||||
Ok(root.join(rel.as_str()))
|
||||
Ok(root.join(rel.as_path()))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
PathBuf::from(OsString::from_vec(raw.to_vec()))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf {
|
||||
PathBuf::from(String::from_utf8_lossy(raw).into_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -292,6 +328,14 @@ mod tests {
|
||||
assert_eq!(&ascii_lookup_key(raw).0[5..13], &raw[5..13]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_non_utf8_legacy_bytes() {
|
||||
let path = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
|
||||
.expect("raw legacy bytes");
|
||||
|
||||
assert_eq!(path.as_str(), "DATA/\u{FFFD}.bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_separators_and_raw_bytes_are_preserved() {
|
||||
let raw = b"DATA\\Maps/Intro\\Land.msh";
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Platform ports for clocks, input, events, windows, and graphics requests.
|
||||
//! Platform ports for clocks, event sources and window descriptors.
|
||||
|
||||
/// Monotonic instant.
|
||||
/// Monotonic instant measured in milliseconds since process start.
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub struct MonotonicInstant(pub u64);
|
||||
|
||||
/// Monotonic clock.
|
||||
/// Platform clock.
|
||||
pub trait MonotonicClock {
|
||||
/// Current instant.
|
||||
fn now(&self) -> MonotonicInstant;
|
||||
@@ -14,26 +14,74 @@ pub trait MonotonicClock {
|
||||
/// Platform event.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum PlatformEvent {
|
||||
/// Quit requested.
|
||||
Quit,
|
||||
/// Window/application requested to quit.
|
||||
QuitRequested,
|
||||
/// Window focus changed.
|
||||
FocusChanged { focused: bool },
|
||||
/// Window resize or move to a new drawable size.
|
||||
Resize { width: u32, height: u32 },
|
||||
/// Device pixel ratio changed.
|
||||
DpiChanged { scale: f64 },
|
||||
/// Window minimized/hidden.
|
||||
Minimized { minimized: bool },
|
||||
/// Window occlusion state changed.
|
||||
Occluded { occluded: bool },
|
||||
/// Window is being suspended.
|
||||
Suspended,
|
||||
/// Window resumed from suspend.
|
||||
Resumed,
|
||||
/// Keyboard/scancode input.
|
||||
KeyboardInput {
|
||||
/// Platform scancode.
|
||||
scancode: u32,
|
||||
/// Pressed state.
|
||||
pressed: bool,
|
||||
},
|
||||
/// Mouse button input.
|
||||
MouseInput {
|
||||
/// Mouse button code.
|
||||
button: u16,
|
||||
/// Pressed state.
|
||||
pressed: bool,
|
||||
/// X position in window coordinates.
|
||||
x: f64,
|
||||
/// Y position in window coordinates.
|
||||
y: f64,
|
||||
},
|
||||
/// Mouse cursor movement.
|
||||
CursorMoved {
|
||||
/// Cursor x.
|
||||
x: f64,
|
||||
/// Cursor y.
|
||||
y: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Platform error.
|
||||
/// Platform error with optional source detail.
|
||||
#[derive(Debug)]
|
||||
pub enum PlatformError {
|
||||
/// Backend failed.
|
||||
Backend,
|
||||
/// Backend/backend-specific failure.
|
||||
Backend {
|
||||
/// Operation or subsystem.
|
||||
context: &'static str,
|
||||
/// Human-readable details.
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PlatformError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{self:?}")
|
||||
match self {
|
||||
Self::Backend { context, message } => {
|
||||
write!(f, "{context}: {message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PlatformError {}
|
||||
|
||||
/// Event source.
|
||||
/// Event source contract for polling platform events.
|
||||
pub trait EventSource {
|
||||
/// Polls events.
|
||||
///
|
||||
@@ -43,7 +91,7 @@ pub trait EventSource {
|
||||
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError>;
|
||||
}
|
||||
|
||||
/// Physical size.
|
||||
/// Physical window size.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct PhysicalSize {
|
||||
/// Width.
|
||||
@@ -52,42 +100,83 @@ pub struct PhysicalSize {
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// Window port.
|
||||
/// Window identity as a stable opaque handle token.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||
pub struct WindowHandle {
|
||||
/// Opaque integer token.
|
||||
pub id: u64,
|
||||
}
|
||||
|
||||
/// Window presentation and lifecycle port.
|
||||
///
|
||||
/// Presentation is not owned by the window abstraction. Render adapters
|
||||
/// own swapchain and present lifecycle.
|
||||
pub trait WindowPort {
|
||||
/// Drawable size.
|
||||
/// Current drawable size.
|
||||
fn drawable_size(&self) -> PhysicalSize;
|
||||
/// Presents.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PlatformError`] when the backend cannot present the current
|
||||
/// frame.
|
||||
fn present(&mut self) -> Result<(), PlatformError>;
|
||||
/// DPI scale for this window.
|
||||
fn dpi_scale(&self) -> f64;
|
||||
/// Whether the window is focused.
|
||||
fn has_focus(&self) -> bool;
|
||||
/// Whether the window is minimized.
|
||||
fn is_minimized(&self) -> bool;
|
||||
/// Whether the window is occluded.
|
||||
fn is_occluded(&self) -> bool;
|
||||
/// Opaque window identity.
|
||||
fn handle(&self) -> WindowHandle;
|
||||
}
|
||||
|
||||
/// Graphics profile.
|
||||
/// Render backend request contract.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum GraphicsProfile {
|
||||
/// Desktop core.
|
||||
DesktopCore,
|
||||
/// Embedded profile.
|
||||
Embedded,
|
||||
pub struct RenderRequest {
|
||||
/// Preferred color-space profile.
|
||||
pub color_space: ColorSpace,
|
||||
/// Preferred presentation mode.
|
||||
pub presentation: PresentationMode,
|
||||
/// Requested depth/stencil format.
|
||||
pub depth: DepthStencilSupport,
|
||||
}
|
||||
|
||||
/// Version.
|
||||
/// Color-space profile.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct Version {
|
||||
/// Major.
|
||||
pub major: u8,
|
||||
/// Minor.
|
||||
pub minor: u8,
|
||||
pub enum ColorSpace {
|
||||
/// sRGB nonlinear.
|
||||
Srgb,
|
||||
/// Linear color-space.
|
||||
Linear,
|
||||
}
|
||||
|
||||
/// Graphics context request.
|
||||
/// Presentation mode.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct GraphicsContextRequest {
|
||||
/// Profile.
|
||||
pub profile: GraphicsProfile,
|
||||
/// Version.
|
||||
pub version: Version,
|
||||
pub enum PresentationMode {
|
||||
/// VSync.
|
||||
Fifo,
|
||||
/// No VSync.
|
||||
Immediate,
|
||||
/// Triple-buffer mailbox fallback.
|
||||
Mailbox,
|
||||
}
|
||||
|
||||
/// Depth/stencil support profile requested by the composition root.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct DepthStencilSupport {
|
||||
/// Depth bits.
|
||||
pub depth_bits: u8,
|
||||
/// Stencil bits.
|
||||
pub stencil_bits: u8,
|
||||
}
|
||||
|
||||
impl RenderRequest {
|
||||
/// Returns a conservative default request.
|
||||
#[must_use]
|
||||
pub const fn conservative() -> Self {
|
||||
Self {
|
||||
color_space: ColorSpace::Srgb,
|
||||
presentation: PresentationMode::Fifo,
|
||||
depth: DepthStencilSupport {
|
||||
depth_bits: 24,
|
||||
stencil_bits: 8,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,12 @@ repository.workspace = true
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
fparkan-material = { path = "../fparkan-material" }
|
||||
fparkan-msh = { path = "../fparkan-msh" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-resource = { path = "../fparkan-resource" }
|
||||
fparkan-texm = { path = "../fparkan-texm" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
|
||||
+549
-281
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,71 @@ pub enum WriteProfile {
|
||||
Lossless,
|
||||
}
|
||||
|
||||
/// Error returned when mutable editing is attempted.
|
||||
#[derive(Debug)]
|
||||
pub enum RsliMutationError {
|
||||
/// Entry id is not present in this editable document.
|
||||
EntryNotFound {
|
||||
/// Requested entry id.
|
||||
id: EntryId,
|
||||
},
|
||||
/// Entry name does not fit into a 12-byte fixed field.
|
||||
AuthoringNameTooLong {
|
||||
/// Observed length in bytes.
|
||||
len: usize,
|
||||
/// Maximum accepted length for an authoring field.
|
||||
max: usize,
|
||||
},
|
||||
/// Entry name contains an explicit NUL byte.
|
||||
AuthoringNameContainsNul {
|
||||
/// Byte offset within the provided name.
|
||||
offset: usize,
|
||||
},
|
||||
/// Packed payload size overflows the format `u32` field.
|
||||
PackedPayloadTooLarge {
|
||||
/// Requested packed payload size.
|
||||
size: usize,
|
||||
/// Format maximum (`u32::MAX`).
|
||||
max: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RsliMutationError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::EntryNotFound { id } => write!(f, "entry id {id:?} is not present"),
|
||||
Self::AuthoringNameTooLong { len, max } => {
|
||||
write!(f, "authoring name is too long: {len} > {max}")
|
||||
}
|
||||
Self::AuthoringNameContainsNul { offset } => {
|
||||
write!(f, "authoring name contains embedded NUL at {offset}")
|
||||
}
|
||||
Self::PackedPayloadTooLarge { size, max } => {
|
||||
write!(f, "packed payload is too large: {size} > {max}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RsliMutationError {}
|
||||
|
||||
/// Mutable editor for `RsliDocument` that can rebuild lookup tables.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RsliEditor {
|
||||
original_image: Arc<[u8]>,
|
||||
header: RsliHeader,
|
||||
overlay: u32,
|
||||
ao_trailer: Option<[u8; 6]>,
|
||||
entries: Vec<EditableEntry>,
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct EditableEntry {
|
||||
meta: EntryMeta,
|
||||
packed: Vec<u8>,
|
||||
}
|
||||
|
||||
/// `RsLi` compatibility switches.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct RsliCompatibilityProfile {
|
||||
@@ -493,6 +558,180 @@ impl RsliDocument {
|
||||
WriteProfile::Lossless => self.bytes.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a mutable editor from the parsed document.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RsliError`] when source payloads cannot be copied from the
|
||||
/// underlying archive image.
|
||||
pub fn editor(&self) -> Result<RsliEditor, RsliError> {
|
||||
let mut entries = Vec::with_capacity(self.records.len());
|
||||
for (id, record) in self.records.iter().enumerate() {
|
||||
let packed = self
|
||||
.packed_slice(EntryId(u32::try_from(id).map_err(|_| RsliError::IntegerOverflow)?)?,
|
||||
record,
|
||||
)?
|
||||
.to_vec();
|
||||
entries.push(EditableEntry {
|
||||
meta: record.meta.clone(),
|
||||
packed,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RsliEditor {
|
||||
original_image: self.bytes.clone(),
|
||||
header: self.header.clone(),
|
||||
overlay: self.ao_trailer.as_ref().map_or(0, |overlay| overlay.overlay),
|
||||
ao_trailer: self.ao_trailer.as_ref().map(|overlay| overlay.raw),
|
||||
entries,
|
||||
dirty: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RsliEditor {
|
||||
/// Returns editable entries by original directory id.
|
||||
#[must_use]
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Replaces packed payload bytes for an entry.
|
||||
///
|
||||
/// `unpacked_size` is stored explicitly for compatibility checks and does
|
||||
/// not imply a packing transform.
|
||||
pub fn set_packed_payload(
|
||||
&mut self,
|
||||
id: EntryId,
|
||||
packed: impl Into<Vec<u8>>,
|
||||
unpacked_size: u32,
|
||||
) -> Result<(), RsliMutationError> {
|
||||
let entry = self.entry_mut(id)?;
|
||||
let packed = packed.into();
|
||||
entry.meta.packed_size = u32::try_from(packed.len()).map_err(|_| {
|
||||
RsliMutationError::PackedPayloadTooLarge {
|
||||
size: packed.len(),
|
||||
max: usize::try_from(u32::MAX).expect("u32 max always fits usize"),
|
||||
}
|
||||
})?;
|
||||
entry.packed = packed;
|
||||
entry.meta.unpacked_size = unpacked_size;
|
||||
self.dirty = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replaces entry packing method in-place.
|
||||
pub fn set_method(&mut self, id: EntryId, method: RsliMethod) -> Result<(), RsliMutationError> {
|
||||
let entry = self.entry_mut(id)?;
|
||||
entry.meta.method = method;
|
||||
self.dirty = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replaces entry name in the fixed 12-byte table field.
|
||||
pub fn set_name(&mut self, id: EntryId, name: &[u8]) -> Result<(), RsliMutationError> {
|
||||
let entry = self.entry_mut(id)?;
|
||||
entry.meta.name_raw = authoring_name_raw(name)?;
|
||||
entry.meta.name = decode_name(c_name_bytes(&entry.meta.name_raw));
|
||||
self.dirty = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encodes the document according to editor state.
|
||||
///
|
||||
/// For untouched documents returns the original image verbatim. On any
|
||||
/// mutation this method rebuilds the lookup table and rewrites packed entry
|
||||
/// bytes deterministically.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RsliError`] when offsets, sizes or ids exceed in-memory limits.
|
||||
pub fn encode(&self) -> Result<Vec<u8>, RsliError> {
|
||||
if !self.dirty {
|
||||
return Ok(self.original_image.to_vec());
|
||||
}
|
||||
self.encode_rebuild()
|
||||
}
|
||||
|
||||
fn encode_rebuild(&self) -> Result<Vec<u8>, RsliError> {
|
||||
let mut output = Vec::with_capacity(self.original_image.len());
|
||||
|
||||
let entry_count = u16::try_from(self.entries.len()).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let table_len = self
|
||||
.entries
|
||||
.len()
|
||||
.checked_mul(32)
|
||||
.ok_or(RsliError::IntegerOverflow)?;
|
||||
|
||||
let mut header = self.header.raw;
|
||||
header[4..6].copy_from_slice(&entry_count.to_le_bytes());
|
||||
output.extend_from_slice(&header);
|
||||
|
||||
let mut sorted = (0..self.entries.len()).collect::<Vec<_>>();
|
||||
sorted.sort_by(|left, right| {
|
||||
cmp_c_string(
|
||||
c_name_bytes(&self.entries[*left].meta.name_raw),
|
||||
c_name_bytes(&self.entries[*right].meta.name_raw),
|
||||
)
|
||||
});
|
||||
|
||||
let mut lookup_map = vec![0i16; self.entries.len()];
|
||||
for (position, original) in sorted.iter().enumerate() {
|
||||
lookup_map[*original] = i16::try_from(position).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
}
|
||||
|
||||
let mut cursor = 32usize
|
||||
.checked_add(table_len)
|
||||
.ok_or(RsliError::IntegerOverflow)?;
|
||||
let mut table_plain = Vec::with_capacity(table_len);
|
||||
for (index, entry) in self.entries.iter().enumerate() {
|
||||
let mut row = [0u8; 32];
|
||||
let name_len = entry.meta.name_raw.len().min(12);
|
||||
row[0..name_len].copy_from_slice(&entry.meta.name_raw[..name_len]);
|
||||
|
||||
row[16..18].copy_from_slice(&i16::try_from(entry.meta.flags)
|
||||
.map_err(|_| RsliError::IntegerOverflow)?
|
||||
.to_le_bytes());
|
||||
row[18..20].copy_from_slice(&lookup_map[index].to_le_bytes());
|
||||
row[20..24].copy_from_slice(&entry.meta.unpacked_size.to_le_bytes());
|
||||
|
||||
let packed_len = u32::try_from(entry.packed.len()).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let cursor_u32 = u32::try_from(cursor).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let offset_raw = if self.overlay == 0 {
|
||||
cursor_u32
|
||||
} else {
|
||||
cursor_u32
|
||||
.checked_sub(self.overlay)
|
||||
.ok_or(RsliError::IntegerOverflow)?
|
||||
};
|
||||
|
||||
row[24..28].copy_from_slice(&offset_raw.to_le_bytes());
|
||||
row[28..32].copy_from_slice(&packed_len.to_le_bytes());
|
||||
table_plain.extend_from_slice(&row);
|
||||
|
||||
output.extend_from_slice(&entry.packed);
|
||||
cursor = cursor
|
||||
.checked_add(entry.packed.len())
|
||||
.ok_or(RsliError::IntegerOverflow)?;
|
||||
}
|
||||
|
||||
let seed = self.header.xor_seed & 0xFFFF;
|
||||
let encrypted = xor_stream(&table_plain, seed);
|
||||
output.splice(32..32, encrypted.into_iter());
|
||||
|
||||
if let Some(overlay) = &self.ao_trailer {
|
||||
output.extend_from_slice(overlay);
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn entry_mut(&mut self, id: EntryId) -> Result<&mut EditableEntry, RsliMutationError> {
|
||||
self.entries
|
||||
.get_mut(usize::try_from(id.0).map_err(|_| RsliMutationError::EntryNotFound { id })?)
|
||||
.ok_or_else(|| RsliMutationError::EntryNotFound { id })
|
||||
}
|
||||
}
|
||||
|
||||
impl RsliDocument {
|
||||
@@ -833,6 +1072,23 @@ fn decode_name(name: &[u8]) -> String {
|
||||
name.iter().map(|byte| char::from(*byte)).collect()
|
||||
}
|
||||
|
||||
fn authoring_name_raw(name: &[u8]) -> Result<[u8; 12], RsliMutationError> {
|
||||
if name.len() > 12 {
|
||||
return Err(RsliMutationError::AuthoringNameTooLong {
|
||||
len: name.len(),
|
||||
max: 12,
|
||||
});
|
||||
}
|
||||
let mut output = [0u8; 12];
|
||||
for (offset, byte) in name.iter().copied().enumerate() {
|
||||
if byte == 0 {
|
||||
return Err(RsliMutationError::AuthoringNameContainsNul { offset });
|
||||
}
|
||||
output[offset] = byte;
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
|
||||
let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len());
|
||||
&raw[..len]
|
||||
@@ -1814,6 +2070,85 @@ mod tests {
|
||||
assert_eq!(doc.encode(WriteProfile::Lossless), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_roundtrip_without_mutations_is_identity() {
|
||||
let bytes = synthetic_rsli(
|
||||
&[
|
||||
SyntheticEntry::stored(b"A", 0, b"alpha"),
|
||||
SyntheticEntry::stored(b"B", 1, b"beta"),
|
||||
],
|
||||
true,
|
||||
0x7777,
|
||||
None,
|
||||
);
|
||||
|
||||
let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("editable archive");
|
||||
let editor = doc.editor().expect("editor");
|
||||
|
||||
assert_eq!(editor.encode().expect("editor encode"), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_can_mutate_names_and_payloads() {
|
||||
let bytes = synthetic_rsli(
|
||||
&[
|
||||
SyntheticEntry::stored(b"A", 0, b"alpha"),
|
||||
SyntheticEntry::stored(b"B", 1, b"beta"),
|
||||
],
|
||||
true,
|
||||
0x7778,
|
||||
None,
|
||||
);
|
||||
|
||||
let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive");
|
||||
let mut editor = doc.editor().expect("editor");
|
||||
editor
|
||||
.set_name(EntryId(1), b"ZETA")
|
||||
.expect("edit name");
|
||||
editor
|
||||
.set_packed_payload(EntryId(0), b"repacked-alpha", 13)
|
||||
.expect("edit packed payload");
|
||||
editor
|
||||
.set_method(EntryId(0), RsliMethod::RawDeflate)
|
||||
.expect("edit method");
|
||||
|
||||
let rebuilt = editor.encode().expect("editor encode");
|
||||
let doc = decode(arc(rebuilt), ReadProfile::Strict).expect("repacked archive");
|
||||
|
||||
let renamed = doc.find("ZETA").expect("renamed entry");
|
||||
assert_eq!(
|
||||
doc.load(renamed).expect("renamed payload"),
|
||||
b"beta"
|
||||
);
|
||||
let original = doc
|
||||
.find("A")
|
||||
.or_else(|| doc.find("a"))
|
||||
.expect("original renamed entry fallback");
|
||||
assert_eq!(doc.load(original).expect("updated payload"), b"repacked-alpha");
|
||||
assert_eq!(doc.entries()[original.0 as usize].method, RsliMethod::RawDeflate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_rejects_unknown_entry_id_and_invalid_name() {
|
||||
let bytes = synthetic_rsli(
|
||||
&[SyntheticEntry::stored(b"A", 0, b"alpha")],
|
||||
true,
|
||||
0x7779,
|
||||
None,
|
||||
);
|
||||
let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive");
|
||||
let mut editor = doc.editor().expect("editor");
|
||||
|
||||
assert!(matches!(
|
||||
editor.set_name(EntryId(10), b"BAD"),
|
||||
Err(RsliMutationError::EntryNotFound { id: EntryId(10) })
|
||||
));
|
||||
assert!(matches!(
|
||||
editor.set_name(EntryId(0), b"TOO_LONG_ENTRY_NAME"),
|
||||
Err(RsliMutationError::AuthoringNameTooLong { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_supported_methods_decode_expected_bytes() {
|
||||
let cases = [
|
||||
|
||||
@@ -6,15 +6,12 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-mission-format = { path = "../fparkan-mission-format" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-assets = { path = "../fparkan-assets" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-platform = { path = "../fparkan-platform" }
|
||||
fparkan-prototype = { path = "../fparkan-prototype" }
|
||||
fparkan-render = { path = "../fparkan-render" }
|
||||
fparkan-resource = { path = "../fparkan-resource" }
|
||||
fparkan-terrain = { path = "../fparkan-terrain" }
|
||||
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||
fparkan-world = { path = "../fparkan-world" }
|
||||
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runtime orchestration for headless and rendered modes.
|
||||
|
||||
use fparkan_mission_format::{
|
||||
decode_tma, decode_tma_land_path, LpString, MissionDocument, MissionError, TmaProfile,
|
||||
use fparkan_assets::{
|
||||
AssetError as AssetPreparationError, AssetManager, MissionAssetPlan,
|
||||
decode_mission_land_path, decode_nres_payload, decode_mission_payload, prepare_terrain_world,
|
||||
derive_mission_land_paths, BuildCategory, MissionDocument, MissionError, MissionTerrainPaths,
|
||||
TerrainFormatError, TerrainPreparationError, TmaProfile, TerrainWorld,
|
||||
NresError,
|
||||
extend_graph_report_with_visual_dependencies,
|
||||
};
|
||||
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
|
||||
use fparkan_prototype::{
|
||||
build_prototype_graph_report, extend_graph_report_with_visual_dependencies, EffectivePrototype,
|
||||
build_prototype_graph_report,
|
||||
PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport,
|
||||
};
|
||||
use fparkan_resource::{resource_name, CachedResourceRepository};
|
||||
use fparkan_terrain::TerrainWorld;
|
||||
use fparkan_terrain_format::{
|
||||
decode_build_dat, decode_land_map, decode_land_msh, BuildCategory, TerrainFormatError,
|
||||
};
|
||||
use fparkan_vfs::{Vfs, VfsError};
|
||||
use fparkan_world::{
|
||||
construct_object, new as new_world, register_object, step, InputSnapshot, ObjectDraft,
|
||||
@@ -21,6 +22,8 @@ use fparkan_world::{
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use fparkan_assets::MissionAssets;
|
||||
|
||||
/// Engine mode.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum EngineMode {
|
||||
@@ -167,6 +170,8 @@ pub struct LoadedMission {
|
||||
pub graph_unit_component_count: usize,
|
||||
/// Mission prototype graph root count.
|
||||
pub graph_root_count: usize,
|
||||
/// Mission asset plan visual count after dependency preparation.
|
||||
pub asset_visual_count: usize,
|
||||
/// Expanded prototype requests resolved to effective prototypes.
|
||||
pub graph_resolved_count: usize,
|
||||
/// Reached mesh dependency count.
|
||||
@@ -189,6 +194,14 @@ pub struct LoadedMission {
|
||||
pub graph_lightmap_request_count: usize,
|
||||
/// Lightmap Texm entries decoded.
|
||||
pub graph_lightmap_resolved_count: usize,
|
||||
/// Mission asset plan mesh-backed count after dependency preparation.
|
||||
pub asset_model_count: usize,
|
||||
/// Mission asset plan material count after dependency preparation.
|
||||
pub asset_material_count: usize,
|
||||
/// Mission asset plan texture count after dependency preparation.
|
||||
pub asset_texture_count: usize,
|
||||
/// Mission asset plan lightmap count after dependency preparation.
|
||||
pub asset_lightmap_count: usize,
|
||||
}
|
||||
|
||||
/// Frame result.
|
||||
@@ -222,7 +235,8 @@ struct LoadedMissionState {
|
||||
build_categories: Vec<BuildCategory>,
|
||||
prototype_graph: PrototypeGraph,
|
||||
prototype_report: PrototypeGraphReport,
|
||||
resolved_prototypes: Vec<EffectivePrototype>,
|
||||
mission_assets: MissionAssets,
|
||||
asset_plan: MissionAssetPlan,
|
||||
}
|
||||
|
||||
/// Engine error.
|
||||
@@ -251,7 +265,7 @@ pub enum EngineError {
|
||||
/// Resource path.
|
||||
path: String,
|
||||
/// Source error.
|
||||
source: fparkan_nres::NresError,
|
||||
source: NresError,
|
||||
},
|
||||
/// Mission decode error.
|
||||
Mission {
|
||||
@@ -268,12 +282,19 @@ pub enum EngineError {
|
||||
source: TerrainFormatError,
|
||||
},
|
||||
/// Terrain runtime build error.
|
||||
Terrain(fparkan_terrain::TerrainError),
|
||||
Terrain(fparkan_assets::TerrainError),
|
||||
/// Prototype graph errors.
|
||||
PrototypeGraph {
|
||||
/// Root failures.
|
||||
failures: Vec<PrototypeGraphFailure>,
|
||||
},
|
||||
/// Asset preparation errors.
|
||||
AssetPreparation {
|
||||
/// Mission key.
|
||||
mission: String,
|
||||
/// Source error.
|
||||
source: AssetPreparationError,
|
||||
},
|
||||
/// World error.
|
||||
World(fparkan_world::WorldError),
|
||||
/// Scheduler phase order was violated.
|
||||
@@ -319,6 +340,9 @@ impl std::fmt::Display for EngineError {
|
||||
Self::PrototypeGraph { failures } => {
|
||||
write!(f, "mission prototype graph has {} failures", failures.len())
|
||||
}
|
||||
Self::AssetPreparation { mission, source } => {
|
||||
write!(f, "{mission}: asset preparation failed: {source}")
|
||||
}
|
||||
Self::World(source) => write!(f, "{source}"),
|
||||
Self::SchedulerPhaseOrder { previous, current } => write!(
|
||||
f,
|
||||
@@ -346,6 +370,7 @@ impl std::error::Error for EngineError {
|
||||
Self::TerrainFormat { source, .. } => Some(source),
|
||||
Self::Terrain(source) => Some(source),
|
||||
Self::World(source) => Some(source),
|
||||
Self::AssetPreparation { source, .. } => Some(source),
|
||||
Self::MissingVfs
|
||||
| Self::PrototypeGraph { .. }
|
||||
| Self::SchedulerPhaseOrder { .. }
|
||||
@@ -410,44 +435,44 @@ fn load_mission_with_options(
|
||||
let mission_bytes = read_vfs(&vfs, &mission_path)?;
|
||||
|
||||
trace.phases.push(MissionLoadPhase::Map);
|
||||
let land_path = decode_tma_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| {
|
||||
let land_path = decode_mission_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| {
|
||||
EngineError::Mission {
|
||||
path: mission_path.as_str().to_string(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
let (land_msh_path, land_map_path) = terrain_paths_from_land_path(&land_path)?;
|
||||
let land_msh_nres = decode_nres(&vfs, &land_msh_path)?;
|
||||
let land_map_nres = decode_nres(&vfs, &land_map_path)?;
|
||||
let land_msh =
|
||||
decode_land_msh(&land_msh_nres).map_err(|source| EngineError::TerrainFormat {
|
||||
let MissionTerrainPaths { land_msh: land_msh_path, land_map: land_map_path } =
|
||||
derive_mission_land_paths(&land_path).map_err(|source| EngineError::Path {
|
||||
role: "mission land",
|
||||
value: mission_path.as_str().to_string(),
|
||||
source,
|
||||
})?;
|
||||
let land_msh_nres = decode_nres_payload(read_vfs(&vfs, &land_msh_path)?)
|
||||
.map_err(|source| EngineError::Nres {
|
||||
path: land_msh_path.as_str().to_string(),
|
||||
source,
|
||||
})?;
|
||||
let land_map =
|
||||
decode_land_map(&land_map_nres).map_err(|source| EngineError::TerrainFormat {
|
||||
let land_map_nres = decode_nres_payload(read_vfs(&vfs, &land_map_path)?)
|
||||
.map_err(|source| EngineError::Nres {
|
||||
path: land_map_path.as_str().to_string(),
|
||||
source,
|
||||
})?;
|
||||
let terrain =
|
||||
TerrainWorld::from_land_assets(&land_msh, &land_map).map_err(EngineError::Terrain)?;
|
||||
|
||||
let build_dat_path = normalize_engine_path("BuildDat", "BuildDat.lst")?;
|
||||
let build_dat = read_vfs(&vfs, &build_dat_path)?;
|
||||
let build_categories =
|
||||
decode_build_dat(&build_dat).map_err(|source| EngineError::TerrainFormat {
|
||||
path: build_dat_path.as_str().to_string(),
|
||||
source,
|
||||
let (terrain, build_categories) = prepare_terrain_world(&land_msh_nres, &land_map_nres, &build_dat)
|
||||
.map_err(|source| match source {
|
||||
TerrainPreparationError::Decode(source) => EngineError::TerrainFormat {
|
||||
path: build_dat_path.as_str().to_string(),
|
||||
source,
|
||||
},
|
||||
TerrainPreparationError::Runtime(source) => EngineError::Terrain(source),
|
||||
})?;
|
||||
trace.phases.push(MissionLoadPhase::Tma);
|
||||
let mission =
|
||||
decode_tma(mission_bytes, TmaProfile::Strict).map_err(|source| EngineError::Mission {
|
||||
decode_mission_payload(mission_bytes, TmaProfile::Strict).map_err(|source| EngineError::Mission {
|
||||
path: mission_path.as_str().to_string(),
|
||||
source,
|
||||
})?;
|
||||
let verified_terrain_paths = terrain_paths(&mission)?;
|
||||
debug_assert_eq!(verified_terrain_paths.0.as_str(), land_msh_path.as_str());
|
||||
debug_assert_eq!(verified_terrain_paths.1.as_str(), land_map_path.as_str());
|
||||
trace.transforms = mission
|
||||
.objects
|
||||
.iter()
|
||||
@@ -471,6 +496,7 @@ fn load_mission_with_options(
|
||||
extend_graph_report_with_visual_dependencies(
|
||||
&repository,
|
||||
&mut prototype_report,
|
||||
&prototype_graph,
|
||||
&resolved_prototypes,
|
||||
);
|
||||
if !prototype_report.is_success() {
|
||||
@@ -478,6 +504,16 @@ fn load_mission_with_options(
|
||||
failures: prototype_report.failures.clone(),
|
||||
});
|
||||
}
|
||||
let mission_assets = AssetManager::new(repository)
|
||||
.prepare_mission_assets(
|
||||
&prototype_graph.root_prototype_request_spans,
|
||||
&resolved_prototypes,
|
||||
)
|
||||
.map_err(|source| EngineError::AssetPreparation {
|
||||
mission: request.key.clone(),
|
||||
source,
|
||||
})?;
|
||||
let mission_asset_plan = mission_assets.to_plan();
|
||||
trace.phases.push(MissionLoadPhase::Assets);
|
||||
|
||||
let mut new_runtime_world = new_world(WorldConfig);
|
||||
@@ -519,6 +555,7 @@ fn load_mission_with_options(
|
||||
graph_direct_reference_count: prototype_report.direct_reference_count,
|
||||
graph_unit_component_count: prototype_report.unit_component_count,
|
||||
graph_root_count: prototype_report.root_count,
|
||||
asset_visual_count: mission_asset_plan.visual_count,
|
||||
graph_resolved_count: prototype_report.resolved_count,
|
||||
graph_mesh_dependency_count: prototype_report.mesh_dependency_count,
|
||||
graph_failure_count: prototype_report.failures.len(),
|
||||
@@ -530,6 +567,10 @@ fn load_mission_with_options(
|
||||
graph_texture_resolved_count: prototype_report.texture_resolved_count,
|
||||
graph_lightmap_request_count: prototype_report.lightmap_request_count,
|
||||
graph_lightmap_resolved_count: prototype_report.lightmap_resolved_count,
|
||||
asset_model_count: mission_asset_plan.model_count,
|
||||
asset_material_count: mission_asset_plan.material_count,
|
||||
asset_texture_count: mission_asset_plan.texture_count,
|
||||
asset_lightmap_count: mission_asset_plan.lightmap_count,
|
||||
};
|
||||
|
||||
engine.world = new_runtime_world;
|
||||
@@ -540,7 +581,8 @@ fn load_mission_with_options(
|
||||
build_categories,
|
||||
prototype_graph,
|
||||
prototype_report,
|
||||
resolved_prototypes,
|
||||
mission_assets,
|
||||
asset_plan: mission_asset_plan,
|
||||
});
|
||||
Ok((summary, trace))
|
||||
}
|
||||
@@ -618,13 +660,16 @@ pub fn loaded_prototype_graph_report(engine: &Engine) -> Option<&PrototypeGraphR
|
||||
engine.loaded.as_ref().map(|state| &state.prototype_report)
|
||||
}
|
||||
|
||||
/// Returns resolved effective prototypes for the loaded mission.
|
||||
/// Returns the prepared mission asset plan for the loaded mission.
|
||||
#[must_use]
|
||||
pub fn loaded_resolved_prototypes(engine: &Engine) -> Option<&[EffectivePrototype]> {
|
||||
engine
|
||||
.loaded
|
||||
.as_ref()
|
||||
.map(|state| state.resolved_prototypes.as_slice())
|
||||
pub fn loaded_mission_asset_plan(engine: &Engine) -> Option<&MissionAssetPlan> {
|
||||
engine.loaded.as_ref().map(|state| &state.asset_plan)
|
||||
}
|
||||
|
||||
/// Returns prepared mission assets for the loaded mission.
|
||||
#[must_use]
|
||||
pub fn loaded_mission_assets(engine: &Engine) -> Option<&MissionAssets> {
|
||||
engine.loaded.as_ref().map(|state| &state.mission_assets)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -716,49 +761,6 @@ fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, Engi
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_nres(
|
||||
vfs: &Arc<dyn Vfs>,
|
||||
path: &NormalizedPath,
|
||||
) -> Result<fparkan_nres::NresDocument, EngineError> {
|
||||
let bytes = read_vfs(vfs, path)?;
|
||||
fparkan_nres::decode(bytes, fparkan_nres::ReadProfile::Compatible).map_err(|source| {
|
||||
EngineError::Nres {
|
||||
path: path.as_str().to_string(),
|
||||
source,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn terrain_paths(
|
||||
mission: &MissionDocument,
|
||||
) -> Result<(NormalizedPath, NormalizedPath), EngineError> {
|
||||
terrain_paths_from_land_path(&mission.land_path)
|
||||
}
|
||||
|
||||
fn terrain_paths_from_land_path(
|
||||
land_path: &LpString,
|
||||
) -> Result<(NormalizedPath, NormalizedPath), EngineError> {
|
||||
let land_path_raw = String::from_utf8_lossy(&land_path.raw).to_string();
|
||||
let normalized =
|
||||
normalize_relative(&land_path.raw, PathPolicy::StrictLegacy).map_err(|source| {
|
||||
EngineError::Path {
|
||||
role: "mission land",
|
||||
value: land_path_raw.clone(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
let Some((parent, _stem)) = normalized.as_str().rsplit_once('/') else {
|
||||
return Err(EngineError::Path {
|
||||
role: "mission land",
|
||||
value: normalized.as_str().to_string(),
|
||||
source: PathError::Empty,
|
||||
});
|
||||
};
|
||||
let mesh = normalize_engine_path("Land.msh", &format!("{parent}/Land.msh"))?;
|
||||
let map = normalize_engine_path("Land.map", &format!("{parent}/Land.map"))?;
|
||||
Ok((mesh, map))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+104
-19
@@ -8,6 +8,10 @@ use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
|
||||
/// VFS metadata.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -110,6 +114,7 @@ impl DirectoryVfs {
|
||||
struct CachedHostFingerprint {
|
||||
len: u64,
|
||||
modified: Option<SystemTime>,
|
||||
identity: Option<u64>,
|
||||
fingerprint: Sha256Digest,
|
||||
}
|
||||
|
||||
@@ -120,14 +125,23 @@ impl Vfs for DirectoryVfs {
|
||||
|
||||
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
||||
let host = self.host_path(path)?;
|
||||
if fs::symlink_metadata(&host)
|
||||
.map_err(VfsError::Io)?
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
let pre_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
|
||||
if pre_metadata.file_type().is_symlink() || !pre_metadata.is_file() {
|
||||
return Err(VfsError::Path);
|
||||
}
|
||||
let pre_identity = file_identity(&pre_metadata);
|
||||
let pre_len = pre_metadata.len();
|
||||
let pre_modified = pre_metadata.modified().ok();
|
||||
let bytes = fs::read(&host).map_err(VfsError::Io)?;
|
||||
let post_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
|
||||
if post_metadata.file_type().is_symlink()
|
||||
|| !post_metadata.is_file()
|
||||
|| post_metadata.len() != pre_len
|
||||
|| post_metadata.modified().ok() != pre_modified
|
||||
|| file_identity(&post_metadata) != pre_identity
|
||||
{
|
||||
return Err(VfsError::Path);
|
||||
}
|
||||
let bytes = fs::read(host).map_err(VfsError::Io)?;
|
||||
Ok(Arc::from(bytes.into_boxed_slice()))
|
||||
}
|
||||
|
||||
@@ -248,7 +262,11 @@ fn metadata_from_host_file_with_cache(
|
||||
.map_err(|_| VfsError::Path)?
|
||||
.get(path)
|
||||
.cloned()
|
||||
.filter(|cached| cached.len == len && cached.modified == modified)
|
||||
.filter(|cached| {
|
||||
cached.len == len
|
||||
&& cached.modified == modified
|
||||
&& cached.identity == file_identity(metadata)
|
||||
})
|
||||
{
|
||||
return Ok(VfsMetadata {
|
||||
len,
|
||||
@@ -266,6 +284,7 @@ fn metadata_from_host_file_with_cache(
|
||||
CachedHostFingerprint {
|
||||
len,
|
||||
modified,
|
||||
identity: file_identity(metadata),
|
||||
fingerprint,
|
||||
},
|
||||
);
|
||||
@@ -275,15 +294,15 @@ fn metadata_from_host_file_with_cache(
|
||||
/// In-memory VFS.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MemoryVfs {
|
||||
files: BTreeMap<String, Arc<[u8]>>,
|
||||
lookup: BTreeMap<Vec<u8>, Vec<String>>,
|
||||
files: BTreeMap<Vec<u8>, Arc<[u8]>>,
|
||||
lookup: BTreeMap<Vec<u8>, Vec<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl MemoryVfs {
|
||||
/// Inserts a file.
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) {
|
||||
let path = path.as_str().to_string();
|
||||
let path = path.as_bytes().to_vec();
|
||||
self.files.insert(path, bytes);
|
||||
self.rebuild_lookup();
|
||||
}
|
||||
@@ -292,7 +311,7 @@ impl MemoryVfs {
|
||||
self.lookup.clear();
|
||||
for path in self.files.keys() {
|
||||
self.lookup
|
||||
.entry(ascii_lookup_key(path.as_bytes()).0)
|
||||
.entry(ascii_lookup_key(path).0)
|
||||
.or_default()
|
||||
.push(path.clone());
|
||||
}
|
||||
@@ -301,20 +320,39 @@ impl MemoryVfs {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_path(&self, path: &NormalizedPath) -> Result<&str, VfsError> {
|
||||
let key = ascii_lookup_key(path.as_str().as_bytes()).0;
|
||||
fn resolve_path(&self, path: &NormalizedPath) -> Result<&[u8], VfsError> {
|
||||
let key = ascii_lookup_key(path.as_bytes()).0;
|
||||
let matches = self
|
||||
.lookup
|
||||
.get(&key)
|
||||
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
|
||||
match matches.as_slice() {
|
||||
[single] => Ok(single.as_str()),
|
||||
[single] => Ok(single.as_slice()),
|
||||
[] => Err(VfsError::NotFound(path.as_str().to_string())),
|
||||
_ => Err(VfsError::Ambiguous(path.as_str().to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
|
||||
Some((metadata.dev() as u64).rotate_left(32) ^ metadata.ino())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
|
||||
Some(
|
||||
(metadata.volume_serial_number() as u64).rotate_left(40)
|
||||
^ ((metadata.file_index_high() as u64) << 32)
|
||||
^ metadata.file_index_low() as u64,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn file_identity(_metadata: &fs::Metadata) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
impl Vfs for MemoryVfs {
|
||||
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
||||
let resolved = self.resolve_path(path)?;
|
||||
@@ -339,13 +377,9 @@ impl Vfs for MemoryVfs {
|
||||
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
||||
let mut out = Vec::new();
|
||||
for (path, bytes) in &self.files {
|
||||
if path
|
||||
.as_bytes()
|
||||
.get(..prefix.as_str().len())
|
||||
.is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_str().as_bytes()))
|
||||
{
|
||||
if has_segment_boundary_prefix_bytes(path, prefix.as_bytes()) {
|
||||
let normalized = fparkan_path::normalize_relative(
|
||||
path.as_bytes(),
|
||||
path,
|
||||
fparkan_path::PathPolicy::StrictLegacy,
|
||||
)
|
||||
.map_err(|_| VfsError::Path)?;
|
||||
@@ -362,6 +396,25 @@ impl Vfs for MemoryVfs {
|
||||
}
|
||||
}
|
||||
|
||||
fn has_segment_boundary_prefix_bytes(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
if haystack.len() < needle.len() {
|
||||
return false;
|
||||
}
|
||||
if haystack.len() == needle.len() {
|
||||
return haystack
|
||||
.iter()
|
||||
.zip(needle.iter())
|
||||
.all(|(left, right)| left.eq_ignore_ascii_case(right));
|
||||
}
|
||||
if haystack[needle.len()] != b'/' {
|
||||
return false;
|
||||
}
|
||||
haystack[..needle.len()]
|
||||
.iter()
|
||||
.zip(needle.iter())
|
||||
.all(|(left, right)| left.eq_ignore_ascii_case(right))
|
||||
}
|
||||
|
||||
/// Layered VFS with deterministic first-layer precedence.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct OverlayVfs {
|
||||
@@ -507,6 +560,21 @@ mod tests {
|
||||
std::fs::remove_dir_all(root).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_vfs_list_prefix_is_boundary_safe() {
|
||||
let mut vfs = MemoryVfs::default();
|
||||
let exact = normalize_relative(b"DATA/Land.map", PathPolicy::StrictLegacy).expect("path");
|
||||
let sibling = normalize_relative(b"DATA2/Land.map", PathPolicy::StrictLegacy).expect("path");
|
||||
vfs.insert(exact.clone(), Arc::from(b"exact".as_slice()));
|
||||
vfs.insert(sibling, Arc::from(b"sibling".as_slice()));
|
||||
|
||||
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
|
||||
let entries = vfs.list(&prefix).expect("list");
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].path.as_str(), exact.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_vfs_fingerprint_changes_for_same_length_content() {
|
||||
let root = unique_test_dir("content-fingerprint");
|
||||
@@ -589,6 +657,23 @@ mod tests {
|
||||
assert!(matches!(vfs.read(&query), Err(VfsError::Ambiguous(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_vfs_distinguishes_non_utf8_path_bytes() {
|
||||
let mut vfs = MemoryVfs::default();
|
||||
let ascii = normalize_relative(b"DATA/normal.bin", PathPolicy::HostCompatible)
|
||||
.expect("ascii path");
|
||||
let binary = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
|
||||
.expect("binary path");
|
||||
vfs.insert(ascii.clone(), Arc::from(b"ascii".as_slice()));
|
||||
vfs.insert(binary.clone(), Arc::from(b"binary".as_slice()));
|
||||
|
||||
let binary_query = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
|
||||
.expect("binary query");
|
||||
|
||||
assert_eq!(vfs.read(&binary_query).expect("read binary").as_ref(), b"binary");
|
||||
assert_eq!(vfs.read(&ascii).expect("read ascii").as_ref(), b"ascii");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_vfs_uses_first_matching_layer() {
|
||||
let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path");
|
||||
|
||||
Reference in New Issue
Block a user