diff --git a/README.md b/README.md index 4dd0c45..94319eb 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,12 @@ not a current support claim. > Tilewright is experimental. It can inventory and load selected project JSON > into bounded, read-only raw snapshots, then project map IDs, names, display > order, and parent relationships into a typed catalog. It can also summarize -> one catalog-selected map's basic metadata and opaque event count, and selected -> project-level system settings. A further bounded projection lists map-scoped -> event IDs, names, coordinates, and opaque page counts. Its first contextual -> validator checks only the stored player start. It does not yet provide broader -> semantic understanding, project validity, or modification. Do not rely on it -> for valuable workflows. +> one catalog-selected map's basic metadata and opaque event count, selected +> project-level system settings, tileset IDs and editor-facing names, and +> map-scoped event IDs, names, coordinates, and opaque page counts. Its first +> contextual validator checks only the stored player start. It does not yet +> provide broader semantic understanding, project validity, or modification. +> Do not rely on it for valuable workflows. ## What Tilewright aims to provide @@ -58,8 +58,8 @@ for the distinction between planned and supported behavior. | Package | Role | Current state | | --- | --- | --- | -| [`tilewright`](crates/tilewright/README.md) | Format-aware domain library and primary public API | Experimental discovery, inventory, strict lossless JSON syntax, raw snapshot loading, typed map and event catalogs, selected-map and system summaries, and player-start validation | -| [`tilewright-cli`](crates/tilewright-cli/README.md) | Human- and script-facing adapter; installs the `tilewright` executable | Experimental discovery, inventory, raw snapshot, typed map/event projections, player-start validation, and JSON inspection adapter | +| [`tilewright`](crates/tilewright/README.md) | Format-aware domain library and primary public API | Experimental discovery, inventory, strict lossless JSON syntax, raw snapshot loading, typed map, tileset, and event catalogs, selected-map and system summaries, and player-start validation | +| [`tilewright-cli`](crates/tilewright-cli/README.md) | Human- and script-facing adapter; installs the `tilewright` executable | Experimental discovery, inventory, raw snapshot, typed map/tileset/event projections, player-start validation, and JSON inspection adapter | | [`tilewright-mcp`](crates/tilewright-mcp/README.md) | Thin MCP adapter over the library | Scaffold | The dependency direction is inward: @@ -115,6 +115,7 @@ cargo run -p tilewright-cli -- discover path/to/project --format json cargo run -p tilewright-cli -- inventory path/to/project cargo run -p tilewright-cli -- snapshot path/to/project cargo run -p tilewright-cli -- maps path/to/project +cargo run -p tilewright-cli -- tilesets path/to/project cargo run -p tilewright-cli -- map path/to/project 1 cargo run -p tilewright-cli -- events path/to/project 1 cargo run -p tilewright-cli -- system path/to/project @@ -133,6 +134,7 @@ tilewright discover path/to/project --format json tilewright inventory path/to/project tilewright snapshot path/to/project tilewright maps path/to/project +tilewright tilesets path/to/project tilewright map path/to/project 1 tilewright events path/to/project 1 tilewright system path/to/project @@ -146,9 +148,11 @@ for update and uninstall details. The CLI currently exposes experimental candidate discovery, project inventory, bounded raw snapshot loading, typed map-catalog inspection, selected-map -summaries, selected-map event catalogs, selected system-setting summaries, -bounded player-start validation, and strict lossless JSON syntax inspection. -The event command leaves page bodies and commands opaque. The validation +summaries, tileset identity/name catalogs, selected-map event catalogs, +selected system-setting summaries, bounded player-start validation, and strict +lossless JSON syntax inspection. The tileset command leaves modes, images, +flags, and notes opaque; the event command leaves page bodies and commands +opaque. The validation command covers only the stored player start; it does not establish general project validity or editor compatibility. No command establishes MZ-version compatibility, round-trip behavior, or write support. Contributors should use diff --git a/crates/tilewright-cli/README.md b/crates/tilewright-cli/README.md index bf3511b..22dc853 100644 --- a/crates/tilewright-cli/README.md +++ b/crates/tilewright-cli/README.md @@ -7,14 +7,13 @@ ## Status This crate is experimental. It provides help and version output plus read-only -`discover`, `inventory`, `snapshot`, `maps`, `map`, `events`, `system`, -`validate`, and `inspect-json` -commands over the core library's experimental RPG Maker MZ -candidate-discovery, capability-relative project inventory, raw snapshot -loader, typed map catalog, selected-map summary and event catalog, system -summary, player-start validation, and strict lossless JSON syntax APIs. It does -not provide general project understanding, project validity, editor -compatibility, or modification. +`discover`, `inventory`, `snapshot`, `maps`, `tilesets`, `map`, `events`, +`system`, `validate`, and `inspect-json` commands over the core library's +experimental RPG Maker MZ candidate-discovery, capability-relative project +inventory, raw snapshot loader, typed map, tileset, and selected-map event +catalogs, selected-map and system summaries, player-start validation, and +strict lossless JSON syntax APIs. It does not provide general project +understanding, project validity, editor compatibility, or modification. ## Install from a checkout @@ -66,6 +65,10 @@ cargo run -p tilewright-cli -- snapshot path/to/project --format json cargo run -p tilewright-cli -- maps path/to/project cargo run -p tilewright-cli -- maps path/to/project --format json +# List tileset IDs and editor-facing names. +cargo run -p tilewright-cli -- tilesets path/to/project +cargo run -p tilewright-cli -- tilesets path/to/project --format json + # Summarize one catalog-selected map. cargo run -p tilewright-cli -- map path/to/project 1 cargo run -p tilewright-cli -- map path/to/project 1 --format json @@ -128,6 +131,16 @@ available on `maps`. The command does not interpret map contents or events, validate editor compatibility, provide stable project-wide resource identities, or establish mutation, round-trip, and write support. +The `tilesets` command loads the same bounded snapshot and reports positive +tileset IDs and decoded editor-facing names in ID order. Missing, unavailable, +or structurally ambiguous `data/Tilesets.json` prevents projection and exits +with code 1. Unrelated snapshot diagnostics remain separate. + +The command does not emit or interpret modes, image names, tile flags, notes, or +unknown fields. It does not validate map references, assets, tile behavior, +editor compatibility, mutation, round trips, or writes. The snapshot +resource-limit options are available on `tilesets`. + The `map` command loads the same bounded snapshot and delegates selection and projection to the core library. It requires a coherent map catalog and matching positive ID, then reports the catalog and display names, exact evidenced @@ -188,9 +201,9 @@ a non-UTF-8 path. Note: While descendant symlink entries are reported without traversal, the initial `open_ambient_dir` acquisition used by `inventory`, `snapshot`, `maps`, -`map`, `events`, `system`, and `validate` may resolve root or ancestor symlinks -and does not prove root identity. The `inspect-json` command explicitly opens -the provided path and makes no project-containment claim. +`tilesets`, `map`, `events`, `system`, and `validate` may resolve root or +ancestor symlinks and does not prove root identity. The `inspect-json` command +explicitly opens the provided path and makes no project-containment claim. ## Responsibilities diff --git a/crates/tilewright-cli/src/main.rs b/crates/tilewright-cli/src/main.rs index 29794a5..d0d50e8 100644 --- a/crates/tilewright-cli/src/main.rs +++ b/crates/tilewright-cli/src/main.rs @@ -31,6 +31,9 @@ use tilewright::rpg_maker_mz::snapshot::{ DocumentDiagnostic, ProjectSnapshot, SnapshotCompleteness, SnapshotLimits, load_snapshot, }; use tilewright::rpg_maker_mz::system_summary::{SystemSummary, SystemSummaryError, system_summary}; +use tilewright::rpg_maker_mz::tileset_catalog::{ + TilesetCatalog, TilesetCatalogError, TilesetField, tileset_catalog, +}; const OUTPUT_SCHEMA_VERSION: u8 = 1; @@ -136,6 +139,16 @@ enum Command { #[command(flatten)] limits: SnapshotLimitArgs, }, + /// List the experimental typed RPG Maker MZ tileset catalog. + Tilesets { + /// RPG Maker MZ project directory to inspect. + path: PathBuf, + /// Output intended for a person or a script. + #[arg(long, value_enum, default_value_t = OutputFormat::Human)] + format: OutputFormat, + #[command(flatten)] + limits: SnapshotLimitArgs, + }, /// Summarize one experimental, catalog-selected RPG Maker MZ map. Map { /// RPG Maker MZ project directory to inspect. @@ -472,6 +485,66 @@ struct MapsErrorReport { error: MapCatalogErrorDetail, } +#[derive(Debug, Serialize)] +struct TilesetsReport { + schema_version: u8, + root: PathReport, + snapshot_completeness: SnapshotCompletenessReport, + limits: SnapshotLimitsReport, + tileset_count: usize, + snapshot_diagnostic_count: usize, + tilesets: Vec, + snapshot_diagnostics: Vec, +} + +#[derive(Debug, Serialize)] +struct TilesetReport { + id: u32, + name: String, +} + +#[derive(Debug, Serialize)] +struct TilesetsErrorReport { + schema_version: u8, + root: PathReport, + snapshot_completeness: SnapshotCompletenessReport, + limits: SnapshotLimitsReport, + snapshot_diagnostics: Vec, + error: TilesetCatalogErrorDetail, +} + +#[derive(Debug, Serialize)] +struct TilesetCatalogErrorDetail { + category: TilesetCatalogErrorCategory, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + field: Option, + #[serde(skip_serializing_if = "Option::is_none")] + actual_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + decoded_id: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +enum TilesetCatalogErrorCategory { + MissingDocument, + UnavailableDocument, + UnexpectedRootKind, + UnexpectedEntryKind, + MissingField, + DuplicateField, + UnexpectedFieldKind, + UnsupportedInteger, + InvalidString, + IndexOutOfRange, + ReservedIndex, + IdIndexMismatch, + Unrecognized, +} + #[derive(Debug, Serialize)] struct MapCatalogErrorDetail { category: MapCatalogErrorCategory, @@ -1061,6 +1134,37 @@ impl MapsReport { } } +impl TilesetsReport { + fn new( + root: &Path, + snapshot: &ProjectSnapshot, + catalog: &TilesetCatalog, + limits: SnapshotLimits, + ) -> Self { + Self { + schema_version: OUTPUT_SCHEMA_VERSION, + root: PathReport::new(root), + snapshot_completeness: SnapshotCompletenessReport::new(snapshot.completeness()), + limits: SnapshotLimitsReport::new(limits), + tileset_count: catalog.records().len(), + snapshot_diagnostic_count: snapshot.diagnostics().len(), + tilesets: catalog + .records() + .values() + .map(|record| TilesetReport { + id: record.id().get(), + name: record.name().to_owned(), + }) + .collect(), + snapshot_diagnostics: snapshot + .diagnostics() + .iter() + .map(|(path, diagnostic)| SnapshotDiagnosticReport::new(path, diagnostic)) + .collect(), + } + } +} + impl SelectedMapReport { fn new( root: &Path, @@ -1881,6 +1985,142 @@ impl MapsErrorReport { } } +impl TilesetsErrorReport { + fn new( + root: &Path, + snapshot: &ProjectSnapshot, + limits: SnapshotLimits, + error: &TilesetCatalogError, + ) -> Self { + Self { + schema_version: OUTPUT_SCHEMA_VERSION, + root: PathReport::new(root), + snapshot_completeness: SnapshotCompletenessReport::new(snapshot.completeness()), + limits: SnapshotLimitsReport::new(limits), + snapshot_diagnostics: snapshot + .diagnostics() + .iter() + .map(|(path, diagnostic)| SnapshotDiagnosticReport::new(path, diagnostic)) + .collect(), + error: TilesetCatalogErrorDetail::new(error), + } + } +} + +impl TilesetCatalogErrorDetail { + fn new(error: &TilesetCatalogError) -> Self { + let (category, index, field, actual_kind, decoded_id) = match error { + TilesetCatalogError::MissingDocument => ( + TilesetCatalogErrorCategory::MissingDocument, + None, + None, + None, + None, + ), + TilesetCatalogError::UnavailableDocument => ( + TilesetCatalogErrorCategory::UnavailableDocument, + None, + None, + None, + None, + ), + TilesetCatalogError::UnexpectedRootKind { actual, .. } => ( + TilesetCatalogErrorCategory::UnexpectedRootKind, + None, + None, + Some(json_value_kind_name(*actual)), + None, + ), + TilesetCatalogError::UnexpectedEntryKind { index, actual, .. } => ( + TilesetCatalogErrorCategory::UnexpectedEntryKind, + Some(*index), + None, + Some(json_value_kind_name(*actual)), + None, + ), + TilesetCatalogError::MissingField { index, field, .. } => ( + TilesetCatalogErrorCategory::MissingField, + Some(*index), + Some(field.to_string()), + None, + None, + ), + TilesetCatalogError::DuplicateField { index, field, .. } => ( + TilesetCatalogErrorCategory::DuplicateField, + Some(*index), + Some(field.to_string()), + None, + None, + ), + TilesetCatalogError::UnexpectedFieldKind { + index, + field, + actual, + .. + } => ( + TilesetCatalogErrorCategory::UnexpectedFieldKind, + Some(*index), + Some(field.to_string()), + Some(json_value_kind_name(*actual)), + None, + ), + TilesetCatalogError::UnsupportedInteger { index, field, .. } => ( + TilesetCatalogErrorCategory::UnsupportedInteger, + Some(*index), + Some(field.to_string()), + None, + None, + ), + TilesetCatalogError::InvalidString { index, field, .. } => ( + TilesetCatalogErrorCategory::InvalidString, + Some(*index), + Some(field.to_string()), + None, + None, + ), + TilesetCatalogError::IndexOutOfRange { index, .. } => ( + TilesetCatalogErrorCategory::IndexOutOfRange, + Some(*index), + None, + None, + None, + ), + TilesetCatalogError::ReservedIndex => ( + TilesetCatalogErrorCategory::ReservedIndex, + Some(0), + None, + None, + None, + ), + TilesetCatalogError::IdIndexMismatch { + index, decoded_id, .. + } => ( + TilesetCatalogErrorCategory::IdIndexMismatch, + Some(*index), + Some(TilesetField::Id.to_string()), + None, + Some(decoded_id.get()), + ), + _ => ( + TilesetCatalogErrorCategory::Unrecognized, + None, + None, + None, + None, + ), + }; + + Self { + category, + message: error.to_string(), + index, + field, + actual_kind, + decoded_id, + } + } +} + impl MapCatalogErrorDetail { fn new(error: &MapCatalogError) -> Self { let (category, index, field, actual_kind, decoded_id) = match error { @@ -2012,6 +2252,11 @@ fn main() -> ExitCode { format, limits, } => run_maps(&path, format, limits.limits()), + Command::Tilesets { + path, + format, + limits, + } => run_tilesets(&path, format, limits.limits()), Command::Map { path, id, @@ -2128,6 +2373,53 @@ fn run_maps(path: &Path, format: OutputFormat, limits: SnapshotLimits) -> ExitCo } } +fn run_tilesets(path: &Path, format: OutputFormat, limits: SnapshotLimits) -> ExitCode { + let root_dir = match cap_std::fs::Dir::open_ambient_dir(path, cap_std::ambient_authority()) { + Ok(dir) => dir, + Err(error) => { + let report = ErrorReport { + schema_version: OUTPUT_SCHEMA_VERSION, + root: PathReport::new(path), + error: ErrorDetail { + message: format!("failed to open project root '{}'", path.display()), + cause: Some(error.to_string()), + }, + }; + return write_error_report(&report, format); + } + }; + + let snapshot = match load_snapshot(&root_dir, limits) { + Ok(snapshot) => snapshot, + Err(error) => { + let report = ErrorReport { + schema_version: OUTPUT_SCHEMA_VERSION, + root: PathReport::new(path), + error: ErrorDetail { + message: error.to_string(), + cause: error.source().map(ToString::to_string), + }, + }; + return write_error_report(&report, format); + } + }; + + match tileset_catalog(&snapshot) { + Ok(catalog) => { + let report = TilesetsReport::new(path, &snapshot, &catalog, limits); + let write_result = match format { + OutputFormat::Human => write_human_tilesets_report(io::stdout().lock(), &report), + OutputFormat::Json => write_json(io::stdout().lock(), &report), + }; + finish_write(write_result) + } + Err(error) => { + let report = TilesetsErrorReport::new(path, &snapshot, limits, &error); + write_tilesets_error_report(&report, format) + } + } +} + fn run_map(path: &Path, map_id: MapId, format: OutputFormat, limits: SnapshotLimits) -> ExitCode { let root_dir = match cap_std::fs::Dir::open_ambient_dir(path, cap_std::ambient_authority()) { Ok(dir) => dir, @@ -2552,6 +2844,18 @@ fn write_maps_error_report(report: &MapsErrorReport, format: OutputFormat) -> Ex } } +fn write_tilesets_error_report(report: &TilesetsErrorReport, format: OutputFormat) -> ExitCode { + let write_result = match format { + OutputFormat::Human => write_human_tilesets_error(io::stderr().lock(), report), + OutputFormat::Json => write_json(io::stdout().lock(), report), + }; + + match write_result { + Ok(()) => ExitCode::from(1), + Err(write_error) => report_write_error(write_error), + } +} + fn write_selected_map_error_report( report: &SelectedMapErrorReport, format: OutputFormat, @@ -2815,6 +3119,52 @@ fn write_human_maps_report(mut writer: impl Write, report: &MapsReport) -> io::R ) } +fn write_human_tilesets_report(mut writer: impl Write, report: &TilesetsReport) -> io::Result<()> { + writeln!( + writer, + "Tilesets for {}:", + escape_controls(&report.root.display) + )?; + writeln!(writer, " Tilesets: {}", report.tileset_count)?; + writeln!( + writer, + " Snapshot completeness: {}", + report.snapshot_completeness.name() + )?; + writeln!( + writer, + " Snapshot diagnostics: {}", + report.snapshot_diagnostic_count + )?; + writeln!( + writer, + " Limits: {} documents, {} bytes/document, {} aggregate bytes", + report.limits.max_documents, + report.limits.max_bytes_per_document, + report.limits.max_aggregate_bytes + )?; + + if report.tilesets.is_empty() { + writeln!(writer, " (no tilesets)")?; + } else { + writeln!(writer, "Tileset catalog:")?; + for tileset in &report.tilesets { + writeln!( + writer, + " - {}: {}", + tileset.id, + escape_controls(&tileset.name) + )?; + } + } + + write_human_snapshot_diagnostics( + &mut writer, + "Snapshot diagnostics:", + &report.snapshot_diagnostics, + ) +} + fn write_human_selected_map_report( mut writer: impl Write, report: &SelectedMapReport, @@ -3198,6 +3548,23 @@ fn write_human_maps_error(mut writer: impl Write, report: &MapsErrorReport) -> i ) } +fn write_human_tilesets_error( + mut writer: impl Write, + report: &TilesetsErrorReport, +) -> io::Result<()> { + writeln!( + writer, + "error: {} for {}", + escape_controls(&report.error.message), + escape_controls(&report.root.display) + )?; + write_human_snapshot_diagnostics( + &mut writer, + "Snapshot diagnostics:", + &report.snapshot_diagnostics, + ) +} + fn write_human_selected_map_error( mut writer: impl Write, report: &SelectedMapErrorReport, diff --git a/crates/tilewright-cli/tests/cli.rs b/crates/tilewright-cli/tests/cli.rs index 428bc19..7ddff17 100644 --- a/crates/tilewright-cli/tests/cli.rs +++ b/crates/tilewright-cli/tests/cli.rs @@ -45,6 +45,13 @@ fn write_system_project(temp: &TempDir, system_document: &[u8]) -> PathBuf { root } +fn write_tileset_project(temp: &TempDir, tileset_document: &[u8]) -> PathBuf { + let root = temp.path().join("root"); + fs::create_dir_all(root.join("data")).unwrap(); + fs::write(root.join("data/Tilesets.json"), tileset_document).unwrap(); + root +} + fn write_validation_project( temp: &TempDir, system_document: &[u8], @@ -1029,6 +1036,125 @@ fn map_help_lists_id_format_and_snapshot_limits() { assert!(output.contains("--max-aggregate-bytes")); } +#[test] +fn tilesets_help_lists_format_and_snapshot_limits() { + let help = tilewright(&["tilesets", "--help"]); + assert!(help.status.success()); + let output = stdout(&help); + assert!(output.contains("")); + assert!(output.contains("--format")); + assert!(output.contains("--max-documents")); + assert!(output.contains("--max-bytes-per-document")); + assert!(output.contains("--max-aggregate-bytes")); +} + +#[test] +fn tilesets_reports_names_and_escapes_controls_for_people() { + let temp = TempDir::new().unwrap(); + let root = write_tileset_project( + &temp, + br#"[null,{"id":1,"name":"Field\n\u001b[31m","mode":1,"note":"do not print","tilesetNames":["secret"],"flags":[1]},null,{"id":3,"name":"Area"}]"#, + ); + + let output = tilewright(&["tilesets", root.to_str().unwrap()]); + + assert!(output.status.success()); + assert!(stderr(&output).is_empty()); + let output = stdout(&output); + assert!(output.contains("Tilesets: 2")); + assert!(output.contains("1: Field\\n\\u{1b}[31m")); + assert!(output.contains("3: Area")); + assert!(!output.contains('\u{1b}')); + assert!(!output.contains("do not print")); + assert!(!output.contains("secret")); +} + +#[test] +fn tilesets_emits_deterministic_versioned_json_without_opaque_contents() { + let temp = TempDir::new().unwrap(); + let root = write_tileset_project( + &temp, + br#"[null,{"id":1,"name":"Field","mode":1,"note":"memo secret","tilesetNames":["asset secret"],"flags":[1]},null,{"id":3,"name":"Area"}]"#, + ); + + let first = tilewright(&["tilesets", root.to_str().unwrap(), "--format", "json"]); + let second = tilewright(&["tilesets", root.to_str().unwrap(), "--format", "json"]); + + assert!(first.status.success()); + assert!(stderr(&first).is_empty()); + assert_eq!(first.stdout, second.stdout); + let report: Value = serde_json::from_slice(&first.stdout).unwrap(); + assert_eq!(report["schema_version"], 1); + assert_eq!(report["snapshot_completeness"], "complete"); + assert_eq!(report["tileset_count"], 2); + assert_eq!(report["tilesets"][0]["id"], 1); + assert_eq!(report["tilesets"][0]["name"], "Field"); + assert_eq!(report["tilesets"][1]["id"], 3); + assert_eq!(report["tilesets"][1]["name"], "Area"); + let output = stdout(&first); + assert!(!output.contains("memo secret")); + assert!(!output.contains("asset secret")); + assert!(!output.contains("flags")); +} + +#[test] +fn tilesets_projection_errors_have_structured_human_and_json_forms() { + let temp = TempDir::new().unwrap(); + let root = write_tileset_project(&temp, br#"[null,{"id":2,"name":"Wrong"}]"#); + + let human = tilewright(&["tilesets", root.to_str().unwrap()]); + assert_eq!(human.status.code(), Some(1)); + assert!(stdout(&human).is_empty()); + assert!(stderr(&human).contains("entry 1 has decoded ID 2")); + + let json = tilewright(&["tilesets", root.to_str().unwrap(), "--format", "json"]); + assert_eq!(json.status.code(), Some(1)); + assert!(stderr(&json).is_empty()); + let report: Value = serde_json::from_slice(&json.stdout).unwrap(); + assert_eq!(report["error"]["category"], "id_index_mismatch"); + assert_eq!(report["error"]["index"], 1); + assert_eq!(report["error"]["field"], "id"); + assert_eq!(report["error"]["decoded_id"], 2); +} + +#[test] +fn tilesets_forwards_limits_and_reports_unavailable_document() { + let temp = TempDir::new().unwrap(); + let root = write_tileset_project( + &temp, + br#"[null,{"id":1,"name":"a deliberately long tileset name that exceeds the configured document limit"}]"#, + ); + + let output = tilewright(&[ + "tilesets", + root.to_str().unwrap(), + "--format", + "json", + "--max-bytes-per-document", + "80", + ]); + + assert_eq!(output.status.code(), Some(1)); + assert!(stderr(&output).is_empty()); + let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["limits"]["max_bytes_per_document"], 80); + assert_eq!(report["error"]["category"], "unavailable_document"); + assert!( + report["snapshot_diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| { + diagnostic["path"]["utf8"] + == PathBuf::from("data") + .join("Tilesets.json") + .to_str() + .unwrap() + && diagnostic["category"] == "exceeds_document_byte_limit" + }) + ); +} + #[test] fn map_reports_selected_summary_and_escapes_controls_for_people() { let temp = TempDir::new().unwrap(); diff --git a/crates/tilewright/README.md b/crates/tilewright/README.md index e880ba8..083d871 100644 --- a/crates/tilewright/README.md +++ b/crates/tilewright/README.md @@ -19,6 +19,8 @@ An additional experimental projection reports selected `System.json` strings and stored map-position scalars without validating their relationships. The first experimental contextual validator composes those scalars with the map catalog and selected-map dimensions to inspect only the player start. +An experimental tileset catalog separately exposes positive IDs and decoded +editor-facing names while leaving tile behavior and assets opaque. ### Example: Candidate Discovery @@ -159,6 +161,32 @@ findings identify relationships that Tilewright cannot reconcile; they do not claim that RPG Maker MZ rejects the project. No map mutation or serialization API exists. +### Example: Typed Tileset Catalog + +The experimental tileset catalog projects only IDs and decoded editor-facing +names from exact `data/Tilesets.json`. Unknown fields and exact bytes remain in +the raw snapshot. + +```rust +use tilewright::rpg_maker_mz::snapshot::ProjectSnapshot; +use tilewright::rpg_maker_mz::tileset_catalog::tileset_catalog; + +fn print_tilesets(snapshot: &ProjectSnapshot) { + match tileset_catalog(snapshot) { + Ok(catalog) => { + for record in catalog.records().values() { + println!("{}: {}", record.id(), record.name()); + } + } + Err(error) => eprintln!("tileset catalog unavailable: {error}"), + } +} +``` + +The projection refuses ambiguous required structure. It does not interpret +tileset modes, images, flags, notes, or tile behavior; validate map references +or assets; establish editor compatibility; or expose mutation and serialization. + ### Example: Selected-Map Summary The experimental selected-map summary reuses a catalog-scoped `MapId` and reads diff --git a/crates/tilewright/src/rpg_maker_mz/mod.rs b/crates/tilewright/src/rpg_maker_mz/mod.rs index 5c792d2..3970989 100644 --- a/crates/tilewright/src/rpg_maker_mz/mod.rs +++ b/crates/tilewright/src/rpg_maker_mz/mod.rs @@ -12,3 +12,4 @@ pub mod map_summary; pub mod player_start_validation; pub mod snapshot; pub mod system_summary; +pub mod tileset_catalog; diff --git a/crates/tilewright/src/rpg_maker_mz/tileset_catalog.rs b/crates/tilewright/src/rpg_maker_mz/tileset_catalog.rs new file mode 100644 index 0000000..16b3387 --- /dev/null +++ b/crates/tilewright/src/rpg_maker_mz/tileset_catalog.rs @@ -0,0 +1,517 @@ +// SPDX-License-Identifier: MPL-2.0 + +//! Experimental, read-only typed projection of RPG Maker MZ tileset identity. + +use crate::rpg_maker_mz::map_catalog::JsonValueKind; +use crate::rpg_maker_mz::snapshot::ProjectSnapshot; +use jsonc_parser::cst::{CstNode, CstObject}; +use std::collections::BTreeMap; +use std::fmt; +use std::num::NonZeroU32; + +const TILESETS_PATH: &str = "data/Tilesets.json"; + +/// A positive identifier scoped to the experimental tileset catalog. +/// +/// This is not yet a stable project-wide resource identity contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct TilesetId(NonZeroU32); + +impl TilesetId { + /// Creates a tileset identifier, returning `None` for the reserved zero value. + pub fn new(value: u32) -> Option { + NonZeroU32::new(value).map(Self) + } + + /// Returns the positive numeric identifier. + pub fn get(self) -> u32 { + self.0.get() + } +} + +impl fmt::Display for TilesetId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, formatter) + } +} + +/// One structurally understood tileset record. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct TilesetRecord { + id: TilesetId, + name: String, +} + +impl TilesetRecord { + /// Returns the record's positive tileset identifier. + pub fn id(&self) -> TilesetId { + self.id + } + + /// Returns the decoded editor-facing tileset name. + pub fn name(&self) -> &str { + &self.name + } +} + +/// A structurally coherent, read-only tileset catalog projection. +#[derive(Debug)] +#[non_exhaustive] +pub struct TilesetCatalog { + records: BTreeMap, +} + +impl TilesetCatalog { + /// Returns records in ascending tileset-ID order. + pub fn records(&self) -> &BTreeMap { + &self.records + } + + /// Returns one record by tileset ID. + pub fn get(&self, id: TilesetId) -> Option<&TilesetRecord> { + self.records.get(&id) + } +} + +/// A required field in the experimental tileset catalog. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum TilesetField { + /// The positive tileset identifier. + Id, + /// The decoded editor-facing name. + Name, +} + +impl TilesetField { + fn name(self) -> &'static str { + match self { + Self::Id => "id", + Self::Name => "name", + } + } +} + +impl fmt::Display for TilesetField { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.name()) + } +} + +/// Why a raw snapshot could not produce a coherent tileset catalog. +#[derive(Debug)] +#[non_exhaustive] +pub enum TilesetCatalogError { + /// The snapshot inventory does not contain `data/Tilesets.json`. + MissingDocument, + /// Snapshot diagnostics exist instead of a loaded tileset document. + UnavailableDocument, + /// The tileset document root is not an array. + #[non_exhaustive] + UnexpectedRootKind { actual: JsonValueKind }, + /// A non-null array entry is not an object. + #[non_exhaustive] + UnexpectedEntryKind { index: usize, actual: JsonValueKind }, + /// A required field is absent. + #[non_exhaustive] + MissingField { index: usize, field: TilesetField }, + /// A required decoded field name occurs more than once. + #[non_exhaustive] + DuplicateField { index: usize, field: TilesetField }, + /// A required field has the wrong JSON value kind. + #[non_exhaustive] + UnexpectedFieldKind { + index: usize, + field: TilesetField, + actual: JsonValueKind, + }, + /// A required number is not a supported positive unsigned integer. + #[non_exhaustive] + UnsupportedInteger { index: usize, field: TilesetField }, + /// A string field could not be decoded. + #[non_exhaustive] + InvalidString { index: usize, field: TilesetField }, + /// An array index cannot be represented by the identifier type. + #[non_exhaustive] + IndexOutOfRange { index: usize }, + /// A tileset object occupies reserved array index zero. + ReservedIndex, + /// A decoded positive ID does not equal its array index. + #[non_exhaustive] + IdIndexMismatch { index: usize, decoded_id: TilesetId }, +} + +impl fmt::Display for TilesetCatalogError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingDocument => formatter.write_str("data/Tilesets.json is missing"), + Self::UnavailableDocument => formatter + .write_str("data/Tilesets.json was not loaded; inspect snapshot diagnostics"), + Self::UnexpectedRootKind { actual, .. } => write!( + formatter, + "data/Tilesets.json root is {actual}, expected array" + ), + Self::UnexpectedEntryKind { index, actual, .. } => write!( + formatter, + "data/Tilesets.json entry {index} is {actual}, expected object or null" + ), + Self::MissingField { index, field, .. } => write!( + formatter, + "data/Tilesets.json entry {index} is missing required field {field}" + ), + Self::DuplicateField { index, field, .. } => write!( + formatter, + "data/Tilesets.json entry {index} has duplicate required field {field}" + ), + Self::UnexpectedFieldKind { + index, + field, + actual, + .. + } => write!( + formatter, + "data/Tilesets.json entry {index} field {field} is {actual}, expected {}", + expected_kind(*field) + ), + Self::UnsupportedInteger { index, field, .. } => write!( + formatter, + "data/Tilesets.json entry {index} field {field} is not a supported positive unsigned integer" + ), + Self::InvalidString { index, field, .. } => write!( + formatter, + "data/Tilesets.json entry {index} field {field} could not be decoded" + ), + Self::IndexOutOfRange { index, .. } => write!( + formatter, + "data/Tilesets.json index {index} exceeds the supported identifier range" + ), + Self::ReservedIndex => { + formatter.write_str("data/Tilesets.json has a tileset object at reserved index 0") + } + Self::IdIndexMismatch { + index, decoded_id, .. + } => write!( + formatter, + "data/Tilesets.json entry {index} has decoded ID {decoded_id}" + ), + } + } +} + +impl std::error::Error for TilesetCatalogError {} + +/// Projects exact `data/Tilesets.json` into bounded tileset IDs and names. +/// +/// This operation reads only the lossless document already present in +/// `snapshot`. Null array entries are accepted as holes. Every non-null record +/// must have a positive ID equal to its array index and a decodable name. +/// +/// Modes, image-name slots, tile flags, notes, and unknown fields remain +/// untouched in the raw snapshot. This operation performs no filesystem I/O, +/// map-reference or asset validation, mutation, serialization, persistence, +/// runtime execution, or compatibility verification. +/// +/// # Errors +/// +/// Returns [`TilesetCatalogError`] when the document is absent or unavailable, +/// or when required root, entry, ID, or name structure is ambiguous. +pub fn tileset_catalog(snapshot: &ProjectSnapshot) -> Result { + let path = std::path::Path::new(TILESETS_PATH); + let Some(document) = snapshot.documents().get(path) else { + return if snapshot.diagnostics().contains_key(path) { + Err(TilesetCatalogError::UnavailableDocument) + } else { + Err(TilesetCatalogError::MissingDocument) + }; + }; + + let root = document + .cst_root() + .value() + .ok_or(TilesetCatalogError::UnexpectedRootKind { + actual: JsonValueKind::Unrecognized, + })?; + let array = root + .as_array() + .ok_or_else(|| TilesetCatalogError::UnexpectedRootKind { + actual: json_kind(&root), + })?; + + let mut records = BTreeMap::new(); + for (index, node) in array.elements().into_iter().enumerate() { + if node.as_null_keyword().is_some() { + continue; + } + let object = node + .as_object() + .ok_or_else(|| TilesetCatalogError::UnexpectedEntryKind { + index, + actual: json_kind(&node), + })?; + let index_id = tileset_id_from_index(index)?; + let decoded_id = required_positive_integer(&object, index, TilesetField::Id)?; + if decoded_id != index_id { + return Err(TilesetCatalogError::IdIndexMismatch { index, decoded_id }); + } + let name = required_string(&object, index, TilesetField::Name)?; + records.insert(index_id, TilesetRecord { id: index_id, name }); + } + + Ok(TilesetCatalog { records }) +} + +fn tileset_id_from_index(index: usize) -> Result { + let value = u32::try_from(index).map_err(|_| TilesetCatalogError::IndexOutOfRange { index })?; + TilesetId::new(value).ok_or(TilesetCatalogError::ReservedIndex) +} + +fn required_node( + object: &CstObject, + index: usize, + field: TilesetField, +) -> Result { + let mut matches = object.properties().into_iter().filter(|property| { + property + .name() + .and_then(|name| name.decoded_value().ok()) + .is_some_and(|name| name == field.name()) + }); + let property = matches + .next() + .ok_or(TilesetCatalogError::MissingField { index, field })?; + if matches.next().is_some() { + return Err(TilesetCatalogError::DuplicateField { index, field }); + } + property + .value() + .ok_or(TilesetCatalogError::MissingField { index, field }) +} + +fn required_positive_integer( + object: &CstObject, + index: usize, + field: TilesetField, +) -> Result { + let node = required_node(object, index, field)?; + let number = node + .as_number_lit() + .ok_or_else(|| TilesetCatalogError::UnexpectedFieldKind { + index, + field, + actual: json_kind(&node), + })?; + let value = number + .to_string() + .parse::() + .ok() + .and_then(TilesetId::new) + .ok_or(TilesetCatalogError::UnsupportedInteger { index, field })?; + Ok(value) +} + +fn required_string( + object: &CstObject, + index: usize, + field: TilesetField, +) -> Result { + let node = required_node(object, index, field)?; + let string = node + .as_string_lit() + .ok_or_else(|| TilesetCatalogError::UnexpectedFieldKind { + index, + field, + actual: json_kind(&node), + })?; + string + .decoded_value() + .map_err(|_| TilesetCatalogError::InvalidString { index, field }) +} + +fn expected_kind(field: TilesetField) -> JsonValueKind { + match field { + TilesetField::Id => JsonValueKind::Number, + TilesetField::Name => JsonValueKind::String, + } +} + +fn json_kind(node: &CstNode) -> JsonValueKind { + if node.as_object().is_some() { + JsonValueKind::Object + } else if node.as_array().is_some() { + JsonValueKind::Array + } else if node.as_string_lit().is_some() { + JsonValueKind::String + } else if node.as_number_lit().is_some() { + JsonValueKind::Number + } else if node.as_boolean_lit().is_some() { + JsonValueKind::Boolean + } else if node.as_null_keyword().is_some() { + JsonValueKind::Null + } else { + JsonValueKind::Unrecognized + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rpg_maker_mz::snapshot::{SnapshotLimits, load_snapshot}; + use cap_std::fs::Dir; + use std::fs; + use std::num::NonZeroUsize; + use std::path::Path; + use tempfile::TempDir; + + type ErrorPredicate = fn(&TilesetCatalogError) -> bool; + + fn snapshot(source: Option<&[u8]>, max_bytes: usize) -> (TempDir, ProjectSnapshot) { + let temp = TempDir::new().unwrap(); + fs::create_dir(temp.path().join("data")).unwrap(); + if let Some(source) = source { + fs::write(temp.path().join(TILESETS_PATH), source).unwrap(); + } + let root = Dir::open_ambient_dir(temp.path(), cap_std::ambient_authority()).unwrap(); + let loaded = load_snapshot( + &root, + SnapshotLimits { + max_documents: NonZeroUsize::new(8).unwrap(), + max_bytes_per_document: NonZeroUsize::new(max_bytes).unwrap(), + max_aggregate_bytes: NonZeroUsize::new(131_072).unwrap(), + }, + ) + .unwrap(); + (temp, loaded) + } + + #[test] + fn projects_holes_and_decoded_names_without_changing_raw_bytes() { + let source = br#"[null,{"id":1,"name":"Field \u2603","mode":1,"flags":[1],"tilesetNames":["secret"],"note":"memo","plugin":{"value":1}},null,{"id":3,"name":"Area"}]"#; + let (_temp, snapshot) = snapshot(Some(source), 65_536); + + let catalog = tileset_catalog(&snapshot).unwrap(); + + let ids: Vec<_> = catalog.records().keys().map(|id| id.get()).collect(); + assert_eq!(ids, [1, 3]); + assert_eq!( + catalog.get(TilesetId::new(1).unwrap()).unwrap().name(), + "Field ☃" + ); + assert_eq!( + snapshot.documents()[Path::new(TILESETS_PATH)].source_bytes(), + source + ); + } + + #[test] + fn distinguishes_missing_and_unavailable_documents() { + let (_temp, missing) = snapshot(None, 65_536); + assert!(matches!( + tileset_catalog(&missing), + Err(TilesetCatalogError::MissingDocument) + )); + + let source = format!(r#"[null,{{"id":1,"name":"{}"}}]"#, "x".repeat(256)); + let (_temp, unavailable) = snapshot(Some(source.as_bytes()), 64); + assert!(matches!( + tileset_catalog(&unavailable), + Err(TilesetCatalogError::UnavailableDocument) + )); + } + + #[test] + fn refuses_ambiguous_or_malformed_required_structure() { + let cases: &[(&[u8], ErrorPredicate)] = &[ + (br#"{}"#, |error| { + matches!(error, TilesetCatalogError::UnexpectedRootKind { .. }) + }), + (br#"[null,true]"#, |error| { + matches!( + error, + TilesetCatalogError::UnexpectedEntryKind { + index: 1, + actual: JsonValueKind::Boolean, + .. + } + ) + }), + (br#"[null,{"name":"A"}]"#, |error| { + matches!( + error, + TilesetCatalogError::MissingField { + index: 1, + field: TilesetField::Id, + .. + } + ) + }), + (br#"[null,{"id":1,"name":"A","n\u0061me":"B"}]"#, |error| { + matches!( + error, + TilesetCatalogError::DuplicateField { + index: 1, + field: TilesetField::Name, + .. + } + ) + }), + (br#"[null,{"id":1,"name":false}]"#, |error| { + matches!( + error, + TilesetCatalogError::UnexpectedFieldKind { + index: 1, + field: TilesetField::Name, + actual: JsonValueKind::Boolean, + .. + } + ) + }), + ]; + + for (source, predicate) in cases { + let (_temp, snapshot) = snapshot(Some(source), 65_536); + let error = tileset_catalog(&snapshot).unwrap_err(); + assert!(predicate(&error), "unexpected error: {error}"); + } + } + + #[test] + fn refuses_unsupported_integer_forms_and_id_mismatch() { + for source in [ + br#"[null,{"id":0,"name":"Zero"}]"#.as_slice(), + br#"[null,{"id":1.0,"name":"Decimal"}]"#.as_slice(), + br#"[null,{"id":4294967296,"name":"Large"}]"#.as_slice(), + ] { + let (_temp, snapshot) = snapshot(Some(source), 65_536); + assert!(matches!( + tileset_catalog(&snapshot), + Err(TilesetCatalogError::UnsupportedInteger { .. }) + )); + } + + let (_temp, reserved) = snapshot(Some(br#"[{"id":1,"name":"Reserved"}]"#), 65_536); + assert!(matches!( + tileset_catalog(&reserved), + Err(TilesetCatalogError::ReservedIndex) + )); + + let (_temp, mismatch) = snapshot(Some(br#"[null,{"id":2,"name":"Wrong"}]"#), 65_536); + assert!(matches!( + tileset_catalog(&mismatch), + Err(TilesetCatalogError::IdIndexMismatch { + index: 1, + decoded_id, + .. + }) if decoded_id == TilesetId::new(2).unwrap() + )); + } + + #[cfg(target_pointer_width = "64")] + #[test] + fn checks_index_range_without_allocating_a_huge_array() { + assert!(matches!( + tileset_id_from_index(usize::MAX), + Err(TilesetCatalogError::IndexOutOfRange { .. }) + )); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 0b4254f..cf99f0d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,12 +110,12 @@ The raw-document and typed-view relationship uses the CST/raw-storage-plus-typed-view architectural direction accepted in [ADR 0004](decisions/0004-lossless-json-representation.md). The first immutable `LosslessJsonDocument` implements strict byte-to-syntax representation while -keeping its provisional CST backend private. The experimental map catalog, -selected-map summary, selected-map event catalog, and system summary are owned, -read-only typed projections over a raw project snapshot; they do not expose CST -nodes or mutate the snapshot. The experimental player-start validator composes -those owned projections without adding filesystem I/O or a second representation -layer. +keeping its provisional CST backend private. The experimental map, tileset, +and selected-map event catalogs plus the selected-map and system summaries are +owned, read-only typed projections over a raw project snapshot; they do not +expose CST nodes or mutate the snapshot. The experimental player-start +validator composes those owned projections without adding filesystem I/O or a +second representation layer. Production typed-view ownership and concurrency policy, mutation, and operation-specific preservation and refusal contracts remain unresolved or unimplemented. Silent data loss is not an acceptable answer. diff --git a/docs/capability-roadmap.md b/docs/capability-roadmap.md index ec462b5..073a536 100644 --- a/docs/capability-roadmap.md +++ b/docs/capability-roadmap.md @@ -186,6 +186,14 @@ integer fields, retains raw bytes and unknown settings in the snapshot, and does not validate map references or coordinate bounds. Neither acceptance nor implementation makes the capability Supported. +A bounded tileset identity/name catalog is implemented experimentally under +accepted [ADR 0012](decisions/0012-experimental-tileset-catalog.md). It leaves +modes, image slots, tile flags, notes, and unknown fields in the raw snapshot. +Its evidence covers 24 records and 196 resolving map references across four MZ +1.10.0 projects, and an independent differential audit matched all 72 bounded +comparisons. Neither acceptance nor implementation makes the capability +Supported. + An independent differential audit matched all seven projected fields and the bounded CLI envelope across the four-project MZ 1.10.0 corpus. This closes the initial implementation-verification step without generalizing to later @@ -301,9 +309,11 @@ Explicit-root candidate discovery, capability-relative project inventory, an immutable single-document lossless syntax representation, and a read-only raw project snapshot loader are implemented experimentally. The first typed map-catalog projection and its `maps` CLI adapter are also implemented -experimentally. The selected-map summary and its `map` CLI adapter are also -implemented experimentally without interpreting tile contents. The selected-map -event catalog and its `events` CLI adapter expose only bounded event identity, +experimentally. A tileset identity/name projection and its `tilesets` CLI +adapter begin the database-table stage without interpreting tile behavior or +assets. The selected-map summary and its `map` CLI adapter are implemented +experimentally without interpreting tile contents. The selected-map event +catalog and its `events` CLI adapter expose only bounded event identity, placement, and opaque page counts. The system summary and its `system` CLI adapter are implemented experimentally without interpreting other system settings. A first player-start validation slice and its `validate` CLI adapter @@ -314,7 +324,8 @@ selected-map summaries in the local four-project MZ 1.10.0 evidence corpus. It also matched all 28 field comparisons and all four output envelopes for the system summary. Controlled MZ 1.10.0 experiments also establish same-map start relocation and editor recognition/preservation of the exact zero triplet as -`None`. The next evidence steps are differential verification of the event -catalog and controlled editor experiments for event creation, movement, -renaming, page lifecycle, and deletion. Page bodies and commands remain a later -separate slice. +`None`. Independent differential verification also matched the bounded tileset +and event projections against the four-project corpus. The next evidence steps +are controlled tileset name, mode, and map-assignment experiments plus event +creation, movement, renaming, page lifecycle, and deletion. Tile behavior, +event page bodies, and commands remain later separate slices. diff --git a/docs/compatibility.md b/docs/compatibility.md index a5cac61..de2549f 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -30,8 +30,8 @@ not use “supported” to mean only that one file happened to parse. | Capability | Status | Notes | | --- | --- | --- | -| Core `tilewright` package | Experimental | Exposes its package version, candidate discovery, capability-relative read-only project inventory, immutable strict lossless JSON syntax representation, read-only raw project snapshot loader, typed map catalog projection, selected-map summary and event catalog, system summary, and bounded player-start validation. | -| `tilewright` CLI executable | Experimental | Provides help/version output and human- or versioned JSON-formatted access to the core library's experimental explicit-root candidate discovery, capability-relative project inventory, bounded raw snapshot loader, typed map catalog, selected-map summary and event catalog, system summary, player-start validation, and strict lossless JSON syntax inspection. Initial `open_ambient_dir` acquisition for inventory, snapshot loading, map and event inspection, system inspection, and validation may resolve root or ancestor symlinks and does not prove root identity. The `inspect-json` command explicitly opens the provided path and makes no project-containment claim. Raw snapshot output omits document contents; typed output includes only the requested projections, contextual information, and separate snapshot diagnostics. The CLI does not establish general semantic understanding, general project validity, MZ-version compatibility, round-trip support, or writes. | +| Core `tilewright` package | Experimental | Exposes its package version, candidate discovery, capability-relative read-only project inventory, immutable strict lossless JSON syntax representation, read-only raw project snapshot loader, typed map and tileset catalog projections, selected-map summary and event catalog, system summary, and bounded player-start validation. | +| `tilewright` CLI executable | Experimental | Provides help/version output and human- or versioned JSON-formatted access to the core library's experimental explicit-root candidate discovery, capability-relative project inventory, bounded raw snapshot loader, typed map and tileset catalogs, selected-map summary and event catalog, system summary, player-start validation, and strict lossless JSON syntax inspection. Initial `open_ambient_dir` acquisition for inventory, snapshot loading, map, tileset, event, and system inspection, and validation may resolve root or ancestor symlinks and does not prove root identity. The `inspect-json` command explicitly opens the provided path and makes no project-containment claim. Raw snapshot output omits document contents; typed output includes only the requested projections, contextual information, and separate snapshot diagnostics. The CLI does not establish general semantic understanding, general project validity, MZ-version compatibility, round-trip support, or writes. | | `tilewright-mcp` server | Scaffold | Not yet an MCP server; it prints its package version. | | RPG Maker MZ explicit-root candidate recognition | Experimental | Path-level recognition is based on the documented marker role and recorded MZ 1.10.0 observations from the tested macOS environment. CI on Ubuntu, macOS, and Windows is implementation regression coverage, not editor-compatibility evidence. Newer MZ versions and unobserved editor/filesystem combinations remain unknown. Discovery does not validate project contents, infer a version, parse JSON, or guarantee compatibility. It uses `std::fs` and does not provide race-free sandbox containment or complete root symlink rejection. | | RPG Maker MZ capability-relative project inventory | Experimental | Recursively reports deterministically ordered, exact project-relative native paths, entry kinds, and conservative known/extension-candidate/unknown pathname classifications beneath a caller-authorized `cap_std::fs::Dir`. Symlinks are reported without traversal, and file contents are not read. Known paths are limited to evidenced immediate-root names and immediate standard `data` filename families; other immediate `data/*.json` names are extension candidates, not proven plugin content. The API does not acquire or verify the initial root capability, validate entry kinds or contents, infer compatibility, provide an atomic snapshot during concurrent mutation, or return partial results after an I/O error. | @@ -39,6 +39,7 @@ not use “supported” to mean only that one file happened to parse. | RPG Maker MZ versions before 1.10.0 | Unsupported | Outside the maintained target. Evidence-backed contributor proposals to expand the matrix are welcome. | | Project-file parsing | Experimental | The core can load an experimental, read-only raw project snapshot that composes authorized inventory with per-document identity and syntax diagnostics. It enforces caller-supplied resource limits, preserves exact accepted bytes, and returns a partial snapshot if some documents fail to load. It does not turn path classification or syntax acceptance into an implicit domain or compatibility claim. | | RPG Maker MZ typed map catalog | Experimental | Projects a structurally coherent loaded `data/MapInfos.json` into map IDs, decoded names, positive display order, and optional parent IDs while retaining the untouched raw document. It refuses ambiguous or malformed required structure and reports deterministic contextual parent, order, and map-document findings without claiming editor rejection. Direct evidence covers four MZ 1.10.0 projects and controlled map lifecycle experiments; an independent differential audit matched all 196 projected records and map-document identities in that corpus with no findings or snapshot diagnostics. Later versions, malformed-input editor behavior, IDs above 999, map contents, mutation, and persistence remain unknown or unimplemented, so the capability remains Experimental. | +| RPG Maker MZ typed tileset catalog | Experimental | Projects exact `data/Tilesets.json` into positive catalog-scoped IDs and decoded editor-facing names in ID order while retaining exact bytes and every unprojected field. It accepts null holes and refuses ambiguous required structure or ID/index mismatches. Aggregate evidence covers 24 records and 196 resolving map references across four MZ 1.10.0 projects, and an independent differential audit matched all 72 bounded comparisons. Modes, image slots, flags, notes, map-reference validation, assets, tile behavior, lifecycle operations, malformed-input editor behavior, mutation, persistence, and later versions remain unknown or unimplemented, so the capability remains Experimental. | | RPG Maker MZ selected-map summary | Experimental | Requires a coherent typed map catalog and matching catalog-scoped ID, then projects one evidenced three-digit map document into its exact path, catalog and display names, positive dimensions, positive tileset ID scalar, and count of opaque event objects. Unknown fields and exact bytes remain in the raw snapshot. The operation refuses ambiguous required structure and does not interpret tile data or event bodies, validate tileset references or editor compatibility, support IDs above 999, mutate, serialize, or persist data. Direct shape evidence covers 196 map documents from four MZ 1.10.0 projects, and an independent differential audit matched every bounded summary field across the corpus. Later versions and malformed-input editor behavior remain unknown, so the capability remains Experimental. | | RPG Maker MZ selected-map event catalog | Experimental | Requires a coherent map catalog, matching catalog-scoped ID, and evidenced three-digit map document, then projects map-scoped positive event IDs, decoded names, nonnegative coordinates, and opaque page counts in ID order. Coordinates outside the map dimensions are contextual findings, not editor-rejection claims. Exact bytes, notes, page bodies, commands, and unknown fields remain in the raw snapshot. Aggregate evidence covers 1,555 events and 1,576 pages across 196 MZ 1.10.0 map documents, and an independent differential audit matched all 10,323 bounded comparisons. Page semantics, commands, mutation, persistence, malformed-input editor behavior, and later versions remain unknown or unimplemented, so the capability remains Experimental. | | RPG Maker MZ system summary | Experimental | Projects exact `data/System.json` from an existing snapshot into its exact path, decoded game title, currency unit, and locale, plus nonnegative editor-map and player-start map/X/Y scalars. Unknown settings and exact bytes remain in the raw snapshot. The operation refuses ambiguous required structure and does not interpret other system settings, validate map references or coordinate bounds, compare titles across files, mutate, serialize, or persist data. Aggregate shape evidence covers four MZ 1.10.0 projects, and an independent differential audit matched all 28 field comparisons and all four output envelopes across that corpus. Later versions and malformed-input editor behavior remain unknown, so the capability remains Experimental. | diff --git a/docs/decisions/0012-experimental-tileset-catalog.md b/docs/decisions/0012-experimental-tileset-catalog.md new file mode 100644 index 0000000..d0dcf2c --- /dev/null +++ b/docs/decisions/0012-experimental-tileset-catalog.md @@ -0,0 +1,86 @@ +# ADR 0012: Experimental Tileset Catalog + +- **Status:** Accepted +- **Date:** 2026-08-09 + +## Context + +Tilewright's selected-map summary exposes a positive `tilesetId` scalar but +cannot resolve it to even an editor-facing name. The next database-table slice +should make tileset identity inspectable without prematurely interpreting tile +flags, image slots, modes, or notes. + +The [tileset-catalog research](../formats/rpg-maker-mz/tileset-catalog.md) +combines official editor documentation with an aggregate audit of 24 tileset +records and 196 map references from four MZ 1.10.0 projects. Public identifier +scope, malformed-structure behavior, and unknown-field preservation require an +explicit decision. + +## Decision + +For the experimental tileset-catalog slice: + +1. The core library will expose a pure, read-only operation over an existing + `ProjectSnapshot`. Adapters will not reimplement tileset interpretation. +2. A positive `TilesetId` will be scoped to this experimental catalog. It is not + yet a stable project-wide resource identifier. +3. The operation will select exact `data/Tilesets.json` and return an owned + `TilesetCatalog` whose records are ordered by ID. +4. Each `TilesetRecord` will contain only its positive ID and decoded + editor-facing name. +5. The root must be an array. Null entries are accepted as holes. Each non-null + entry must be an object with unambiguous `id` and `name` fields; the ID must + use the bounded unsigned-decimal form, be positive, and equal its array + index. +6. Missing, duplicate, wrong-kind, malformed-string, unsupported-integer, + reserved-index, index-range, and ID/index mismatch conditions are typed + structural errors. A structural error prevents the catalog rather than + returning a partial typed result. +7. `mode`, `tilesetNames`, `flags`, `note`, and unknown fields remain only in the + untouched raw snapshot. The operation does not interpret or validate them. +8. Map-reference validation, assets, tile meaning, mutation, serialization, + persistence, runtime behavior, and editor compatibility remain outside this + decision. + +## Rationale + +Identity and name make the existing selected-map tileset scalar useful while +staying within strong evidence. An owned projection keeps the provisional CST +private and is straightforward for multiple adapters. Requiring ID/index +equality follows every observed database record and avoids inventing an +alternate identity when structure is inconsistent. + +Deferring adjacent fields is material: observed integer modes do not by +themselves establish their enum mapping, and image-slot and flag arrays carry +substantially broader asset and tile-behavior semantics. + +## Consequences + +- Callers can list and resolve tileset IDs to decoded editor names. +- Unknown fields and exact bytes remain retained in `ProjectSnapshot`. +- A malformed required record prevents the typed catalog but not raw access. +- Cross-file reference validation remains a later compositional operation. +- Evidence currently covers MZ 1.10.0 only, so the capability remains + Experimental even if implemented. + +## Alternatives Considered + +- **Expose mode, image names, and flags now:** Rejected because their exact + encoding and semantic contracts need separate evidence. +- **Return CST nodes or generic JSON values:** Rejected because it leaks the + provisional representation and makes adapters own domain behavior. +- **Reuse `MapId`:** Rejected because map and tileset identities are different + resource domains. +- **Validate every map reference during catalog projection:** Deferred to a + separate contextual validation operation so catalog structure remains useful + independently. +- **Treat holes as fatal:** Rejected because indexed MZ database arrays commonly + reserve index zero and may use null entries. + +## Validation + +Before implementation is ready for review, it must include generated synthetic +tests for success, holes, ordering, decoded strings, all structural refusals, +raw-byte preservation, and absence of mutation. Public Rustdoc and compatibility +documentation must state identifier scope and exact non-claims. Each adapter +must add deterministic output, error, and terminal-control tests. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index ef534d1..582fc64 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -22,6 +22,7 @@ No decisions are currently proposed. | [0009: Experimental system summary](0009-experimental-system-summary.md) | Accepted | Add a read-only owned summary for selected `System.json` metadata and map-position scalars. | | [0010: Experimental player-start validation](0010-experimental-player-start-validation.md) | Accepted | Compose the bounded system, catalog, and selected-map projections into deterministic player-start findings without implying general project validity. | | [0011: Experimental selected-map event catalog](0011-experimental-map-event-catalog.md) | Accepted | Add a read-only owned projection of map-scoped event IDs, names, positions, and opaque page counts for one catalog-selected map. | +| [0012: Experimental tileset catalog](0012-experimental-tileset-catalog.md) | Accepted | Add a read-only owned projection of catalog-scoped tileset IDs and editor-facing names while leaving modes, images, flags, and notes opaque. | ## Adding or changing a decision diff --git a/docs/formats/rpg-maker-mz/README.md b/docs/formats/rpg-maker-mz/README.md index 9750aac..508aa2d 100644 --- a/docs/formats/rpg-maker-mz/README.md +++ b/docs/formats/rpg-maker-mz/README.md @@ -36,6 +36,9 @@ the version actually documented or observed. - [Player-start validation contract](player-start-validation.md) records controlled relocation and unset-state observations and bounds the first contextual cross-file validation slice. +- [Tileset catalog contract](tileset-catalog.md) records the aggregate + `Tilesets.json` identity/name audit while leaving modes, image slots, flags, + and notes opaque. - [Research ledger](research-ledger.md) contains the claim-level evidence, source records, conflicts, and next controlled experiments. diff --git a/docs/formats/rpg-maker-mz/project-layout-coverage.md b/docs/formats/rpg-maker-mz/project-layout-coverage.md index 6a91757..b22f946 100644 --- a/docs/formats/rpg-maker-mz/project-layout-coverage.md +++ b/docs/formats/rpg-maker-mz/project-layout-coverage.md @@ -45,6 +45,7 @@ enumerating every path a project may legally contain. | Fresh-project root directories | **Strong for observed scope** | All four templates originally shared 12 immediate entries and 30 child directories. | Other platforms, versions, editor editions, and post-creation lifecycle additions. | | Standard database filenames and purposes | **Strong at filename/purpose level** | Official references and all templates agree on 14 non-map JSON names, including plural `Skills.json` and `Items.json`. | Requiredness, detailed schemas, edited-project variants, extension fields, and other versions. | | Map file family | **Partial, including catalog lifecycle, aggregate map shape, and bounded event identity/placement** | `MapInfos.json` plus three-digit `MapNNN.json` is documented and observed for 196 maps through ID 189. Controlled lifecycle actions distinguish ID, order, hierarchy, and file identity. All 196 map documents share an object shape; basic metadata kinds, positive dimensions, tileset references, tile-array length relationships, and 1,555 events' IDs, names, coordinates, and opaque page counts have been audited. | Deeper hierarchy, parent deletion, multiple-hole selection, IDs above 999, malformed-input enforcement, tile-layer meaning, page/command semantics, event mutation, and other versions. | +| Tileset database family | **Partial identity/name catalog** | Exact `Tilesets.json` contains 24 observed records across four projects. Positive IDs equal array indexes, names are nonempty strings, and all 196 audited map references resolve. | Mode encoding, image slots, flags, notes, record lifecycle, asset/reference validation, mutation, malformed-input enforcement, and other versions. | | Asset directory families | **Strong at high level** | Official help documents image, audio, and movie purposes; fresh projects establish their default directories. Generated `img/tilesets` also contains 31 stem-paired PNG/`.txt` pairs, whose text purpose is unknown. | Requiredness, companion semantics, filename normalization, collisions, invalid assets, and all plugin-specific resource paths. | | Asset nesting and formats | **One nested import observed; broader behavior partial** | Official help permits browsable subfolders and documents PNG images, Ogg Vorbis audio, and WebM/MP4 movie roles. Resource Manager discovered one manually created directory beneath `img/pictures` and preserved the nested path during PNG import. Fresh trees additionally contain `.efkefc`, `.efkmodel`, WebAssembly, WOFF, and tileset `.txt` files. | Other roots, depth limits, references, extension semantics, Unicode/case behavior, invalid names, symlinks, platforms, versions, and deployment preservation. | | Asset references | **One command-specific representation and deletion observed** | A Show Picture command stores a nested PNG as `tilewright-nested/tilewright-resource-probe`, relative to `img/pictures`, with `/` and no extension. In a controlled-input copy, Resource Manager deleted the PNG silently, retained the empty nested directory, and preserved the dangling string through save and close. | Other commands/fields, roots, formats, separators, extension rules, normalization, runtime resolution, editor-created replication of the deletion input, platforms, and versions. | diff --git a/docs/formats/rpg-maker-mz/project-layout.md b/docs/formats/rpg-maker-mz/project-layout.md index cacee6f..277108f 100644 --- a/docs/formats/rpg-maker-mz/project-layout.md +++ b/docs/formats/rpg-maker-mz/project-layout.md @@ -165,7 +165,7 @@ The tree is an inventory of documented locations, not a required-file schema. | `data/Troops.json` | **Documented:** enemy groups and battle events | Detailed cross-file references are unknown. | | `data/States.json` | **Documented:** character/enemy states | Requiredness and JSON contract are unknown. | | `data/Animations.json` | **Documented:** animation database data | MZ-native versus imported MV-compatible records and related assets are not classified here. | -| `data/Tilesets.json` | **Documented:** tileset composition used by maps | Filename references, indices, and map linkage are unknown. | +| `data/Tilesets.json` | **Documented and observed:** tileset composition used by maps | All 24 observed records use positive ID/index equality and nonempty editor names; all 196 observed map references resolve. Modes, image slots, flags, notes, lifecycle behavior, and editor enforcement remain outside the bounded catalog. | | `data/CommonEvents.json` | **Documented:** common event processing | Command encoding and references are outside this investigation. | | `data/System.json` | **Documented:** system settings plus terms, types, switch names, and variable names | It must not be treated as a proven version/magic file. | | `data/MapInfos.json` | **Documented and observed:** map information including IDs, names, ordering, and parent relationships in the version 1.0.0 reference; an array layout in all four MZ 1.10.0 projects | Controlled additions, deletion, reuse, and reorder distinguish ID/index from compact `order`. Child creation sets `parentId`, inserts after the parent, and initially expands that parent. Reparenting changes `parentId` and compact order without changing IDs/files or either parent's existing `expanded` value. Multiple children, deeper hierarchy, multiple-hole selection, inconsistent inputs, and other versions remain unknown. Objects in the observed array do not all have identical keys. | diff --git a/docs/formats/rpg-maker-mz/research-ledger.md b/docs/formats/rpg-maker-mz/research-ledger.md index 155d4cc..ed2c06f 100644 --- a/docs/formats/rpg-maker-mz/research-ledger.md +++ b/docs/formats/rpg-maker-mz/research-ledger.md @@ -1654,6 +1654,98 @@ copy, save and reopen it, and compare the exact persisted fields. A separate tileset change can then isolate that reference. Event structure should remain a separate investigation before any event fields are typed. +## `mz-tileset-catalog-001`: What is the smallest useful tileset catalog? + +- **Status:** Active +- **Behavior depending on this:** Read-only resolution of tileset IDs to + editor-facing names without interpreting tile behavior or assets. +- **Scope:** Exact `data/Tilesets.json` in four RPG Maker MZ 1.10.0 projects and + the `tilesetId` scalars in 196 map documents. Modes, image slots, flags, + notes, lifecycle operations, mutation, persistence, malformed-input editor + behavior, and later versions remain outside this investigation. +- **Last updated:** 2026-08-09 + +### Evidence ledger + +The claim-level ledger, bounded contract, fixture implications, and remaining +experiments are maintained in +[`tileset-catalog.md`](tileset-catalog.md#evidence-ledger). + +### Evidence record: `MZ-HELP-TILESET-SETTINGS-2026-08-09` + +- **Kind:** Official editor documentation. +- **Source:** *Tileset Settings*, RPG Maker MZ Help, + , and *Map + Properties*, + ; accessed + 2026-08-09. +- **Documented:** Tilesets combine tile images and behavior settings and can be + assigned to maps. The editor-facing Name is distinct from Mode, Images, tile + settings, and Notes. +- **Limits:** The help does not define JSON property names, identifier/index + encoding, numeric mode mapping, array lengths, malformed-input behavior, or + persistence fidelity. + +### Evidence record: `MZ-1.10.0-TILESET-SHAPE-AUDIT-2026-08-09` + +- **Kind:** Read-only aggregate shape and cross-file audit. +- **Version/environment:** The four authorized, user-owned MZ 1.10.0 projects + recorded by `MZ-1.10.0-FRESH-4-2026-08-01`; `jq` 1.8.2 on arm64 macOS 26.6 + build 25G72. +- **Procedure:** Verified the canonical ignored research root, source + containment, and absence of symlinks. Queried only decoded property names, + JSON kinds, counts, ID/index equality, array lengths, and map-reference + existence. The audit emitted aggregate results only. +- **Observed:** Each project has a seven-entry array with null at index zero and + six object records. All 24 records share keys `flags`, `id`, `mode`, `name`, + `note`, and `tilesetNames`; IDs 1 through 6 equal their indexes and names are + nonempty strings. Notes are empty strings. Modes use two integer values, + image-name arrays contain nine strings, and flag arrays contain 8,192 + integers. All 196 positive map references, using IDs 1 through 4, resolve to + records in their containing project. +- **Limits:** Decoded duplicate properties were not established absent. The + audit does not establish mode mapping, image-slot meaning, flag encoding, + allowed lengths, record lifecycle, editor validation, mutation fidelity, or + other-version behavior. +- **Redistribution:** No tileset name, note, asset name, flag, map path, project + path, raw document, excerpt, hash, or per-project manifest is retained. Only + aggregate derived observations and the safe procedure are recorded. + +### Implementation implications + +The experimental core projection can expose positive catalog-scoped +`TilesetId` values and decoded names while retaining every other field in the +raw snapshot. It must accept null holes, refuse ambiguous required structure, +and avoid interpreting mode, image slots, flags, notes, or map-reference +validity. The proposed architecture is recorded in +[ADR 0012](../../decisions/0012-experimental-tileset-catalog.md). + +### Evidence record: `MZ-1.10.0-TILESET-DIFFERENTIAL-2026-08-09` + +- **Kind:** Read-only differential projection and CLI-envelope audit. +- **Version/environment:** The same four authorized MZ 1.10.0 projects; the + local proposed tileset-catalog implementation on 2026-08-09; Rust 1.97.1 and + `jq` 1.8.2 on arm64 macOS 26.6 build 25G72. +- **Procedure:** Ran `tilesets --format json` once per project and independently + decoded exact `Tilesets.json` with `jq`. Compared every record ID and decoded + name plus schema version, snapshot completeness, diagnostic counts/lists, and + record count. It emitted aggregate counts only. +- **Observed:** All four projects, 24 records, and 72 individual comparisons + matched. Every snapshot was complete and every report had zero diagnostics. +- **Limits:** This verifies one implementation on four MZ-generated 1.10.0 + states. It does not establish adjacent-field semantics, record lifecycle, + editor validation, mutation or save/reopen fidelity, malformed-input + behavior, or later-version compatibility. +- **Redistribution:** No project path, tileset name, note, asset name, flag, raw + document, excerpt, field value, report, hash, or per-project manifest is + retained. Only aggregate derived observations and the non-content-revealing + procedure are recorded. + +### Next experiment + +Change one tileset name, toggle one mode, and reassign one map in separate +disposable MZ 1.10.0 copies, saving and reopening after each action. + ## `mz-map-events-001`: What is the smallest useful map-event catalog? - **Status:** Active @@ -1724,8 +1816,8 @@ findings from editor-validity claims. The proposed architecture is recorded in - **Kind:** Read-only differential projection and CLI-envelope audit. - **Version/environment:** The same four authorized MZ 1.10.0 projects; the - local experimental event-catalog implementation on 2026-08-09; Rust 1.97.1 and - `jq` 1.8.2 on arm64 macOS 26.6 build 25G72. + local experimental event-catalog implementation on 2026-08-09; Rust 1.97.1 + and `jq` 1.8.2 on arm64 macOS 26.6 build 25G72. - **Procedure:** Ran the bounded `events --format json` adapter for every coherent catalog record. Independently decoded each map-info and map document with `jq`, then compared map ID, catalog name, exact evidenced path, diff --git a/docs/formats/rpg-maker-mz/tileset-catalog.md b/docs/formats/rpg-maker-mz/tileset-catalog.md new file mode 100644 index 0000000..c832880 --- /dev/null +++ b/docs/formats/rpg-maker-mz/tileset-catalog.md @@ -0,0 +1,129 @@ +# RPG Maker MZ tileset catalog contract + +This document defines the evidence boundary for a small read-only projection of +tileset identity and editor-facing names from exact `data/Tilesets.json`. It is +a research result and accepted experimental contract, not a support claim. + +## Question and scope + +What is the smallest tileset projection that lets callers resolve the positive +`tilesetId` scalar already exposed by selected-map summaries without typing tile +flags, image slots, modes, or notes? + +Direct observations cover 24 tileset records in four user-owned projects +created by RPG Maker MZ 1.10.0 and 196 map references. Official MZ help +separately documents tilesets, their editor-facing names, modes, images, tile +settings, and assignment to maps. Later versions, record lifecycle, malformed +input, and editor persistence remain unknown. + +## Evidence ledger + +| Claim | Evidence | Classification | Confidence | Remaining uncertainty | +| --- | --- | --- | --- | --- | +| Tilesets are database records used to design maps, with editor-only names and separately configured modes, images, tile settings, and notes. | `MZ-HELP-TILESET-SETTINGS-2026-08-09` | Documented | High for editor concepts | The help does not define JSON encoding or malformed-input behavior. | +| Each audited `Tilesets.json` root is a seven-entry array with null at index zero and six object records. | `MZ-1.10.0-TILESET-SHAPE-AUDIT-2026-08-09` | Observed | High across all four projects | Record-count changes and lifecycle operations were not observed. | +| Every observed record has exactly `flags`, `id`, `mode`, `name`, `note`, and `tilesetNames`; IDs are positive integers equal to array indexes and names are nonempty strings. | Shape audit | Observed | High across 24 records | Plugins or later versions may add fields; decoded duplicate keys were not established absent. | +| Every audited map's positive `tilesetId` resolves to an ID/index-consistent record in its project's tileset array. | Shape audit and 196-map cross-file comparison | Observed; reference meaning Documented and Inferred | High in the audited scope | Missing-reference editor behavior and tileset changes remain unknown. | +| Observed `mode` values are integers 0 or 1, `tilesetNames` has nine strings, and `flags` has 8,192 integers. | Shape audit | Observed syntax only | High in the audited scope | Numeric mode mapping, slot meaning, flag encoding, allowed lengths, and mutation rules remain unestablished. | +| Tilewright's tileset catalog matches an independent direct extraction of every bounded record and CLI-envelope field. | `MZ-1.10.0-TILESET-DIFFERENTIAL-2026-08-09` | Observed | High across all four projects and 24 records | This does not test editor mutation, malformed input, adjacent fields, or later versions. | + +## Aggregate shape audit + +On 2026-08-09, a read-only aggregate audit inspected exact +`data/Tilesets.json` and 196 map documents from the four authorized projects +recorded under `MZ-1.10.0-FRESH-4-2026-08-01`. It verified source containment +and absence of source symlinks before using `jq` 1.8.2. + +The audit queried only decoded property names, JSON kinds, counts, integer +relationships and ranges, array lengths, and cross-file identifier existence. +It emitted no tileset names, notes, asset names, flags, map paths, project paths, +raw documents, excerpts, hashes, or per-project manifests. + +The audit observed: + +- four seven-entry arrays, each with one null slot and six object records; +- 24 records with one shared key set: `flags`, `id`, `mode`, `name`, `note`, and + `tilesetNames`; +- positive integer IDs 1 through 6, all equal to their array indexes; +- nonempty string names and empty string notes in all 24 records; +- integer modes using two distinct values, without establishing their mapping; +- nine-string `tilesetNames` arrays and 8,192-integer `flags` arrays; and +- 196 positive map references using IDs 1 through 4, all resolving to records + in their containing projects. + +These are observations of MZ-generated states, not editor validation rules. + +## Differential projection audit + +On 2026-08-09, the tileset catalog and its JSON CLI adapter were run read-only +against the same four authorized projects. An independent `jq` extraction +compared every record ID and decoded name plus the schema, snapshot completeness, +diagnostic, and record-count envelope. + +All four projects, all 24 records, and all 72 individual comparisons matched +exactly. Every snapshot was complete and every report had zero diagnostics. The +audit emitted only aggregate counts and booleans. + +This verifies one implementation against independently decoded MZ-generated +data. It does not establish mode, image-slot, flag, note, lifecycle, editor +validation, mutation, persistence, or later-version behavior. No project path, +tileset name, note, asset name, flag, raw document, excerpt, field value, report, +hash, or per-project manifest was retained. + +## Official documentation + +The official *Tileset Settings* help page describes tilesets as the tile-image +and behavior configurations assigned to maps. It documents an editor-only Name +separately from Mode, Images, tile settings, and Notes. The official *Map +Properties* help page documents selection of a tileset for a map. + +- *Tileset Settings*, RPG Maker MZ Help, + , accessed + 2026-08-09. +- *Map Properties*, RPG Maker MZ Help, + , accessed + 2026-08-09. + +The help documents concepts, not JSON property names or exact serialization. + +## Accepted bounded typed contract + +The experimental slice should accept an existing `ProjectSnapshot` and project +exact `data/Tilesets.json` into positive catalog-scoped IDs and decoded names, +ordered by ID. It should accept null holes, require each non-null record's ID to +equal its array index, and refuse missing, duplicate, wrong-kind, malformed, or +unsupported required values. + +The operation should not require or interpret `mode`, `tilesetNames`, `flags`, +`note`, or unknown fields. Those values and exact bytes remain only in the raw +snapshot. A `TilesetId` is an experimental tileset-catalog identifier, not yet +a stable project-wide resource identity. + +Map-to-tileset reference validation remains a separate operation. The catalog +introduces no mutation, serialization, persistence, asset validation, runtime, +or editor-compatibility behavior. + +## Fixture implications + +Tests can generate minimal strict JSON in temporary project trees. The matrix +should include null holes, ID ordering, decoded names, unknown fields, missing +and unavailable documents, non-array roots, non-object entries, every required +field ambiguity, integer and index boundaries, ID/index mismatch, raw-byte +preservation, and absence of mutation. + +Generated fixtures are Tilewright-owned test inputs only and do not establish +that MZ accepts or rejects malformed states. + +## Remaining unknowns and next experiments + +- Change only one tileset name and confirm the exact persisted field after save + and reopen. +- Toggle one tileset mode and establish the numeric mapping without inferring it + from stock names. +- Change one map's selected tileset and confirm the cross-file identifier + relationship after save and reopen. +- Audit tileset record creation, deletion, maximum changes, and hole reuse only + when those behaviors are needed. +- Investigate image slots and flags as separate capabilities before typing them. +- Observe another named MZ version at or above 1.10.0 before generalizing the + contract. diff --git a/docs/open-questions.md b/docs/open-questions.md index 09aadb6..bc6ac29 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -57,9 +57,10 @@ unknown and plugin-defined content. The following details remain open: - How should stale-document or stale-node detection be handled? - What constitutes a stable project or resource identifier? ADR 0007 introduces an experimental positive `MapId` scoped to map-catalog records, - not a stable project-wide identity scheme. ADR 0011 similarly - introduces a `MapEventId` scoped to one selected map. ADR 0009 deliberately - retains system map fields as unvalidated `u32` scalars. + not a stable project-wide identity scheme. ADR 0011 similarly introduces a + `MapEventId` scoped to one selected map, while ADR 0012 introduces a + `TilesetId` scoped to one catalog. ADR 0009 deliberately retains system map + fields as unvalidated `u32` scalars. - How should event-command parameter arrays be typed incrementally? - How should validation findings, severities, source locations, and related diagnostics be represented? diff --git a/docs/safety.md b/docs/safety.md index fe2948d..62c4835 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -23,13 +23,14 @@ The requirement to avoid silent loss is established. The mechanism uses the CST/raw-storage-plus-typed-view architectural direction accepted in [ADR 0004](decisions/0004-lossless-json-representation.md). The first immutable `LosslessJsonDocument` retains accepted source bytes exactly and exposes no -mutation surface. The experimental map catalog, selected-map summary, -selected-map event catalog, and system summary copy bounded typed values from -retained CSTs without changing raw bytes. Event pages, commands, notes, and -unknown fields remain in the raw snapshot. The experimental player-start -validator reads those projections and also leaves every raw document unchanged. -Production typed-view ownership and operation-specific preservation or refusal -contracts remain unresolved or unimplemented. +mutation surface. The experimental map, tileset, and selected-map event +catalogs plus the selected-map and system summaries copy bounded typed values +from retained CSTs without changing raw bytes. Tileset modes, image slots, +flags, notes, event pages, commands, and unknown fields remain in the raw +snapshot. The experimental player-start validator reads those projections and +also leaves every raw document unchanged. Production typed-view ownership and +operation-specific preservation or refusal contracts remain unresolved or +unimplemented. ## Evidence limits writes